From e2ac0e88ec13dca1f4b944535cbfc8aeb3eda862 Mon Sep 17 00:00:00 2001 From: Antoine Kalmbach Date: Wed, 29 May 2024 12:25:35 +0300 Subject: [PATCH] Bring back the akka module --- README.md | 19 + .../jsonapi/akka/AkkaExceptionHandler.scala | 183 ++++ .../qvantel/jsonapi/akka/JsonApiSupport.scala | 195 ++++ .../jsonapi/JsonApiSortingAkkaSpec.scala | 617 ++++++++++++ .../jsonapi/RelatedResponseAkkaSpec.scala | 106 ++ .../akka/AkkaExceptionHandlerSpec.scala | 424 ++++++++ .../jsonapi/akka/JsonApiSupportSpec.scala | 942 ++++++++++++++++++ build.sbt | 55 +- 8 files changed, 2540 insertions(+), 1 deletion(-) create mode 100644 akka/src/main/scala/com/qvantel/jsonapi/akka/AkkaExceptionHandler.scala create mode 100644 akka/src/main/scala/com/qvantel/jsonapi/akka/JsonApiSupport.scala create mode 100644 akka/src/test/scala/com/qvantel/jsonapi/JsonApiSortingAkkaSpec.scala create mode 100644 akka/src/test/scala/com/qvantel/jsonapi/RelatedResponseAkkaSpec.scala create mode 100644 akka/src/test/scala/com/qvantel/jsonapi/akka/AkkaExceptionHandlerSpec.scala create mode 100644 akka/src/test/scala/com/qvantel/jsonapi/akka/JsonApiSupportSpec.scala diff --git a/README.md b/README.md index c593260..28fed95 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,25 @@ implicit val endpoint: ApiEndpoint = ApiEndpoint.Static("http://localhost:8080/a val jac = JsonApiClient.instance ``` +#### akka-http client (to be deprecated in favor of pekko-http) + +```scala +// needs ActorSystem and Materializer for akka-http +// the ApiEndPoint is used to as the "root" where to launch queries +import io.lemonlabs.uri.typesafe.dsl._ +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import com.qvantel.jsonapi.ApiEndpoint +import com.qvantel.jsonapi.JsonApiClient +import com.qvantel.jsonapi.client.akka.AkkaClient._ + +implicit val system: ActorSystem = ActorSystem() +implicit val materializer: ActorMaterializer = ActorMaterializer() +implicit val endpoint: ApiEndpoint = ApiEndpoint.Static("http://localhost:8080/api") + +val jac = JsonApiClient.instance +``` + #### http4s client Setup for http4s client ```scala diff --git a/akka/src/main/scala/com/qvantel/jsonapi/akka/AkkaExceptionHandler.scala b/akka/src/main/scala/com/qvantel/jsonapi/akka/AkkaExceptionHandler.scala new file mode 100644 index 0000000..117f503 --- /dev/null +++ b/akka/src/main/scala/com/qvantel/jsonapi/akka/AkkaExceptionHandler.scala @@ -0,0 +1,183 @@ +/* +Copyright (c) 2017, Qvantel +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Qvantel nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL Qvantel BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.qvantel.jsonapi.akka + +import com.qvantel.jsonapi.model.ErrorObject + +import _root_.spray.json.DefaultJsonProtocol._ +import _root_.spray.json._ + +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.StatusCodes._ +import akka.http.scaladsl.model.{StatusCode, IllegalRequestException, ContentType} +import akka.http.scaladsl.settings.RoutingSettings +import akka.http.scaladsl.model.{HttpEntity, HttpResponse, MediaTypes} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.http.scaladsl.server.AuthenticationFailedRejection._ + +import scala.util.control.NonFatal + +trait AkkaExceptionHandlerTrait { + + import AkkaExceptionHandlerObject._ + + val defaultAkkaRejectionHandler: RejectionHandler = RejectionHandler + .newBuilder() + .handle { + case AuthenticationFailedRejection(cause, _) => + val rejectionMessage = cause match { + case CredentialsMissing => "The resource requires authentication, which was not supplied with the request" + case CredentialsRejected => "The supplied authentication is invalid" + } + completeJsonApiError(Unauthorized, "Authentication Failed", rejectionMessage) + + case AuthorizationFailedRejection => + completeJsonApiError(Forbidden, + "Authorization Failed", + "The supplied authentication is not authorized to access this resource") + + case MalformedFormFieldRejection(name, msg, _) => + completeJsonApiError(BadRequest, "Malformed Form Field", "The form field '" + name + "' was malformed:\n" + msg) + + case MalformedHeaderRejection(headerName, msg, _) => + completeJsonApiError(BadRequest, + "Malformed Header", + s"The value of HTTP header '$headerName' was malformed:\n" + msg) + + case MalformedQueryParamRejection(name, msg, _) => + completeJsonApiError(BadRequest, + "Malformed Query Param", + "The query parameter '" + name + "' was malformed:\n" + msg) + + case MalformedRequestContentRejection(msg, _) => + completeJsonApiError(BadRequest, "Malformed Request Content", "The request content was malformed:\n" + msg) + + case MethodRejection(supported) => + completeJsonApiError(MethodNotAllowed, + "HTTP method not allowed", + "HTTP method not allowed, supported methods: " + supported.toString) + + case SchemeRejection(supported) => + completeJsonApiError(BadRequest, + "Uri scheme not allowed", + "Uri scheme not allowed, supported schemes: " + supported) + + case MissingCookieRejection(cookieName) => + completeJsonApiError(BadRequest, "Missing Cookie", s"Request is missing required cookie '$cookieName'") + + case MissingFormFieldRejection(fieldName) => + completeJsonApiError(BadRequest, "Missing Form Field", s"Request is missing required form field '$fieldName'") + + case MissingHeaderRejection(headerName) => + completeJsonApiError(BadRequest, "Missing Header", s"Request is missing required HTTP header '$headerName'") + + case MissingQueryParamRejection(paramName) => + completeJsonApiError(BadRequest, + "Missing Query Param", + s"Request is missing required query parameter '$paramName'") + + case RequestEntityExpectedRejection => + completeJsonApiError(BadRequest, "Request Entity Expected", "Request entity expected but not supplied") + + case TooManyRangesRejection(_) => + completeJsonApiError(RangeNotSatisfiable, "Too Many Ranges", "Request contains too many ranges") + + case UnsatisfiableRangeRejection(unsatisfiableRanges, _) => + completeJsonApiError( + RangeNotSatisfiable, + "Unsatisfiable Range", + unsatisfiableRanges.mkString("None of the following requested Ranges were satisfiable:\n", "\n", "") + ) + + case UnacceptedResponseContentTypeRejection(supported) => + completeJsonApiError( + NotAcceptable, + "Unaccepted Response Content Type", + "Resource representation is only available with these Content-Types:\n" + supported.mkString("\n") + ) + + case UnacceptedResponseEncodingRejection(supported) => + completeJsonApiError( + NotAcceptable, + "Unaccepted Response Encoding", + "Resource representation is only available with these Content-Encodings:\n" + supported.mkString("\n") + ) + + case UnsupportedRequestContentTypeRejection(supported) => + completeJsonApiError(UnsupportedMediaType, + "Unsupported Request Content-Type", + "There was a problem with the requests Content-Type:\n" + supported.mkString(" or ")) + + case UnsupportedRequestEncodingRejection(supported) => + completeJsonApiError(BadRequest, + "Unsupported Request Encoding", + "The request Content-Encoding must be the following:\n" + supported.value) + + case ValidationRejection(msg, _) => + completeJsonApiError(BadRequest, "Validation Rejection", msg) + } + .handleNotFound { + completeJsonApiError(NotFound, NotFound.reason, NotFound.defaultMessage) + } + .result() + + def defaultAkkaExceptionHandler(implicit settings: RoutingSettings, log: LoggingAdapter): ExceptionHandler = + ExceptionHandler { + case e: IllegalRequestException => { + extractRequestContext { ctx => + log.warning("Illegal request {}\n\t{}\n\tCompleting with '{}' response", ctx.request, e.getMessage, e.status) + complete(jsonApiErrorResponse(e.status, "Illegal Request", e.info.format(settings.verboseErrorMessages))) + } + } + case NonFatal(e) => { + extractRequestContext { ctx => + log.error(e, "Error during processing of request {}", ctx.request) + complete( + jsonApiErrorResponse(InternalServerError, + InternalServerError.reason, + if (e.getMessage != null) e.getMessage else InternalServerError.defaultMessage)) + } + } + } +} + +object AkkaExceptionHandlerObject extends Rejection { + + def jsonApiError(code: StatusCode, title: String, detail: String): JsValue = + JsObject("errors" -> List( + ErrorObject(status = Some(code.intValue.toString), title = Some(title), detail = Some(detail))).toJson) + + def jsonApiErrorResponse(code: StatusCode, title: String, detail: String): HttpResponse = + HttpResponse( + status = code, + entity = + HttpEntity(ContentType(MediaTypes.`application/vnd.api+json`), jsonApiError(code, title, detail).prettyPrint)) + + def completeJsonApiError(code: StatusCode, title: String, detail: String): Route = + complete(jsonApiErrorResponse(code, title, detail)) +} diff --git a/akka/src/main/scala/com/qvantel/jsonapi/akka/JsonApiSupport.scala b/akka/src/main/scala/com/qvantel/jsonapi/akka/JsonApiSupport.scala new file mode 100644 index 0000000..9a89a68 --- /dev/null +++ b/akka/src/main/scala/com/qvantel/jsonapi/akka/JsonApiSupport.scala @@ -0,0 +1,195 @@ +/* +Copyright (c) 2017, Qvantel +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Qvantel nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL Qvantel BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.qvantel.jsonapi.akka + +import _root_.akka.http.scaladsl.Http +import _root_.akka.http.scaladsl.client.RequestBuilding +import _root_.akka.http.scaladsl.marshalling._ +import _root_.akka.http.scaladsl.model._ +import _root_.akka.http.scaladsl.model.headers._ +import _root_.akka.http.scaladsl.unmarshalling._ +import _root_.akka.stream.Materializer +import _root_.akka.stream.scaladsl._ +import _root_.akka.util.{ByteString, Timeout} +import _root_.spray.json._ + +import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.duration._ + +import com.qvantel.jsonapi._ +import com.qvantel.jsonapi.model.TopLevel + +trait JsonApiSupport extends JsonApiSupport0 { + + implicit def jsonApiCollectionMarshaller[T]( + implicit writer: JsonApiWriter[T], + printer: JsonPrinter = PrettyPrinter, + metaProfiles: Set[MetaProfile] = Set.empty, + sorting: JsonApiSorting = JsonApiSorting.Unsorted, + sparseFields: Map[String, List[String]] = Map.empty, + pagination: JsonApiPagination.PaginationFunc = JsonApiPagination.EmptyFunc): ToEntityMarshaller[Iterable[T]] = + Marshaller.withFixedContentType(ct) { as => + HttpEntity(ct, rawCollection(as)) + } + + implicit def jsonApiCollectionRequestUnmarshaller[T]( + implicit reader: JsonApiReader[T]): FromRequestUnmarshaller[Iterable[T]] = + new FromRequestUnmarshaller[Iterable[T]] { + override def apply(value: HttpRequest)(implicit ec: ExecutionContext, + materializer: Materializer): Future[Iterable[T]] = { + val include = value.uri.query().get("include").map(_.split(',').toSet).getOrElse(Set.empty[String]) + value.entity.toStrict(10.seconds).flatMap(strictEntity => extractEntities(strictEntity.data, include)) + } + } + + implicit def jsonApiCollectionResponseUnmarshaller[T]( + implicit reader: JsonApiReader[T]): FromResponseUnmarshaller[Iterable[T]] = + new FromResponseUnmarshaller[Iterable[T]] { + override def apply(value: HttpResponse)(implicit ec: ExecutionContext, + materializer: Materializer): Future[Iterable[T]] = { + val include = value.headers + .find(_.name == JsonApiSupport.JsonApiIncludeHeader) + .map(_.value.split(',').toSet) + .getOrElse(Set.empty[String]) + value.entity.toStrict(10.seconds).flatMap(strictEntity => extractEntities(strictEntity.data, include)) + } + } + +} + +trait JsonApiSupport0 { + val ct = MediaTypes.`application/vnd.api+json` + + implicit val jsonApiTopLevelSingle: Unmarshaller[HttpEntity, TopLevel.Single] = { + Unmarshaller.byteStringUnmarshaller.map { data => + JsonParser(data.utf8String).asJsObject.convertTo[TopLevel.Single] + } + } + + implicit val jsonApiTopLevelCollection: Unmarshaller[HttpEntity, TopLevel.Collection] = { + Unmarshaller.byteStringUnmarshaller.map { data => + JsonParser(data.utf8String).asJsObject.convertTo[TopLevel.Collection] + } + } + + implicit val jsonApiErrorObject: Unmarshaller[HttpEntity, TopLevel.Errors] = { + Unmarshaller.byteStringUnmarshaller.map { data => + JsonParser(data.utf8String).asJsObject.convertTo[TopLevel.Errors] + } + } + + implicit def jsonApiOneMarshaller[T](implicit writer: JsonApiWriter[T], + printer: JsonPrinter = PrettyPrinter, + metaProfiles: Set[MetaProfile] = Set.empty, + sorting: JsonApiSorting = JsonApiSorting.Unsorted, + sparseFields: Map[String, List[String]] = Map.empty): ToEntityMarshaller[T] = + Marshaller.withFixedContentType(ct) { a => + HttpEntity(ct, rawOne(a)) + } + + implicit def relatedResponseMarshaller[A]( + implicit writer: JsonApiWriter[A], + printer: JsonPrinter = PrettyPrinter, + sorting: JsonApiSorting = JsonApiSorting.Unsorted, + sparseFields: Map[String, List[String]] = Map.empty): ToEntityMarshaller[com.qvantel.jsonapi.RelatedResponse[A]] = + PredefinedToEntityMarshallers.StringMarshaller.wrap(ct) { value => + printer.apply(value.toResponse) + } + + implicit def jsonApiOneRequestUnmarshaller[T](implicit reader: JsonApiReader[T]): FromRequestUnmarshaller[T] = + new FromRequestUnmarshaller[T] { + override def apply(value: HttpRequest)(implicit ec: ExecutionContext, materializer: Materializer): Future[T] = { + val include = value.uri.query().get("include").map(_.split(',').toSet).getOrElse(Set.empty[String]) + value.entity.toStrict(10.seconds).flatMap(strictEntity => extractEntity(strictEntity.data, include)) + } + } + + implicit def jsonApiOneResponseUnmarshaller[T](implicit reader: JsonApiReader[T]): FromResponseUnmarshaller[T] = + new FromResponseUnmarshaller[T] { + override def apply(value: HttpResponse)(implicit ec: ExecutionContext, materializer: Materializer): Future[T] = { + val include = value.headers + .find(_.name == JsonApiSupport.JsonApiIncludeHeader) + .map(_.value.split(',').toSet) + .getOrElse(Set.empty[String]) + value.entity.toStrict(10.seconds).flatMap(strictEntity => extractEntity(strictEntity.data, include)) + } + } + + def extractEntity[T](data: ByteString, include: Set[String])(implicit reader: JsonApiReader[T], + ec: ExecutionContext): Future[T] = + Future { + val json = JsonParser(data.decodeString("UTF-8")).asJsObject + readOne[T](json, include) + } + + def extractEntity[T](data: Source[ByteString, Any], include: Set[String])(implicit materializer: Materializer, + reader: JsonApiReader[T], + ec: ExecutionContext): Future[T] = + data.runFold(ByteString(""))(_ ++ _).flatMap(extractEntity[T](_, include)) + + def extractEntities[T](data: ByteString, include: Set[String])(implicit reader: JsonApiReader[T], + ec: ExecutionContext): Future[Iterable[T]] = + Future { + val json = JsonParser(data.decodeString("UTF-8")).asJsObject + readCollection[T](json, include).toList + } + + def extractEntities[T](data: Source[ByteString, Any], include: Set[String])( + implicit materializer: Materializer, + reader: JsonApiReader[T], + ec: ExecutionContext): Future[Iterable[T]] = + data.runFold(ByteString(""))(_ ++ _).flatMap(extractEntities[T](_, include)) +} + +/** Custom SendReceive that adds the include params into X-Internal-Include + * header that can be read by FromResponseUnmarshaller + */ +object JsonApiClientAkka extends RequestBuilding { + import _root_.akka.actor._ + import _root_.akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} + + import scala.concurrent.duration._ + + def jsonApiSendReceive(implicit refFactory: ActorRefFactory, + executionContext: ExecutionContext, + system: ActorSystem, + futureTimeout: Timeout = 60.seconds): HttpRequest => Future[HttpResponse] = { + + val conSettings = ClientConnectionSettings(system.settings.config).withIdleTimeout(futureTimeout.duration) + val timeoutSettings = ConnectionPoolSettings(system.settings.config).withConnectionSettings(conSettings) + req => + val response = Http().singleRequest(request = req, settings = timeoutSettings) + req.uri.query().get("include") match { + case Some(include) => response.map(_.withHeaders(RawHeader(JsonApiSupport.JsonApiIncludeHeader, include))) + case None => response + } + } +} + +object JsonApiSupport extends JsonApiSupport { + val JsonApiIncludeHeader: String = "X-Internal-Include" +} diff --git a/akka/src/test/scala/com/qvantel/jsonapi/JsonApiSortingAkkaSpec.scala b/akka/src/test/scala/com/qvantel/jsonapi/JsonApiSortingAkkaSpec.scala new file mode 100644 index 0000000..3625ff8 --- /dev/null +++ b/akka/src/test/scala/com/qvantel/jsonapi/JsonApiSortingAkkaSpec.scala @@ -0,0 +1,617 @@ +/* +Copyright (c) 2017, Qvantel +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Qvantel nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL Qvantel BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.qvantel.jsonapi + +import akka.JsonApiSupport._ + +import org.specs2.mutable._ +import _root_.spray.json.DefaultJsonProtocol._ +import _root_.spray.json._ +import _root_.akka.http.scaladsl.testkit.Specs2RouteTest +import io.lemonlabs.uri.typesafe.dsl._ + +final class JsonApiSortingAkkaSpec extends Specification with Specs2RouteTest { + def actorRefFactory = system + + implicit val apiRoot: com.qvantel.jsonapi.ApiRoot = ApiRoot(Some("/api")) + + @jsonApiResource final case class Res(id: String, rel: ToMany[Res]) + + val one = Res( + "1", + ToMany.loaded(Seq(Res("3", ToMany.reference("/api/res/3/rel")), Res("2", ToMany.reference("/api/res/2/rel"))))) + val many = Seq( + Res("1", ToMany.reference("/api/res/1/rel")), + Res("3", ToMany.reference("/api/res/3/rel")), + Res("2", ToMany.reference("/api/res/2/rel")) + ) + + val manualOne = + """ + |{ + | "data": { + | "relationships": { + | "rel": { + | "data": [{ + | "type": "res", + | "id": "3" + | }, { + | "type": "res", + | "id": "2" + | }], + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, + | "included": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + val manualMany = + """ + |{ + | "data": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + "Unsorted" >> { + val rawOne = marshal(one).getData().utf8String.parseJson.asJsObject + val rawMany = marshal(many).getData().utf8String.parseJson.asJsObject + + rawOne must_== manualOne + rawMany must_== manualMany + + one must_== readOne[Res](rawOne, Set("rel")) + many must_== readCollection[Res](rawMany, Set("rel")).toList + } + + "AscendingId" >> { + implicit val sorting: JsonApiSorting = JsonApiSorting.AscendingId + + val rawOne = marshal(one).getData().utf8String.parseJson.asJsObject + val rawMany = marshal(many).getData().utf8String.parseJson.asJsObject + + val sortedOne = + """ + |{ + | "data": { + | "relationships": { + | "rel": { + | "data": [{ + | "type": "res", + | "id": "3" + | }, { + | "type": "res", + | "id": "2" + | }], + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, + | "included": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + val sortedMany = + """ + |{ + | "data": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + rawOne must_== sortedOne + rawMany must_== sortedMany + + // sanity check + one must_== readOne[Res](rawOne, Set("rel")) + many.sortBy(_.id) must_== readCollection[Res](rawMany, Set("rel")).toList + } + + "DescendingId" >> { + implicit val sorting: JsonApiSorting = JsonApiSorting.DescendingId + val rawOne = marshal(one).getData().utf8String.parseJson.asJsObject + val rawMany = marshal(many).getData().utf8String.parseJson.asJsObject + + val sortedOne = + """ + |{ + | "data": { + | "relationships": { + | "rel": { + | "data": [{ + | "type": "res", + | "id": "3" + | }, { + | "type": "res", + | "id": "2" + | }], + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, + | "included": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + val sortedMany = + """ + |{ + | "data": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + rawOne must_== sortedOne + rawMany must_== sortedMany + + // sanity check + one must_== readOne[Res](rawOne, Set("rel")) + many.sortBy(_.id).reverse must_== readCollection[Res](rawMany, Set("rel")).toList + } + + "ByOrdering" >> { + implicit val sorting: JsonApiSorting = + JsonApiSorting.ByOrdering(Ordering.by[JsObject, Option[String]](_.fields.get("id").map(_.convertTo[String]))) + val rawOne = marshal(one).getData().utf8String.parseJson.asJsObject + val rawMany = marshal(many).getData().utf8String.parseJson.asJsObject + + val sortedOne = + """ + |{ + | "data": { + | "relationships": { + | "rel": { + | "data": [{ + | "type": "res", + | "id": "3" + | }, { + | "type": "res", + | "id": "2" + | }], + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, + | "included": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + val sortedMany = + """ + |{ + | "data": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + rawOne must_== sortedOne + rawMany must_== sortedMany + + // sanity check + one must_== readOne[Res](rawOne, Set("rel")) + many.sortBy(_.id) must_== readCollection[Res](rawMany, Set("rel")).toList + } + + "RelatedResponse" >> { + implicit val sorting: JsonApiSorting = JsonApiSorting.AscendingId + + val rawOne = marshal(RelatedResponse(one)).getData().utf8String.parseJson.asJsObject + val rawMany = marshal(RelatedResponse(many)).getData().utf8String.parseJson.asJsObject + val sortedOne = + """ + |{ + | "data": { + | "relationships": { + | "rel": { + | "data": [{ + | "type": "res", + | "id": "3" + | }, { + | "type": "res", + | "id": "2" + | }], + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, + | "included": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + val sortedMany = + """ + |{ + | "data": [{ + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/1/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/1" + | }, + | "id": "1", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/2/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/2" + | }, + | "id": "2", + | "type": "res" + | }, { + | "relationships": { + | "rel": { + | "links": { + | "related": "/api/res/3/rel" + | } + | } + | }, + | "links": { + | "self": "/api/res/3" + | }, + | "id": "3", + | "type": "res" + | }] + |} + """.stripMargin.parseJson.asJsObject + + rawOne must_== sortedOne + rawMany must_== sortedMany + + // sanity check + one must_== readOne[Res](rawOne, Set("rel")) + many.sortBy(_.id) must_== readCollection[Res](rawMany, Set("rel")).toList + } +} diff --git a/akka/src/test/scala/com/qvantel/jsonapi/RelatedResponseAkkaSpec.scala b/akka/src/test/scala/com/qvantel/jsonapi/RelatedResponseAkkaSpec.scala new file mode 100644 index 0000000..ea8af3b --- /dev/null +++ b/akka/src/test/scala/com/qvantel/jsonapi/RelatedResponseAkkaSpec.scala @@ -0,0 +1,106 @@ +/* +Copyright (c) 2017, Qvantel +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Qvantel nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL Qvantel BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.qvantel.jsonapi + +import com.qvantel.jsonapi.akka.JsonApiSupport._ + +import org.specs2.mutable._ +import _root_.spray.json._ +import _root_.spray.json.DefaultJsonProtocol._ +import _root_.akka.http.scaladsl.testkit.Specs2RouteTest +import _root_.akka.http.scaladsl.model._ +import _root_.akka.http.scaladsl.server.Directives._ + +class RelatedResponseAkkaSpec extends Specification with Specs2RouteTest { + def actorRefFactory = system + implicit val apiRoot: com.qvantel.jsonapi.ApiRoot = ApiRoot(None) + @jsonApiResource final case class Test(id: String, name: String) + + val test: Option[Test] = Some(Test("teståöä•Ωé®", "name")) // test UTF-8 + val emptyTest: Option[Test] = None + val tests: List[Test] = List(Test("test 1", "name 1"), Test("test 2", "name 2")) + val emptyTests: List[Test] = List.empty + + val route = get { + complete { + RelatedResponse(test) + } + } + + "correctly write to one none case" in { + RelatedResponse(emptyTest).toResponse must be equalTo JsObject( + "data" -> JsNull + ) + } + + "correctly write to one some case" in { + val answer = rawOne(test.get) + + RelatedResponse(test).toResponse must be equalTo answer + RelatedResponse(test.get).toResponse must be equalTo answer + } + + "correctly write to one some case with sparse fields defined" in { + implicit val sparseFields: Map[String, List[String]] = Map("tests" -> List("someFieldThatDoesNotExist")) + val answer = rawOne(test.get) + + RelatedResponse(test).toResponse must be equalTo answer + RelatedResponse(test.get).toResponse must be equalTo answer + } + + "correctly write to many empty case" in { + RelatedResponse(emptyTests).toResponse must be equalTo JsObject( + "data" -> JsArray.empty + ) + } + + "correctly write to many non-empty case" in { + val answer = rawCollection(tests) + + RelatedResponse(tests).toResponse must be equalTo answer + RelatedResponse(tests.toSeq).toResponse must be equalTo answer + RelatedResponse(tests.toIterable).toResponse must be equalTo answer + RelatedResponse(tests.toSet).toResponse must be equalTo answer + } + + "correctly write to many non-empty case with sparse fields defined" in { + implicit val sparseFields: Map[String, List[String]] = Map("tests" -> List("someFieldThatDoesNotExist")) + + val answer = rawCollection(tests) + + RelatedResponse(tests).toResponse must be equalTo answer + RelatedResponse(tests.toSeq).toResponse must be equalTo answer + RelatedResponse(tests.toIterable).toResponse must be equalTo answer + RelatedResponse(tests.toSet).toResponse must be equalTo answer + } + + "make sure that correct content type is given" in { + Get("/") ~> route ~> check { + contentType must be equalTo ContentType(MediaTypes.`application/vnd.api+json`) + } + } +} diff --git a/akka/src/test/scala/com/qvantel/jsonapi/akka/AkkaExceptionHandlerSpec.scala b/akka/src/test/scala/com/qvantel/jsonapi/akka/AkkaExceptionHandlerSpec.scala new file mode 100644 index 0000000..4570167 --- /dev/null +++ b/akka/src/test/scala/com/qvantel/jsonapi/akka/AkkaExceptionHandlerSpec.scala @@ -0,0 +1,424 @@ +/* +Copyright (c) 2017, Qvantel +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Qvantel nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL Qvantel BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.qvantel.jsonapi.akka + +import _root_.spray.json.DefaultJsonProtocol._ +import _root_.spray.json.lenses.JsonLenses._ +import _root_.spray.json.{JsArray, JsonParser} + +import akka.event.Logging +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.StatusCodes._ +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.server.AuthenticationFailedRejection.{CredentialsMissing, CredentialsRejected} +import akka.http.scaladsl.server._ +import akka.http.scaladsl.testkit.Specs2RouteTest + +import org.specs2.mutable.Specification + +class AkkaExceptionHandlerSpec extends Specification with Directives with Specs2RouteTest { + class TestAkkaExceptionHandler extends AkkaExceptionHandlerTrait + + val testAkkaExceptionHandler = new TestAkkaExceptionHandler + implicit val log = Logging(system, "Log") + private[this] val wrap = Directives.handleExceptions(testAkkaExceptionHandler.defaultAkkaExceptionHandler) + val JSON = ContentType(MediaTypes.`application/vnd.api+json`) + val route = + handleRejections(testAkkaExceptionHandler.defaultAkkaRejectionHandler) { + path("authenticationMissing") { + reject(AuthenticationFailedRejection(CredentialsMissing, HttpChallenge("Auth", Some("")))) + } ~ + path("authenticationRejected") { + reject(AuthenticationFailedRejection(CredentialsRejected, HttpChallenge("Auth", Some("")))) + } ~ + path("authorization") { + reject(AuthorizationFailedRejection) + } ~ + path("malformedFormField") { + reject(MalformedFormFieldRejection("nameX", "messageX")) + } ~ + path("malformedHeader") { + reject(MalformedHeaderRejection("nameX", "messageX")) + } ~ + path("malformedQueryParam") { + reject(MalformedQueryParamRejection("nameX", "messageX")) + } ~ + path("malformedRequestContent") { + reject(MalformedRequestContentRejection("messageX", new Exception(""))) + } ~ + path("method") { + reject(MethodRejection(HttpMethods.GET)) + } ~ + path("scheme") { + reject(SchemeRejection("schemeX")) + } ~ + path("missingCookie") { + reject(MissingCookieRejection("cookieX")) + } ~ + path("missingFormField") { + reject(MissingFormFieldRejection("formFieldX")) + } ~ + path("missingHeader") { + reject(MissingHeaderRejection("headerX")) + } ~ + path("missingQueryParam") { + reject(MissingQueryParamRejection("parameterX")) + } ~ + path("requestEntityExpected") { + reject(RequestEntityExpectedRejection) + } ~ + path("tooManyRanges") { + reject(TooManyRangesRejection(1)) + } ~ + path("unsatisfiableRange") { + reject(UnsatisfiableRangeRejection(Range(ByteRange(1000, 2000)).ranges, 1)) + } ~ + path("unacceptedResponseContentType") { + reject(UnacceptedResponseContentTypeRejection(Set(ContentType(MediaTypes.`application/vnd.api+json`)))) + } ~ + path("unacceptedResponseEncoding") { + reject(UnacceptedResponseEncodingRejection(HttpEncoding("encodingX"))) + } ~ + path("unsupportedRequestContentType") { + reject(UnsupportedRequestContentTypeRejection(Set(ContentType(MediaTypes.`application/vnd.api+json`)), None)) + } ~ + path("unsupportedRequestEncoding") { + reject(UnsupportedRequestEncodingRejection(HttpEncoding("encodingX"))) + } ~ + path("validation") { + reject(ValidationRejection("messageX")) + } + } + + "The Akka ExceptionHandler" should { + + "Respond with InternalServerError and specified error message" in { + Get() ~> wrap { + failWith(new Exception("Specified error message")) + } ~> check { + status must_== InternalServerError + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("title")) must beSome("Internal Server Error") + error.map(_.extract[String]("detail")) must beSome("Specified error message") + } + } + + "Respond with InternalServerError and default error message" in { + Get() ~> wrap { + failWith(new Exception) + } ~> check { + status must_== InternalServerError + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("title")) must beSome("Internal Server Error") + error.map(_.extract[String]("detail")) must beSome(InternalServerError.defaultMessage) + } + } + + "Respond with IllegalRequestException and specific error message" in { + Get() ~> wrap { + failWith(IllegalRequestException(BadRequest, "infoX")) + } ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("title")) must beSome("Illegal Request") + error.map(_.extract[String]("detail")) must beSome("The request contains bad syntax or cannot be fulfilled.") + } + } + } + + "The Akka RejectionHandler" should { + + "authentication should return 401 with credentialsmissing and a proper jsonapi.org error object" in { + Get("/authenticationMissing") ~> route ~> check { + status must_== Unauthorized + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome( + "The resource requires authentication, which was not supplied with the request") + error.map(_.extract[String]("title")) must beSome("Authentication Failed") + } + } + + "authentication should return 401 with credentialsrejected and a proper jsonapi.org error object" in { + Get("/authenticationRejected") ~> route ~> check { + status must_== Unauthorized + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("The supplied authentication is invalid") + error.map(_.extract[String]("title")) must beSome("Authentication Failed") + } + } + + "authorization should return 403 and a proper jsonapi.org error object" in { + Get("/authorization") ~> route ~> check { + status must_== Forbidden + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome( + "The supplied authentication is not authorized to access this resource") + error.map(_.extract[String]("title")) must beSome("Authorization Failed") + } + } + + "malformedFormField should return 400 and a proper jsonapi.org error object" in { + Get("/malformedFormField") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("The form field 'nameX' was malformed:\nmessageX") + error.map(_.extract[String]("title")) must beSome("Malformed Form Field") + } + } + + "malformedHeader should return 400 with proper jsonapi.org error object" in { + Get("/malformedHeader") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("The value of HTTP header 'nameX' was malformed:\nmessageX") + error.map(_.extract[String]("title")) must beSome("Malformed Header") + } + } + + "malformedQueryParam should return 400 with proper jsonapi.org error object" in { + Get("/malformedQueryParam") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("The query parameter 'nameX' was malformed:\nmessageX") + error.map(_.extract[String]("title")) must beSome("Malformed Query Param") + } + } + + "malformedRequestContent should return 400 with proper jsonapi.org error object" in { + Get("/malformedRequestContent") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("The request content was malformed:\nmessageX") + error.map(_.extract[String]("title")) must beSome("Malformed Request Content") + } + } + + "method should return 405 with proper jsonapi.org error object" in { + Get("/method") ~> route ~> check { + status must_== MethodNotAllowed + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome( + "HTTP method not allowed, supported methods: HttpMethod(GET)") + error.map(_.extract[String]("title")) must beSome("HTTP method not allowed") + } + } + + "scheme should return 400 with proper jsonapi.org error object" in { + Get("/scheme") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("Uri scheme not allowed, supported schemes: schemeX") + error.map(_.extract[String]("title")) must beSome("Uri scheme not allowed") + } + } + + "missingCookie should return 400 with proper jsonapi.org error object" in { + Get("/missingCookie") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("Request is missing required cookie 'cookieX'") + error.map(_.extract[String]("title")) must beSome("Missing Cookie") + } + } + + "missingFormField should return 400 with proper jsonapi.org error object" in { + Get("/missingFormField") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("Request is missing required form field 'formFieldX'") + error.map(_.extract[String]("title")) must beSome("Missing Form Field") + } + } + + "missingHeader should return 400 with proper jsonapi.org error object" in { + Get("/missingHeader") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("Request is missing required HTTP header 'headerX'") + error.map(_.extract[String]("title")) must beSome("Missing Header") + } + } + + "missingQueryParam should return 400 with proper jsonapi.org error object" in { + Get("/missingQueryParam") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("Request is missing required query parameter 'parameterX'") + error.map(_.extract[String]("title")) must beSome("Missing Query Param") + } + } + + "requestEntityExpected should return 400 with proper jsonapi.org error object" in { + Get("/requestEntityExpected") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("Request entity expected but not supplied") + error.map(_.extract[String]("title")) must beSome("Request Entity Expected") + } + } + + "tooManyRanges should return 416 with proper jsonapi.org error object" in { + Get("/tooManyRanges") ~> route ~> check { + status must_== RangeNotSatisfiable + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("Request contains too many ranges") + error.map(_.extract[String]("title")) must beSome("Too Many Ranges") + } + } + + "unsatisfiableRange should return 400 with proper jsonapi.org error object" in { + Get("/unsatisfiableRange") ~> route ~> check { + status must_== RangeNotSatisfiable + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome( + "None of the following requested Ranges were satisfiable:\n1000-2000") + error.map(_.extract[String]("title")) must beSome("Unsatisfiable Range") + } + } + + "unacceptedResponseContentType should return 406 with proper jsonapi.org error object" in { + Get("/unacceptedResponseContentType") ~> route ~> check { + status must_== NotAcceptable + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome( + "Resource representation is only available with these Content-Types:\nContentType(application/vnd.api+json)") + error.map(_.extract[String]("title")) must beSome("Unaccepted Response Content Type") + } + } + + "unacceptedResponseEncoding should return 406 with proper jsonapi.org error object" in { + Get("/unacceptedResponseEncoding") ~> route ~> check { + status must_== NotAcceptable + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome( + "Resource representation is only available with these Content-Encodings:\nencodingX") + error.map(_.extract[String]("title")) must beSome("Unaccepted Response Encoding") + } + } + + "unsupportedRequestContentType should return 415 with proper jsonapi.org error object" in { + Get("/unsupportedRequestContentType") ~> route ~> check { + status must_== UnsupportedMediaType + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome( + "There was a problem with the requests Content-Type:\napplication/vnd.api+json") + error.map(_.extract[String]("title")) must beSome("Unsupported Request Content-Type") + } + } + + "unsupportedRequestEncoding should return 400 with proper jsonapi.org error object" in { + Get("/unsupportedRequestEncoding") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome( + "The request Content-Encoding must be the following:\nencodingX") + error.map(_.extract[String]("title")) must beSome("Unsupported Request Encoding") + } + } + + "validation should return 400 with proper jsonapi.org error object" in { + Get("/validation") ~> route ~> check { + status must_== BadRequest + contentType must_== JSON + + val json = JsonParser(responseAs[String]) + val error = json.extract[JsArray]("errors").elements.headOption + error.map(_.extract[String]("detail")) must beSome("messageX") + error.map(_.extract[String]("title")) must beSome("Validation Rejection") + } + } + } +} diff --git a/akka/src/test/scala/com/qvantel/jsonapi/akka/JsonApiSupportSpec.scala b/akka/src/test/scala/com/qvantel/jsonapi/akka/JsonApiSupportSpec.scala new file mode 100644 index 0000000..0f2169d --- /dev/null +++ b/akka/src/test/scala/com/qvantel/jsonapi/akka/JsonApiSupportSpec.scala @@ -0,0 +1,942 @@ +/* +Copyright (c) 2017, Qvantel +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Qvantel nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL Qvantel BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.qvantel.jsonapi.akka + +import scala.concurrent.Future + +import _root_.akka.http.scaladsl.marshalling.ToEntityMarshaller +import _root_.akka.http.scaladsl.model._ +import _root_.akka.http.scaladsl.server.Directive._ +import _root_.akka.http.scaladsl.server.Directives._ +import _root_.akka.http.scaladsl.server._ +import _root_.akka.http.scaladsl.testkit.Specs2RouteTest +import _root_.spray.json.DefaultJsonProtocol._ +import _root_.spray.json._ +import io.lemonlabs.uri.Url +import io.lemonlabs.uri.typesafe.dsl._ +import org.specs2.mutable.Specification +import shapeless._ + +import com.qvantel.jsonapi._ +import com.qvantel.jsonapi.akka.JsonApiSupport._ +import com.qvantel.jsonapi.model.{ErrorObject, TopLevel} + +final class JsonApiSupportSpec extends Specification with Specs2RouteTest { + val ct = ContentType(MediaTypes.`application/vnd.api+json`) + + implicit val apiRoot: com.qvantel.jsonapi.ApiRoot = ApiRoot(None) + + final case class Root(id: String, + nameMangling: String, + loaded: ToOne[Child], + referenced: ToOne[Child], + many: ToMany[Child], + manyReferenced: ToMany[Child], + article: ToOne[Article]) + + object Root { + implicit val resourceType: ResourceType[Root] = ResourceType[Root]("root") + implicit val identifiable: Identifiable[Root] = Identifiable.by(_.id) + implicit val pathTo: PathTo[Root] = new PathToId[Root] { + override def root: Url = "/roots" + } + implicit val format: JsonApiFormat[Root] = jsonApiFormat[Root] + } + + final case class Child(id: String, name: String) + + object Child { + implicit val resourceType: ResourceType[Child] = ResourceType[Child]("child") + implicit val identifiable: Identifiable[Child] = Identifiable.by(_.id) + implicit val pathTo: PathTo[Child] = new PathToId[Child] { + override def root: Url = "/children" + } + implicit val format: JsonApiFormat[Child] = jsonApiFormat[Child] + } + + final case class Article(id: String, name: String) + + object Article { + implicit val resourceType: ResourceType[Article] = ResourceType[Article]("article") + implicit val identifiable: Identifiable[Article] = Identifiable.by(_.id) + implicit val pathTo: PathTo[Article] = new PathToId[Article] { + override def root: Url = "/articles" + } + implicit val format: JsonApiFormat[Article] = jsonApiFormat[Article] + } + + def actorRefFactory = system + + val child = Child("1", "test") + val thang = Thang("idThang", "nameThang", 20) + val many = Seq(Child("3", "test 3"), Child("4", "test 4"), Child("5", "test 5")) + val data = Root("1", + "test data", + ToOne.loaded(child), + ToOne.reference("2"), + ToMany.loaded(many), + ToMany.reference, + ToOne.loaded(Article("55", "test"))) + val data2 = Root("2", + "test data", + ToOne.loaded(child), + ToOne.reference("2"), + ToMany.loaded(many), + ToMany.reference, + ToOne.loaded(Article("55", "test"))) + + @jsonApiResource final case class Thang(id: String, name: String, age: Int) + @jsonApiResource("name-changed", "no-id") final case class Thing(name: String, thang: ToOne[Thang]) + + val route: Route = + get { + complete { + data + } + } ~ + (path("single") & post & entity(as[Root])) { obj => + complete { + obj + } + } ~ + (path("collection") & post & entity(as[Iterable[Root]])) { obj => + complete { + obj + } + } ~ + (path("thing") & post & entity(as[Thing])) { obj => + complete { + obj + } + } + + def recursiveJsObjectComparsion(jsObject1: JsObject, jsObject2: JsObject): Boolean = { + def recursiveJsValueComparsion(jsValue1: JsValue, jsValue2: JsValue): Boolean = + (jsValue1, jsValue2) match { + case (fieldValue: JsObject, fieldValue2: JsObject) => + fieldValue.fields.forall { + case (nestedFieldName, nestedFieldValue) if fieldValue2.fields.isDefinedAt(nestedFieldName) => + recursiveJsValueComparsion(nestedFieldValue, fieldValue2.fields(nestedFieldName)) + case _ => false + } + case (fieldValue: JsArray, fieldValue2: JsArray) => + fieldValue.elements.forall { nestedField => + fieldValue2.elements.exists(recursiveJsValueComparsion(_, nestedField)) + } + case _ => jsValue1 == jsValue2 + } + + if (jsObject1.fields.forall { + case (fieldName, fieldValue) => + recursiveJsValueComparsion( + fieldValue, + jsObject2.fields + .getOrElse(fieldName, throw new Exception(jsObject1.prettyPrint + " != " + jsObject2.prettyPrint))) + }) { + true + } else { + throw new Exception(jsObject1.prettyPrint + " != " + jsObject2.prettyPrint) + } + } + + "JsonApiSupport" should { + "rawOne correctly prints jsonapi json for one entity" in { + val json = + """ + |{ + | "data": { + | "attributes": { + | "name-mangling": "test data" + | }, + | "relationships": { + | "loaded": { + | "data": { + | "type": "child", + | "id": "1" + | }, + | "links": { + | "related": "/roots/1/loaded" + | } + | }, + | "article": { + | "data": { + | "type": "article", + | "id": "55" + | }, + | "links": { + | "related": "/roots/1/article" + | } + | }, + | "referenced": { + | "data": { + | "type": "child", + | "id": "2" + | }, + | "links": { + | "related": "/roots/1/referenced" + | } + | }, + | "many-referenced": { + | "links": { + | "related": "/roots/1/many-referenced" + | } + | }, + | "many": { + | "data": [{ + | "type": "child", + | "id": "3" + | }, { + | "type": "child", + | "id": "4" + | }, { + | "type": "child", + | "id": "5" + | }], + | "links": { + | "related": "/roots/1/many" + | } + | } + | }, + | "links": { + | "self": "/roots/1" + | }, + | "id": "1", + | "type": "root" + | }, + | "included": [{ + | "type": "article", + | "attributes": { + | "name": "test" + | }, + | "id": "55", + | "links": { + | "self": "/articles/55" + | } + | }, { + | "type": "child", + | "attributes": { + | "name": "test 3" + | }, + | "id": "3", + | "links": { + | "self": "/children/3" + | } + | }, { + | "type": "child", + | "attributes": { + | "name": "test" + | }, + | "id": "1", + | "links": { + | "self": "/children/1" + | } + | }, { + | "type": "child", + | "attributes": { + | "name": "test 4" + | }, + | "id": "4", + | "links": { + | "self": "/children/4" + | } + | }, { + | "type": "child", + | "attributes": { + | "name": "test 5" + | }, + | "id": "5", + | "links": { + | "self": "/children/5" + | } + | }] + |} + """.stripMargin.parseJson.asJsObject + recursiveJsObjectComparsion(rawOne(data), json) + } + + "rawOne correctly prints jsonapi json for one entity with sparse fields defined" in { + val json = + """ + |{ + | "data": { + | "attributes": { + | "name-mangling": "test data" + | }, + | "relationships": { + | "article": { + | "data": { + | "type": "article", + | "id": "55" + | }, + | "links": { + | "related": "/roots/1/article" + | } + | }, + | "referenced": { + | "data": { + | "type": "child", + | "id": "2" + | }, + | "links": { + | "related": "/roots/1/referenced" + | } + | } + | }, + | "links": { + | "self": "/roots/1" + | }, + | "id": "1", + | "type": "root" + | }, + | "included": [{ + | "type": "child", + | "id": "4", + | "links": { + | "self": "/children/4" + | } + | }, { + | "type": "child", + | "id": "3", + | "links": { + | "self": "/children/3" + | } + | }, { + | "type": "child", + | "id": "5", + | "links": { + | "self": "/children/5" + | } + | }, { + | "type": "article", + | "attributes": { + | "name": "test" + | }, + | "id": "55", + | "links": { + | "self": "/articles/55" + | } + | }, { + | "type": "child", + | "id": "1", + | "links": { + | "self": "/children/1" + | } + | }] + |} + """.stripMargin.parseJson.asJsObject + + implicit val sparseFields: Map[String, List[String]] = + Map("root" -> List("name-mangling", "referenced", "article"), + "child" -> List("fieldThatDoesNotExist"), + "article" -> List("name")) + + recursiveJsObjectComparsion(rawOne(data), json) + } + + "rawCollection correctly prints jsonapi json for two entities" in { + val json = + """ + |{ + | "data": [{ + | "attributes": { + | "name-mangling": "test data" + | }, + | "relationships": { + | "loaded": { + | "data": { + | "type": "child", + | "id": "1" + | }, + | "links": { + | "related": "/roots/1/loaded" + | } + | }, + | "article": { + | "data": { + | "type": "article", + | "id": "55" + | }, + | "links": { + | "related": "/roots/1/article" + | } + | }, + | "referenced": { + | "data": { + | "type": "child", + | "id": "2" + | }, + | "links": { + | "related": "/roots/1/referenced" + | } + | }, + | "many-referenced": { + | "links": { + | "related": "/roots/1/many-referenced" + | } + | }, + | "many": { + | "data": [{ + | "type": "child", + | "id": "3" + | }, { + | "type": "child", + | "id": "4" + | }, { + | "type": "child", + | "id": "5" + | }], + | "links": { + | "related": "/roots/1/many" + | } + | } + | }, + | "links": { + | "self": "/roots/1" + | }, + | "id": "1", + | "type": "root" + | }, { + | "attributes": { + | "name-mangling": "test data" + | }, + | "relationships": { + | "loaded": { + | "data": { + | "type": "child", + | "id": "1" + | }, + | "links": { + | "related": "/roots/2/loaded" + | } + | }, + | "article": { + | "data": { + | "type": "article", + | "id": "55" + | }, + | "links": { + | "related": "/roots/2/article" + | } + | }, + | "referenced": { + | "data": { + | "type": "child", + | "id": "2" + | }, + | "links": { + | "related": "/roots/2/referenced" + | } + | }, + | "many-referenced": { + | "links": { + | "related": "/roots/2/many-referenced" + | } + | }, + | "many": { + | "data": [{ + | "type": "child", + | "id": "3" + | }, { + | "type": "child", + | "id": "4" + | }, { + | "type": "child", + | "id": "5" + | }], + | "links": { + | "related": "/roots/2/many" + | } + | } + | }, + | "links": { + | "self": "/roots/2" + | }, + | "id": "2", + | "type": "root" + | }], + | "included": [{ + | "type": "article", + | "attributes": { + | "name": "test" + | }, + | "id": "55", + | "links": { + | "self": "/articles/55" + | } + | }, { + | "type": "child", + | "attributes": { + | "name": "test 3" + | }, + | "id": "3", + | "links": { + | "self": "/children/3" + | } + | }, { + | "type": "child", + | "attributes": { + | "name": "test" + | }, + | "id": "1", + | "links": { + | "self": "/children/1" + | } + | }, { + | "type": "child", + | "attributes": { + | "name": "test 4" + | }, + | "id": "4", + | "links": { + | "self": "/children/4" + | } + | }, { + | "type": "child", + | "attributes": { + | "name": "test 5" + | }, + | "id": "5", + | "links": { + | "self": "/children/5" + | } + | }] + |} + """.stripMargin.parseJson.asJsObject + recursiveJsObjectComparsion(rawCollection(Iterable(data, data2)), json) + } + + "rawCollection correctly prints jsonapi json for two entities with sparse fields defined" in { + val json = + """ + |{ + | "data": [{ + | "attributes": { + | "name-mangling": "test data" + | }, + | "relationships": { + | "article": { + | "data": { + | "type": "article", + | "id": "55" + | }, + | "links": { + | "related": "/roots/1/article" + | } + | }, + | "referenced": { + | "data": { + | "type": "child", + | "id": "2" + | }, + | "links": { + | "related": "/roots/1/referenced" + | } + | } + | }, + | "links": { + | "self": "/roots/1" + | }, + | "id": "1", + | "type": "root" + | }, { + | "attributes": { + | "name-mangling": "test data" + | }, + | "relationships": { + | "article": { + | "data": { + | "type": "article", + | "id": "55" + | }, + | "links": { + | "related": "/roots/2/article" + | } + | }, + | "referenced": { + | "data": { + | "type": "child", + | "id": "2" + | }, + | "links": { + | "related": "/roots/2/referenced" + | } + | } + | }, + | "links": { + | "self": "/roots/2" + | }, + | "id": "2", + | "type": "root" + | }], + | "included": [{ + | "type": "child", + | "id": "4", + | "links": { + | "self": "/children/4" + | } + | }, { + | "type": "child", + | "id": "3", + | "links": { + | "self": "/children/3" + | } + | }, { + | "type": "child", + | "id": "5", + | "links": { + | "self": "/children/5" + | } + | }, { + | "type": "article", + | "attributes": { + | "name": "test" + | }, + | "id": "55", + | "links": { + | "self": "/articles/55" + | } + | }, { + | "type": "child", + | "id": "1", + | "links": { + | "self": "/children/1" + | } + | }] + |} + """.stripMargin.parseJson.asJsObject + + implicit val sparseFields: Map[String, List[String]] = + Map("root" -> List("name-mangling", "referenced", "article"), + "child" -> List("fieldThatDoesNotExist"), + "article" -> List("name")) + + recursiveJsObjectComparsion(rawCollection(Iterable(data, data2)), json) + } + + "return correct media type" in { + Get() ~> route ~> check { + contentType must_== ct + } + } + + "return relationships" in { + Get() ~> route ~> check { + import _root_.spray.json.DefaultJsonProtocol._ + import _root_.spray.json.lenses.JsonLenses._ + + contentType must_== ct + + val json = JsonParser(responseAs[String]) + + json.extract[String](Symbol("data") / "id") must_== data.id + + json.extract[String](Symbol("data") / "relationships" / "loaded" / "data" / "type") must_== Child.resourceType.resourceType + json.extract[String](Symbol("data") / "relationships" / "loaded" / "data" / "id") must_== child.id + json.extract[String](Symbol("data") / "relationships" / "loaded" / "links" / "related") must_== "/roots/1/loaded" + + json.extract[String](Symbol("data") / "relationships" / "referenced" / "data" / "type") must_== Child.resourceType.resourceType + json.extract[String](Symbol("data") / "relationships" / "referenced" / "data" / "id") must_== "2" + json.extract[String](Symbol("data") / "relationships" / "referenced" / "links" / "related") must_== "/roots/1/referenced" + + json.extract[String](Symbol("data") / "relationships" / "many" / "data" / element(0) / "type") must_== Child.resourceType.resourceType + json.extract[String](Symbol("data") / "relationships" / "many" / "data" / element(0) / "id") must_== "3" + json.extract[String](Symbol("data") / "relationships" / "many" / "data" / element(1) / "type") must_== Child.resourceType.resourceType + json.extract[String](Symbol("data") / "relationships" / "many" / "data" / element(1) / "id") must_== "4" + json.extract[String](Symbol("data") / "relationships" / "many" / "data" / element(2) / "type") must_== Child.resourceType.resourceType + json.extract[String](Symbol("data") / "relationships" / "many" / "data" / element(2) / "id") must_== "5" + json.extract[String](Symbol("data") / "relationships" / "many" / "links" / "related") must_== "/roots/1/many" + + val included: Seq[JsValue] = json.extract[JsArray]("included").elements + + def checkIdAndType(id: String, `type`: String): Boolean = + included.exists { element => + val fields = element.asJsObject.fields.toSeq + fields.contains("id" -> JsString(id)) && fields.contains("type" -> JsString(`type`)) + } + + checkIdAndType(child.id, Child.resourceType.resourceType) must_== true + checkIdAndType("3", Child.resourceType.resourceType) must_== true + checkIdAndType("4", Child.resourceType.resourceType) must_== true + checkIdAndType("5", Child.resourceType.resourceType) must_== true + checkIdAndType("55", Article.resourceType.resourceType) must_== true + } + } + + "handle UTF-8 correctly" in { + val utf = "вÑÅÄÖåäöæøå" + val root = Root( + "foo", + utf, + ToOne.reference("foo"), + ToOne.reference("foo"), + ToMany.reference("/roots/foo/many"), + ToMany.reference("/roots/foo/many-referenced"), + ToOne.reference("foo") + ) + Post("/single", root) ~> route ~> check { + contentType must_== ct + + val parsed = readOne[Root](JsonParser(responseAs[String]).asJsObject) + + parsed must_== root + } + } + + "render poly lists as jsonapi" in { + @jsonApiResource case class TestA(id: String) + @jsonApiResource case class TestB(id: String) + + type AorB = TestA :+: TestB :+: CNil + + implicitly[JsonApiWriter[AorB]].write(Coproduct[AorB](TestA("1"))) must_== TestA("1").toJson + implicitly[JsonApiWriter[AorB]].write(Coproduct[AorB](TestB("2"))) must_== TestB("2").toJson + } + + "marshal a RelatedResponse[Thang] to JSON" in { + val route = (path("notSparse") & get) { + implicit val marshaller: ToEntityMarshaller[RelatedResponse[Thang]] = relatedResponseMarshaller[Thang] + complete { + Future.successful(Some(RelatedResponse.apply(thang))) + } + } + + Get("/notSparse") ~> route ~> check { + val json = JsonParser(responseAs[String]) + + json must be equalTo rawOne[Thang](thang) + } + } + + "marshal a RelatedResponse[Thang] to JSON with sparse fields" in { + // The sparseFields implicit has to be before the marshaller as the value of it will be "hard-coded" inside of it and not reloaded for each "complete()". + implicit val sparseFields: Map[String, List[String]] = Map("thangs" -> List("age")) + implicit val marshaller: ToEntityMarshaller[RelatedResponse[Thang]] = relatedResponseMarshaller[Thang] + + val route = + (path("sparse") & get) { + complete { + Future.successful(Some(RelatedResponse.apply(thang))) + } + } + + Get("/sparse") ~> route ~> check { + val json = JsonParser(responseAs[String]) + json must be equalTo rawOne[Thang](thang) + } + } + + "unmarshaller a single jsonapi object" in { + Post("/single?include=loaded,article,referenced,many-referenced,many", data) ~> route ~> check { + contentType must_== ct + + val json = JsonParser(responseAs[String]) + val printed = rawOne[Root](data) + + json must be equalTo printed + } + } + + "unmarshaller a single jsonapi object that does not have an id" in { + val thingData = Thing("test", ToOne.loaded(thang)) + + Post("/thing?include=thang", thingData) ~> route ~> check { + contentType must_== ct + + val json = JsonParser(responseAs[String]) + + val writtenJson = rawOne[Thing](thingData) + + json must be equalTo writtenJson + } + } + + "unmarshaller collection jsonapi object" in { + Post("/collection?include=loaded,article,referenced,many-referenced,many", Iterable(data, data2)) ~> route ~> check { + contentType must_== ct + + val json = JsonParser(responseAs[String]) + + val marshalledJson = rawCollection[Root](List(data, data2)) + + json must be equalTo marshalledJson + } + } + + "readOne correctly handles relationships looping back to main entity" in { + @jsonApiResource case class A(id: String, a: ToOne[A]) + + val a = A("1", ToOne.Loaded(A("2", ToOne.reference("1")))) + + val json = rawOne[A](a) + + val parsed = readOne[A](json, Set("a.a")) + + parsed.id must be equalTo "1" + parsed.a.id must be equalTo "2" + + val rel = parsed.a.asInstanceOf[ToOne.Loaded[A]].entity.a + + rel must beAnInstanceOf[ToOne.Loaded[A]] + + val loaded = rel.asInstanceOf[ToOne.Loaded[A]].entity + + loaded must be equalTo A("1", ToOne.reference("2")) + } + + "readCollection correctly handles relationships looping back main entity" in { + @jsonApiResource case class A(id: String, a: ToOne[A]) + + val a = A("a1", ToOne.Loaded(A("a2", ToOne.reference("a1")))) + val b = A("b1", ToOne.Loaded(A("b2", ToOne.reference("b1")))) + + val json = rawCollection[A](List(a, b)) + + val data = readCollection[A](json, Set("a.a")) + + data must contain { (parsed: A) => + parsed.id must be equalTo "a1" + parsed.a.id must be equalTo "a2" + + val rel = parsed.a.asInstanceOf[ToOne.Loaded[A]].entity.a + + rel must beAnInstanceOf[ToOne.Loaded[A]] + + val loaded = rel.asInstanceOf[ToOne.Loaded[A]].entity + + loaded must be equalTo A("a1", ToOne.reference("a2")) + }.exactly(1.times) + + data must contain { (parsed: A) => + parsed.id must be equalTo "b1" + parsed.a.id must be equalTo "b2" + + val rel = parsed.a.asInstanceOf[ToOne.Loaded[A]].entity.a + + rel must beAnInstanceOf[ToOne.Loaded[A]] + + val loaded = rel.asInstanceOf[ToOne.Loaded[A]].entity + + loaded must be equalTo A("b1", ToOne.reference("b2")) + }.exactly(1.times) + } + "unmarshal TopLevel.Single" in { + + val route = get { + complete( + HttpResponse( + status = StatusCodes.BadRequest, + entity = HttpEntity( + MediaTypes.`application/vnd.api+json`, + TopLevel + .Single( + data = None, + links = Map.empty, + meta = Map.empty, + jsonapi = None, + included = Map.empty + ) + .toJson + .prettyPrint + ) + )) + } + Get("/") ~> route ~> check { + val single = responseAs[TopLevel.Single] + single.data must beNone + } + } + "unmarshal response to TopLevel.Collection" in { + val route = get { + complete( + HttpResponse( + status = StatusCodes.BadRequest, + entity = HttpEntity( + MediaTypes.`application/vnd.api+json`, + TopLevel + .Collection( + data = Map.empty, + links = Map.empty, + meta = Map.empty, + jsonapi = None, + included = Map.empty + ) + .toJson + .prettyPrint + ) + )) + } + Get("/") ~> route ~> check { + val collection = responseAs[TopLevel.Collection] + collection.data must be empty + } + } + "unmarshal response to TopLevel.Errors" in { + val route = get { + complete( + HttpResponse( + status = StatusCodes.BadRequest, + entity = HttpEntity( + MediaTypes.`application/vnd.api+json`, + TopLevel + .Errors( + meta = Map.empty, + jsonapi = None, + links = Map.empty, + errors = Set(ErrorObject( + id = None, + links = Map.empty, + status = Some(StatusCodes.BadRequest.intValue.toString), + code = None, + title = Some("title"), + detail = Some("detail"), + source = None, + meta = Map.empty + )) + ) + .toJson + .prettyPrint + ) + )) + } + Get("/") ~> route ~> check { + val errors = responseAs[TopLevel.Errors] + val firstError = errors.errors.head + + firstError.title must beSome("title") + } + } + "unmarshal response to jsonapi entity" in { + val route = get { + complete(HttpResponse(StatusCodes.OK, entity = HttpEntity(rawOne(thang).prettyPrint))) + } + Get("/") ~> route ~> check { + val thangResponse = responseAs[Thang] + thangResponse must be equalTo thang + } + } + } +} diff --git a/build.sbt b/build.sbt index d4da69e..1832c60 100644 --- a/build.sbt +++ b/build.sbt @@ -276,6 +276,59 @@ lazy val pekko = (project in file("pekko")) ) ++ testDeps ) +val akkaVersion = "2.6.20" +val akkaHttpVersion = "10.2.10" + +lazy val akkaClient = (project in file("akka-client")) + .dependsOn(core) + .enablePlugins(MacrosCompiler) + .settings(scalafixSettings) + .settings( + name := "jsonapi-scala-akka-client", + scalaVersion := scalaVersion213, + crossScalaVersions := Seq(scalaVersion212, scalaVersion213), + scalacOptions ++= { + if (scalaVersion.value startsWith "2.12.") { + scala212 + } else + scala213 + }, + libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-stream" % akkaVersion % Provided, + "com.typesafe.akka" %% "akka-actor" % akkaVersion % Provided, + "com.typesafe.akka" %% "akka-http" % akkaHttpVersion % Provided, + "com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpVersion % Provided + ) ++ testDeps + ) + +lazy val akka = (project in file("akka")) + .dependsOn(core, model) + .enablePlugins(MacrosCompiler) + .settings(scalafixSettings) + .settings( + name := "jsonapi-scala-akka", + scalaVersion := scalaVersion213, + crossScalaVersions := Seq(scalaVersion212, scalaVersion213), + scalacOptions ++= { + if (scalaVersion.value startsWith "2.12.") { + scala212 + } else + scala213 + }, + libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-actor" % akkaVersion % Provided excludeAll ( + ExclusionRule(organization = "com.typesafe.akka", name = "akka-cluster"), + ExclusionRule(organization = "com.typesafe.akka", name = "akka-remote") + ), + "com.typesafe.akka" %% "akka-stream" % akkaVersion % Provided, + "com.typesafe.akka" %% "akka-http" % akkaHttpVersion % Provided, + "com.typesafe.akka" %% "akka-http-core" % akkaHttpVersion % Provided, + "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % Test, + "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test, + "org.scalatest" %% "scalatest" % "3.2.14" % Test + ) ++ testDeps + ) + lazy val http4sClient = (project in file("http4s-client")) .dependsOn(core) .enablePlugins(MacrosCompiler) @@ -301,7 +354,7 @@ lazy val http4sClient = (project in file("http4s-client")) ) lazy val root = (project in file(".")) - .aggregate(core, model, pekkoClient, http4sClient, pekko) + .aggregate(core, model, pekkoClient, http4sClient, pekko, akkaClient, akka) .settings( publishArtifact := false, name := "jsonapi-scala",