Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure file errors are written correctly #4346

Merged
merged 8 commits into from
Oct 10, 2023
Merged
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
@@ -1,6 +1,11 @@
package ch.epfl.bluebrain.nexus.delta.sdk

import ch.epfl.bluebrain.nexus.delta.rdf.RdfError
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdOptions}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.{CompactedJsonLd, ExpandedJsonLd}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder
import monix.bio.IO

/**
* A definition of a value that can be converted to JSONLD
Expand All @@ -24,4 +29,18 @@ object JsonLdValue {
override val value: A = v
override val encoder: JsonLdEncoder[A] = implicitly[JsonLdEncoder[A]]
}

implicit val jsonLdEncoder: JsonLdEncoder[JsonLdValue] = {
new JsonLdEncoder[JsonLdValue] {
override def context(value: JsonLdValue): ContextValue = value.encoder.context(value.value)
override def expand(
value: JsonLdValue
)(implicit opts: JsonLdOptions, api: JsonLdApi, rcr: RemoteContextResolution): IO[RdfError, ExpandedJsonLd] =
value.encoder.expand(value.value)
override def compact(
value: JsonLdValue
)(implicit opts: JsonLdOptions, api: JsonLdApi, rcr: RemoteContextResolution): IO[RdfError, CompactedJsonLd] =
value.encoder.compact(value.value)
}
}
Comment on lines +33 to +45
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JsonLdValue is a slightly strange class. I can see why we have it, but I think also this typeclass makes sense for it. The alternative is that we do some wrangling in ResponseToJsonLd, but I think this makes more sense

}
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,9 @@ object CatsResponseToJsonLd extends FileBytesInstances {
}

onSuccess(flattened.unsafeToFuture()) {
case Left(complete: Complete[E]) => emit(complete)
case Left(reject: Reject[E]) => emit(reject)
case Right(Left(c)) =>
implicit val valueEncoder = c.value.encoder
emit(c.value.value)

case Left(complete: Complete[E]) => emit(complete)
case Left(reject: Reject[E]) => emit(reject)
case Right(Left(c)) => emit(c)
case Right(Right((metadata, content))) =>
headerValueByType(Accept) { accept =>
if (accept.mediaRanges.exists(_.matches(metadata.contentType.mediaType))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,9 @@ object ResponseToJsonLd extends FileBytesInstances {
override def apply(statusOverride: Option[StatusCode]): Route = {
val flattened = io.flatMap { fr => fr.content.attempt.map(_.map { s => fr.metadata -> s }) }.attempt
onSuccess(flattened.runToFuture) {
case Left(complete: Complete[E]) => emit(complete)
case Left(reject: Reject[E]) => emit(reject)
case Right(Left(c)) =>
implicit val valueEncoder = c.value.encoder
emit(c.value.value)

case Left(complete: Complete[E]) => emit(complete)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is currently duplicated for the Cats migration, we need to keep the two of them in sync

Copy link
Contributor Author

@shinyhappydan shinyhappydan Oct 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I port the FileRoutes across to cats? I don't feel great about this being untested

Copy link
Contributor Author

@shinyhappydan shinyhappydan Oct 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps now I'm unit testing this class, I can test both 🤔

case Left(reject: Reject[E]) => emit(reject)
case Right(Left(c)) => emit(c)
case Right(Right((metadata, content))) =>
headerValueByType(Accept) { accept =>
if (accept.mediaRanges.exists(_.matches(metadata.contentType.mediaType))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ object AuthTokenError {
final case class AuthTokenNotFoundInResponse(failure: DecodingFailure)
extends AuthTokenError(s"Auth token not found in auth response: ${failure.reason}")

/**
* Signals that the expiry was missing from the authentication response
*/
final case class ExpiryNotFoundInResponse(failure: DecodingFailure)
extends AuthTokenError(s"Expiry not found in auth response: ${failure.reason}")

