Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import org.knora.webapi.slice.admin.AdminModule
import org.knora.webapi.slice.admin.api.*
import org.knora.webapi.slice.admin.domain.service.*
import org.knora.webapi.slice.api.v2.ApiV2ServerEndpoints
import org.knora.webapi.slice.api.v3.ApiV3Module
import org.knora.webapi.slice.api.v3.ApiV3ServerEndpoints
import org.knora.webapi.slice.common.ApiComplexV2JsonLdRequestParser
import org.knora.webapi.slice.common.CommonModule
import org.knora.webapi.slice.common.CommonModule.Provided
Expand Down Expand Up @@ -136,6 +138,7 @@ object LayersLive { self =>
ApiComplexV2JsonLdRequestParser.layer,
ApiV2Endpoints.layer,
ApiV2ServerEndpoints.layer,
ApiV3Module.layer,
AssetPermissionsResponder.layer,
AuthenticationApiModule.layer,
AuthorizationRestService.layer,
Expand Down
19 changes: 11 additions & 8 deletions webapi/src/main/scala/org/knora/webapi/routing/Endpoints.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,23 @@ import zio.*
import org.knora.webapi.routing
import org.knora.webapi.slice.admin.api.AdminApiServerEndpoints
import org.knora.webapi.slice.api.v2.ApiV2ServerEndpoints
import org.knora.webapi.slice.api.v3.ApiV3ServerEndpoints
import org.knora.webapi.slice.infrastructure.api.ManagementServerEndpoints
import org.knora.webapi.slice.shacl.api.ShaclServerEndpoints

final case class Endpoints(
adminApiServerEndpoints: AdminApiServerEndpoints, // admin api
apiV2ServerEndpoints: ApiV2ServerEndpoints,
shaclServerEndpoints: ShaclServerEndpoints,
managementServerEndpoints: ManagementServerEndpoints,
private val adminApi: AdminApiServerEndpoints, // admin api
private val apiV2: ApiV2ServerEndpoints,
private val apiV3: ApiV3ServerEndpoints,
private val shacl: ShaclServerEndpoints,
private val management: ManagementServerEndpoints,
) {
val serverEndpoints: List[ZServerEndpoint[Any, ZioStreams]] =
adminApiServerEndpoints.serverEndpoints ++
apiV2ServerEndpoints.serverEndpoints ++
managementServerEndpoints.serverEndpoints ++
shaclServerEndpoints.serverEndpoints
adminApi.serverEndpoints ++
apiV2.serverEndpoints ++
apiV3.serverEndpoints ++
management.serverEndpoints ++
shacl.serverEndpoints
}
object Endpoints {
val layer = ZLayer.derive[Endpoints]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright © 2021 - 2025 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package org.knora.webapi.slice.api.v3

import zio.*

import org.knora.webapi.slice.security.Authenticator

object ApiV3Module {

type Dependencies = Authenticator
type Provided = ApiV3ServerEndpoints

val layer: URLayer[Dependencies, ApiV3ServerEndpoints] =
V3BaseEndpoint.layer >>> ApiV3ServerEndpoints.layer
}

object ApiV3 {
val basePath = "v3"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright © 2021 - 2025 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package org.knora.webapi.slice.api.v3

import sttp.tapir.ztapir.*
import zio.*

final case class ApiV3ServerEndpoints() {
val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List.empty
}

object ApiV3ServerEndpoints {
val layer = ZLayer.derive[ApiV3ServerEndpoints]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright © 2021 - 2025 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package org.knora.webapi.slice.api.v3

import sttp.model.StatusCode
import sttp.model.headers.WWWAuthenticateChallenge
import sttp.tapir.EndpointOutput
import sttp.tapir.PublicEndpoint
import sttp.tapir.Validator
import sttp.tapir.generic.auto.*
import sttp.tapir.json.zio.jsonBody
import sttp.tapir.ztapir.*
import zio.*
import zio.json.JsonEncoder

import org.knora.webapi.messages.util.KnoraSystemInstances.Users.AnonymousUser
import org.knora.webapi.slice.admin.domain.model.*
import org.knora.webapi.slice.api.v3.V3ErrorInfo.*
import org.knora.webapi.slice.security.Authenticator
import org.knora.webapi.slice.security.AuthenticatorError.*

final case class V3BaseEndpoint(private val authenticator: Authenticator) {

private val defaultErrorOut: EndpointOutput.OneOf[V3ErrorInfo, V3ErrorInfo] =
oneOf[V3ErrorInfo](
// default
oneOfVariant(statusCode(StatusCode.NotFound).and(jsonBody[NotFound])),
oneOfVariant(statusCode(StatusCode.BadRequest).and(jsonBody[BadRequest])),
)

private val secureErrorOut = oneOf[V3ErrorInfo](
// default
oneOfVariant(statusCode(StatusCode.NotFound).and(jsonBody[NotFound])),
oneOfVariant(statusCode(StatusCode.BadRequest).and(jsonBody[BadRequest])),
// plus security
oneOfVariant(statusCode(StatusCode.Unauthorized).and(jsonBody[Unauthorized])),
oneOfVariant(statusCode(StatusCode.Forbidden).and(jsonBody[Forbidden])),
)

val publicEndpoint: PublicEndpoint[Unit, V3ErrorInfo, Unit, Any] = endpoint.errorOut(defaultErrorOut)

private val endpointWithSecureErrorOut = endpoint.errorOut(secureErrorOut)

val securedEndpoint: ZPartialServerEndpoint[Any, String, User, Unit, V3ErrorInfo, Unit, Any] =
endpointWithSecureErrorOut
.securityIn(auth.bearer[String](WWWAuthenticateChallenge.bearer))
.zServerSecurityLogic(handleBearerJwt)

val withUserEndpoint: ZPartialServerEndpoint[Any, Option[String], User, Unit, V3ErrorInfo, Unit, Any] =
endpointWithSecureErrorOut
.securityIn(auth.bearer[Option[String]](WWWAuthenticateChallenge.bearer))
.zServerSecurityLogic {
case Some(jwt) => handleBearerJwt(jwt)
case _ => ZIO.succeed(AnonymousUser)
}

private def handleBearerJwt(jwt: String): IO[V3ErrorInfo, User] =
authenticator.authenticate(jwt).mapError {
case BadCredentials => Unauthorized("Invalid token.")
case UserNotFound => NotFound("User not found.")
case UserNotActive => Forbidden("User not active.")
}
}

object V3BaseEndpoint {
val layer = ZLayer.derive[V3BaseEndpoint]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright © 2021 - 2025 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package org.knora.webapi.slice.api.v3
import zio.json.JsonCodec

enum V3ErrorCode:
case resource_not_found

object V3ErrorCode:
given JsonCodec[V3ErrorCode] = JsonCodec.string
.transformOrFail(
str => V3ErrorCode.values.find(_.toString == str).toRight(s"Unknown V3ErrorCode: $str"),
_.toString.toLowerCase,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright © 2021 - 2025 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package org.knora.webapi.slice.api.v3

import zio.Chunk
import zio.json.*

import org.knora.webapi.slice.common.KnoraIris.ResourceIri

sealed trait V3ErrorInfo {
def message: String
def errors: Chunk[ErrorDetail]
}

case class NotFound(message: String = "Not Found", errors: Chunk[ErrorDetail] = Chunk.empty) extends V3ErrorInfo
object NotFound {

private def singleError(code: V3ErrorCode, message: String, details: Map[String, String] = Map.empty): NotFound =
NotFound(message, Chunk(ErrorDetail(code, message, details)))

def from(resourceIri: ResourceIri): NotFound =
singleError(
V3ErrorCode.resource_not_found,
s"The resource with IRI '$resourceIri' was not found.",
Map("resourceIri" -> resourceIri.toString),
)
}

case class BadRequest(message: String = "Bad Request", errors: Chunk[ErrorDetail] = Chunk.empty) extends V3ErrorInfo

final case class Unauthorized(message: String = "Unauthorized", errors: Chunk[ErrorDetail] = Chunk.empty)
extends V3ErrorInfo
final case class Forbidden(message: String = "Forbidden", errors: Chunk[ErrorDetail] = Chunk.empty) extends V3ErrorInfo

final case class ErrorDetail(
code: V3ErrorCode,
message: String,
details: Map[String, String] = Map.empty,
)
object ErrorDetail {
given errorDetailEncoder: JsonCodec[ErrorDetail] = DeriveJsonCodec.gen[ErrorDetail]
}

object V3ErrorInfo {
given notFoundEncoder: JsonCodec[NotFound] = DeriveJsonCodec.gen[NotFound]
given badRequestEncoder: JsonCodec[BadRequest] = DeriveJsonCodec.gen[BadRequest]
given unauthorizedEncoder: JsonCodec[Unauthorized] = DeriveJsonCodec.gen[Unauthorized]
given forbiddenEncoder: JsonCodec[Forbidden] = DeriveJsonCodec.gen[Forbidden]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright © 2021 - 2025 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package org.knora.webapi.slice.api.v3
import zio.*
import zio.json.*
import zio.test.*

import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.slice.common.service.IriConverter

object NotFoundSpec extends ZIOSpecDefault {
override val spec = suite("NotFoundSpec")(
test("NotFound.from(ResourceIri) should create a NotFound instance with the correct message and error details") {
for {
resourceIri <- ZIO.serviceWithZIO[IriConverter](_.asResourceIri("http://rdfh.ch/0001/abcd1234"))
actual = NotFound.from(resourceIri)
} yield assertTrue(
actual.toJsonPretty == """{
| "message" : "The resource with IRI 'http://rdfh.ch/0001/abcd1234' was not found.",
| "errors" : [
| {
| "code" : "resource_not_found",
| "message" : "The resource with IRI 'http://rdfh.ch/0001/abcd1234' was not found.",
| "details" : {
| "resourceIri" : "http://rdfh.ch/0001/abcd1234"
| }
| }
| ]
|}""".stripMargin,
)
},
).provide(IriConverter.layer, StringFormatter.test)
}