Skip to content

Commit 05e5494

Browse files
committed
feat(search): Add API and route for searching runs with a package
Signed-off-by: Jyrki Keisala <[email protected]>
1 parent 5ea8929 commit 05e5494

File tree

8 files changed

+608
-0
lines changed

8 files changed

+608
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.eclipse.apoapsis.ortserver.components.search
21+
22+
import io.ktor.server.routing.Route
23+
24+
import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService
25+
import org.eclipse.apoapsis.ortserver.components.search.routes.getRunsWithPackage
26+
27+
/** Add all package-search routes. */
28+
fun Route.searchRoutes(searchService: SearchService) {
29+
getRunsWithPackage(searchService)
30+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.eclipse.apoapsis.ortserver.components.search.routes
21+
22+
import io.github.smiley4.ktoropenapi.get
23+
24+
import io.ktor.http.HttpStatusCode
25+
import io.ktor.server.response.respond
26+
import io.ktor.server.routing.Route
27+
28+
import kotlinx.datetime.Clock
29+
30+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.OrganizationPermission
31+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.ProductPermission
32+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission
33+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission
34+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requireSuperuser
35+
import org.eclipse.apoapsis.ortserver.components.search.apimodel.RunWithPackage
36+
import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService
37+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody
38+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
39+
40+
@Suppress("LongMethod")
41+
internal fun Route.getRunsWithPackage(searchService: SearchService) =
42+
get("/search/package", {
43+
operationId = "getRunsWithPackage"
44+
summary = "Return ORT runs containing a package, possibly scoped by organization, product, and repository"
45+
tags = listOf("Search")
46+
47+
request {
48+
queryParameter<String>("identifier") {
49+
description = "The package identifier to search for. Also RegEx supported."
50+
required = true
51+
}
52+
queryParameter<Long?>("organizationId") {
53+
description = "Optional organization ID to filter the search."
54+
}
55+
queryParameter<Long?>("productId") {
56+
description = "Optional product ID to filter the search."
57+
}
58+
queryParameter<Long?>("repositoryId") {
59+
description = "Optional repository ID to filter the search."
60+
}
61+
}
62+
63+
response {
64+
HttpStatusCode.OK to {
65+
description = "Success"
66+
jsonBody<List<RunWithPackage>> {
67+
example("Package Search Result") {
68+
value = listOf(
69+
RunWithPackage(
70+
organizationId = 1L,
71+
productId = 2L,
72+
repositoryId = 3L,
73+
ortRunId = 42L,
74+
revision = "a1b2c3d4",
75+
createdAt = Clock.System.now(),
76+
packageId = "Maven:foo:bar:1.0.0"
77+
),
78+
RunWithPackage(
79+
organizationId = 1L,
80+
productId = 3L,
81+
repositoryId = 7L,
82+
ortRunId = 120L,
83+
revision = "a1b2c3d4",
84+
createdAt = Clock.System.now(),
85+
packageId = "Maven:foo:bar:1.0.0"
86+
)
87+
)
88+
}
89+
}
90+
}
91+
}
92+
}) {
93+
val identifierParam = call.requireParameter("identifier")
94+
val organizationIdParam = call.request.queryParameters["organizationId"]?.toLongOrNull()
95+
val productIdParam = call.request.queryParameters["productId"]?.toLongOrNull()
96+
val repositoryIdParam = call.request.queryParameters["repositoryId"]?.toLongOrNull()
97+
98+
if (repositoryIdParam != null && (productIdParam == null || organizationIdParam == null)) {
99+
return@get call.respond(
100+
HttpStatusCode.BadRequest,
101+
"A repository ID requires a product and an organization ID."
102+
)
103+
}
104+
if (productIdParam != null && organizationIdParam == null) {
105+
return@get call.respond(
106+
HttpStatusCode.BadRequest,
107+
"A product ID requires an organization ID."
108+
)
109+
}
110+
111+
if (repositoryIdParam != null) {
112+
requirePermission(RepositoryPermission.READ.roleName(repositoryIdParam))
113+
} else if (productIdParam != null) {
114+
requirePermission(ProductPermission.READ.roleName(productIdParam))
115+
} else if (organizationIdParam != null) {
116+
requirePermission(OrganizationPermission.READ.roleName(organizationIdParam))
117+
} else {
118+
requireSuperuser()
119+
}
120+
121+
val ortRuns = searchService.findOrtRunsByPackage(
122+
identifier = identifierParam,
123+
organizationId = organizationIdParam,
124+
productId = productIdParam,
125+
repositoryId = repositoryIdParam
126+
)
127+
call.respond(HttpStatusCode.OK, ortRuns)
128+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package ort.eclipse.apoapsis.ortserver.components.search
21+
22+
import io.ktor.client.HttpClient
23+
import io.ktor.server.testing.ApplicationTestBuilder
24+
25+
import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService
26+
import org.eclipse.apoapsis.ortserver.components.search.searchRoutes
27+
import org.eclipse.apoapsis.ortserver.dao.test.Fixtures
28+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
29+
30+
import org.jetbrains.exposed.sql.Database
31+
32+
/** An [AbstractIntegrationTest] pre-configured for testing the search routes. */
33+
@Suppress("UnnecessaryAbstractClass")
34+
abstract class SearchIntegrationTest(
35+
body: SearchIntegrationTest.() -> Unit
36+
) : AbstractIntegrationTest({}) {
37+
lateinit var searchService: SearchService
38+
39+
private lateinit var db: Database
40+
private lateinit var fixtures: Fixtures
41+
42+
init {
43+
beforeEach {
44+
db = dbExtension.db
45+
fixtures = dbExtension.fixtures
46+
searchService = SearchService(db)
47+
}
48+
49+
body()
50+
}
51+
52+
fun searchTestApplication(
53+
block: suspend ApplicationTestBuilder.(client: HttpClient) -> Unit
54+
) = integrationTestApplication(
55+
routes = { searchRoutes(searchService) },
56+
block = block
57+
)
58+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package ort.eclipse.apoapsis.ortserver.components.search.routes
21+
22+
import io.ktor.client.request.get
23+
import io.ktor.client.request.parameter
24+
import io.ktor.http.HttpStatusCode
25+
26+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.OrganizationPermission
27+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.ProductPermission
28+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission
29+
import org.eclipse.apoapsis.ortserver.components.search.apimodel.RunWithPackage
30+
import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService
31+
import org.eclipse.apoapsis.ortserver.components.search.searchRoutes
32+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractAuthorizationTest
33+
34+
import ort.eclipse.apoapsis.ortserver.components.search.createRunWithPackage
35+
36+
class GetRunsWithPackageAuthorizationTest : AbstractAuthorizationTest({
37+
lateinit var searchService: SearchService
38+
lateinit var runWithPackage: RunWithPackage
39+
40+
beforeEach {
41+
searchService = SearchService(dbExtension.db)
42+
runWithPackage = createRunWithPackage(
43+
fixtures = dbExtension.fixtures,
44+
repoId = dbExtension.fixtures.repository.id
45+
)
46+
authorizationService.ensureSuperuserAndSynchronizeRolesAndPermissions()
47+
}
48+
49+
"GetRunsWithPackage" should {
50+
"require superuser role for unscoped searches" {
51+
requestShouldRequireSuperuser(
52+
routes = { searchRoutes(searchService) },
53+
successStatus = HttpStatusCode.OK
54+
) {
55+
get("/search/package") {
56+
parameter("identifier", runWithPackage.packageId)
57+
}
58+
}
59+
}
60+
61+
"require organization permission when scoped to organization" {
62+
val organizationRole = OrganizationPermission.READ.roleName(runWithPackage.organizationId)
63+
64+
requestShouldRequireRole(
65+
routes = { searchRoutes(searchService) },
66+
role = organizationRole,
67+
successStatus = HttpStatusCode.OK
68+
) {
69+
get("/search/package") {
70+
parameter("identifier", runWithPackage.packageId)
71+
parameter("organizationId", runWithPackage.organizationId.toString())
72+
}
73+
}
74+
}
75+
76+
"require product permission when scoped to product" {
77+
val productRole = ProductPermission.READ.roleName(runWithPackage.productId)
78+
79+
requestShouldRequireRole(
80+
routes = { searchRoutes(searchService) },
81+
role = productRole,
82+
successStatus = HttpStatusCode.OK
83+
) {
84+
get("/search/package") {
85+
parameter("identifier", runWithPackage.packageId)
86+
parameter("organizationId", runWithPackage.organizationId.toString())
87+
parameter("productId", runWithPackage.productId.toString())
88+
}
89+
}
90+
}
91+
92+
"require repository permission when scoped to repository" {
93+
val repositoryRole = RepositoryPermission.READ.roleName(runWithPackage.repositoryId)
94+
95+
requestShouldRequireRole(
96+
routes = { searchRoutes(searchService) },
97+
role = repositoryRole,
98+
successStatus = HttpStatusCode.OK
99+
) {
100+
get("/search/package") {
101+
parameter("identifier", runWithPackage.packageId)
102+
parameter("organizationId", runWithPackage.organizationId.toString())
103+
parameter("productId", runWithPackage.productId.toString())
104+
parameter("repositoryId", runWithPackage.repositoryId.toString())
105+
}
106+
}
107+
}
108+
}
109+
})

0 commit comments

Comments
 (0)