/**
* Signals that the realm specified for authentication is deprecated
*/
Expand All @@ -45,8 +39,6 @@ object AuthTokenError {
JsonObject(keywords.tpe := "AuthTokenHttpError", "reason" := r.reason)
case AuthTokenNotFoundInResponse(r) =>
JsonObject(keywords.tpe -> "AuthTokenNotFoundInResponse".asJson, "reason" := r.message)
case ExpiryNotFoundInResponse(r) =>
JsonObject(keywords.tpe -> "ExpiryNotFoundInResponse".asJson, "reason" := r.message)
Comment on lines -48 to -49
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was unused

case r: RealmIsDeprecated =>
JsonObject(keywords.tpe := "RealmIsDeprecated", "reason" := r.getMessage)
}
Expand Down
5 changes: 5 additions & 0 deletions delta/sdk/src/test/resources/directives/blank-id.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"@context" : "https://bluebrain.github.io/nexus/contexts/error.json",
"@type" : "BlankResourceId",
"reason" : "Resource identifier cannot be blank."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package ch.epfl.bluebrain.nexus.delta.sdk.ce

import akka.http.scaladsl.model.ContentTypes.`text/plain(UTF-8)`
import akka.http.scaladsl.model.MediaRanges.`*/*`
import akka.http.scaladsl.model.headers.Accept
import akka.http.scaladsl.model.{ContentType, StatusCodes}
import akka.http.scaladsl.server.RouteConcatenation
import akka.stream.scaladsl.Source
import akka.util.ByteString
import ch.epfl.bluebrain.nexus.delta.rdf.RdfMediaTypes.`application/ld+json`
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder
import ch.epfl.bluebrain.nexus.delta.rdf.syntax.JsonSyntax
import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering
import ch.epfl.bluebrain.nexus.delta.sdk.ce.DeltaDirectives._
import ch.epfl.bluebrain.nexus.delta.sdk.directives.FileResponse
import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields
import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection
import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection.BlankResourceId
import ch.epfl.bluebrain.nexus.delta.sdk.utils.RouteHelpers
import ch.epfl.bluebrain.nexus.delta.sdk.{AkkaSource, SimpleRejection, SimpleResource}
import ch.epfl.bluebrain.nexus.testkit.ShouldMatchers.convertToAnyShouldWrapper
import ch.epfl.bluebrain.nexus.testkit.TestHelpers.jsonContentOf
import monix.bio.IO
import cats.effect.{IO => CatsIO}
import monix.execution.Scheduler

class CatsResponseToJsonLdSpec extends RouteHelpers with JsonSyntax with RouteConcatenation {

implicit val s: Scheduler = Scheduler.global
implicit val rcr: RemoteContextResolution =
RemoteContextResolution.fixed(
SimpleResource.contextIri -> SimpleResource.context,
SimpleRejection.contextIri -> SimpleRejection.context,
contexts.error -> jsonContentOf("/contexts/error.json").topContextValueOrEmpty
)
implicit val jo: JsonKeyOrdering = JsonKeyOrdering.default()

private def responseWithSourceError[E: JsonLdEncoder: HttpResponseFields](error: E) = {
responseWith(
`text/plain(UTF-8)`,
IO.raiseError(error)
)
}

private val expectedBlankIdErrorResponse = jsonContentOf(
"/directives/blank-id.json"
)

private val FileContents = "hello"

private def fileSourceOfString(value: String) = {
IO.pure(Source.single(ByteString(value)))
}

private def responseWith[E: JsonLdEncoder: HttpResponseFields](
contentType: ContentType,
contents: IO[E, AkkaSource]
) = {
CatsIO.pure(
Right(
FileResponse(
"file.name",
contentType,
1024,
contents
)
)
)
}

private def request = {
Get() ~> Accept(`*/*`)
}

"ResponseToJsonLd file handling" should {

"Return the contents of a file" in {
request ~> emit(
responseWith(`text/plain(UTF-8)`, fileSourceOfString(FileContents))
) ~> check {
status shouldEqual StatusCodes.OK
contentType shouldEqual `text/plain(UTF-8)`
response.asString shouldEqual FileContents
}
}

"Return an error from a file content IO" in {
request ~> emit(responseWithSourceError[ResourceRejection](BlankResourceId)) ~> check {
status shouldEqual StatusCodes.BadRequest // BlankResourceId is supposed to result in BadRequest
contentType.mediaType shouldEqual `application/ld+json`
response.asJson shouldEqual expectedBlankIdErrorResponse
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package ch.epfl.bluebrain.nexus.delta.sdk.directives

import akka.http.scaladsl.model.ContentTypes.`text/plain(UTF-8)`
import akka.http.scaladsl.model.MediaRanges.`*/*`
import akka.http.scaladsl.model.headers.Accept
import akka.http.scaladsl.model.{ContentType, StatusCodes}
import akka.http.scaladsl.server.RouteConcatenation
import akka.stream.scaladsl.Source
import akka.util.ByteString
import ch.epfl.bluebrain.nexus.delta.rdf.RdfMediaTypes.`application/ld+json`
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder
import ch.epfl.bluebrain.nexus.delta.rdf.syntax.JsonSyntax
import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering
import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._
import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields
import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection
import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection.BlankResourceId
import ch.epfl.bluebrain.nexus.delta.sdk.utils.RouteHelpers
import ch.epfl.bluebrain.nexus.delta.sdk.{AkkaSource, SimpleRejection, SimpleResource}
import ch.epfl.bluebrain.nexus.testkit.ShouldMatchers.convertToAnyShouldWrapper
import ch.epfl.bluebrain.nexus.testkit.TestHelpers.jsonContentOf
import monix.bio.IO
import monix.execution.Scheduler

class ResponseToJsonLdSpec extends RouteHelpers with JsonSyntax with RouteConcatenation {

implicit val s: Scheduler = Scheduler.global
implicit val rcr: RemoteContextResolution =
RemoteContextResolution.fixed(
SimpleResource.contextIri -> SimpleResource.context,
SimpleRejection.contextIri -> SimpleRejection.context,
contexts.error -> jsonContentOf("/contexts/error.json").topContextValueOrEmpty
)
implicit val jo: JsonKeyOrdering = JsonKeyOrdering.default()

private def responseWithSourceError[E: JsonLdEncoder: HttpResponseFields](error: E) = {
responseWith(
`text/plain(UTF-8)`,
IO.raiseError(error)
)
}

private val expectedBlankIdErrorResponse = jsonContentOf(
"/directives/blank-id.json"
)

private val FileContents = "hello"

private def fileSourceOfString(value: String) = {
IO.pure(Source.single(ByteString(value)))
}

private def responseWith[E: JsonLdEncoder: HttpResponseFields](
contentType: ContentType,
contents: IO[E, AkkaSource]
) = {
IO.pure(
FileResponse(
"file.name",
contentType,
1024,
contents
)
)
}

private def request = {
Get() ~> Accept(`*/*`)
}

"ResponseToJsonLd file handling" should {

"Return the contents of a file" in {
request ~> emit(
responseWith(`text/plain(UTF-8)`, fileSourceOfString(FileContents))
) ~> check {
status shouldEqual StatusCodes.OK
contentType shouldEqual `text/plain(UTF-8)`
response.asString shouldEqual FileContents
}
}

"Return an error from a file content IO" in {
request ~> emit(responseWithSourceError[ResourceRejection](BlankResourceId)) ~> check {
status shouldEqual StatusCodes.BadRequest // BlankResourceId is supposed to result in BadRequest
contentType.mediaType shouldEqual `application/ld+json`
response.asJson shouldEqual expectedBlankIdErrorResponse
}
}
}
}