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
136 changes: 136 additions & 0 deletions app/controllers/ArkController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package controllers

import auth.AuthAction
import models._
import play.api.i18n.{I18nSupport, Messages}
import play.api.libs.json.JsError.toJson
import play.api.libs.json._
import play.api.mvc._
import services._

import javax.inject._
import scala.concurrent.ExecutionContext
import scala.concurrent.Future.{successful => immediate}

/**
* This controller handles actions related to the ARK (Archival Reference Key) service.
*/
@Singleton
class ArkController @Inject()(
val controllerComponents: ControllerComponents,
arkService: ArkService,
pidService: PidService,
AuthAction: AuthAction,
)(implicit val ec: ExecutionContext, appConfig: AppConfig) extends BaseController with I18nSupport with AppControllerHelpers {

private val logger = play.api.Logger(getClass)

private def jsonApiError(status: Status, message: String, args: String*)(implicit request: RequestHeader): Result = {
status(JsonApiError(Messages(message, args: _*), status = Some(status.header.status.toString)))
}

/**
* Renders the list of ARKs.
*/
def index(): Action[AnyContent] = Action.async { implicit request =>
pidService.findAll(PidType.ARK).map { arks =>
Ok(views.html.arks.list(arks.map(p => Ark.create(p.value, p.target, p.tombstone))))
}
}

def getByTarget(target: String): Action[AnyContent] = Action.async { implicit request =>
pidService.findByTarget(PidType.ARK, target).map {
case Some(pid) =>
render {
case Accepts.Html() => Ok(pid.value)
case _ => Ok(Ark(
metadata = ArkMetadata(Some(pid.value)),
target = pid.target,
tombstone = pid.tombstone
))
}
case None => render {
case Accepts.Html() => NotFound(views.html.errors.notFound("ARK not found"))
case _ => jsonApiError(NotFound, "errors.ark.notFound")
}
}
}

def get(prefix: String, suffix: String): Action[AnyContent] = Action.async { implicit request =>
pidService.findById(PidType.ARK, s"$prefix/$suffix").map {
case Some(pid) =>
val target = pid.target
pid.tombstone.map { tombstone =>
render {
case Accepts.Html() => Gone(views.html.arks.tombstone(tombstone.reason))
case _ => jsonApiError(Gone, "errors.ark.gone.explanation")
}
}.getOrElse {
render {
case Accepts.Html() => SeeOther(target)
case _ => Ok(Ark(
metadata = ArkMetadata(Some(pid.value)),
target = target,
tombstone = None
)
)
}
}
case None => render {
case Accepts.Html() => NotFound(views.html.errors.notFound("ARK not found"))
case _ => jsonApiError(NotFound, "errors.ark.notFound")
}
}
}

def update(prefix: String, suffix: String): Action[JsValue] = AuthAction.async(apiJson[JsValue]) { implicit request =>
request.body.validate[Ark] match {
case JsSuccess(Ark(metadata, target, _), _) =>
val ark = s"$prefix/$suffix"
logger.debug(s"Updating ARK '$ark' with target: $target")

for {
pid <- pidService.update(PidType.ARK, ark, target)
} yield Ok(Ark(metadata, target, pid.tombstone))
case JsError(errors) =>
logger.error(s"Invalid request body: $errors")
immediate(jsonApiError(BadRequest, "errors.invalidRequest"))
}
}

def delete(prefix: String, suffix: String): Action[AnyContent] = AuthAction.async { implicit request =>
// Delete the PID
for {
_ <- pidService.delete(PidType.ARK, s"$prefix/$suffix")
} yield NoContent
}

def tombstone(prefix: String, suffix: String): Action[JsonApiData] = AuthAction.async(apiJson[JsonApiData]) { implicit request =>
request.body.data.validate[TombstoneReason] match {
case JsSuccess(reason, _) =>
val ark = s"$prefix/$suffix"
logger.debug(s"Tombstoning ARK '$ark' with reason: '$reason'...")

pidService.tombstone(PidType.ARK, ark, request.clientId, reason.reason).map {
case true => NoContent
case false =>
jsonApiError(BadRequest, "errors.ark.tombstoneFailed", ark)
}

case JsError(errors) =>
logger.error(s"Invalid request body: $errors")
immediate(jsonApiError(BadRequest, "errors.invalidRequest"))
}
}

def deleteTombstone(prefix: String, suffix: String): Action[AnyContent] = AuthAction.async { implicit request =>
val ark = s"$prefix/$suffix"
logger.debug(s"Deleting tombstone for ARK '$ark'...")

pidService.deleteTombstone(PidType.ARK, ark).map {
case true => NoContent
case false =>
jsonApiError(BadRequest, "errors.ark.tombstoneNotFound", ark)
}
}
}
36 changes: 36 additions & 0 deletions app/models/Ark.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package models

import org.apache.pekko.util.ByteString
import play.api.http.Writeable
import play.api.libs.functional.syntax._
import play.api.libs.json._

case class Ark(metadata: ArkMetadata, target: String, tombstone: Option[Tombstone] = None)
object Ark {
def create(ark: String, target: String, tombstone: Option[Tombstone] = None): Ark = Ark(
metadata = ArkMetadata(Some(ark)),
target = target,
tombstone = tombstone
)

implicit val _reads: Reads[Ark] = (
(__ \ "data").read[ArkMetadata] and
(__ \ "meta" \ "target").read[String] and
(__ \ "meta" \ "tombstone").readNullable[Tombstone]
)(Ark.apply _)

// Format that writes the metadata json+api format
// with the target as a meta attribute
implicit val _writes: Writes[Ark] = (
(__ \ "data").write[ArkMetadata] and
(__ \ "meta" \ "target").write[String] and
(__ \ "meta" \ "tombstone").writeNullable[Tombstone]
)(unlift(Ark.unapply))

implicit val _format: Format[Ark] = Format(_reads, _writes)

implicit val _writeable: Writeable[Ark] = Writeable(
d => ByteString(Json.toBytes(Json.toJson(d))),
Some("application/vnd.api+json")
)
}
21 changes: 21 additions & 0 deletions app/models/ArkMetadata.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package models

import org.apache.pekko.util.ByteString
import play.api.libs.json._
import play.api.libs.ws.{BodyWritable, InMemoryBody}

case class ArkMetadata(id: Option[String], attributes: JsValue = JsObject.empty) {
def prefix: String = id.flatMap(_.split("/").headOption).getOrElse("")
def suffix: String = id.flatMap(_.split("/").lift(1)).getOrElse("")

def withArk(ark: String): ArkMetadata = this.copy(id = Some(ark))
}

object ArkMetadata {
implicit val _format: Format[ArkMetadata] = Json.format[ArkMetadata]

implicit val _writeable: BodyWritable[ArkMetadata] = BodyWritable(
(d: ArkMetadata) => InMemoryBody(ByteString(Json.toBytes(Json.obj("data" -> d)))),
"application/vnd.api+json"
)
}
17 changes: 17 additions & 0 deletions app/services/ArkService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package services

import com.google.inject.ImplementedBy

@ImplementedBy(classOf[ArkServiceImpl])
trait ArkService {
def generateSuffix(): String
}

case class ArkServiceImpl() extends ArkService {
// Simple implementation that generates a random suffix
// In a real application, this could be more complex
override def generateSuffix(): String = {
val alphabet = "abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
(1 to 8).map(_ => alphabet(scala.util.Random.nextInt(alphabet.length))).mkString
}
}
25 changes: 25 additions & 0 deletions app/views/arks/list.scala.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@(arks: Seq[Ark], prod: Boolean = false)(implicit request: RequestHeader, messages: Messages, config: AppConfig)

@views.html.layout.base("ARK List") {
<div class="page-content">
<article class="search-list">
<ul class="ark-list">
@for(ark <- arks) {
<li class="ark-list-item" id="ark-@ark.metadata.id">
<header>
<h3 class="ark-heading">
<a href="@routes.ArkController.get(ark.metadata.prefix, ark.metadata.suffix)">@ark.metadata.id</a>
</h3>
</header>
<div class="ark-description">
<p><a class="ark-target" href="@ark.target">@ark.target</a></p>
</div>
</li>
}
</ul>
</article>
<aside id="sidebar">

</aside>
</div>
}
8 changes: 8 additions & 0 deletions app/views/arks/tombstone.scala.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@(message: String)(implicit request: RequestHeader, messages: Messages)

@views.html.layout.base(Messages("errors.ark.gone")) {
<div class="page-content">
<h1>@Messages("errors.ark.gone")</h1>
<p>@Messages("errors.ark.gone.explanation")</p>
</div>
}
12 changes: 12 additions & 0 deletions conf/messages
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ errors.doi.tombstoneNotFound=The DOI provided either does not exist, or is not t
errors.doi.collisionError=A DOI collision has occurred. This DOI has already been taken. \
This error is usually transient and can be resolved by retrying the request.

# Ark messages
errors.ark.notFound=The identifier you provided does not exist, is unpublished, or is not valid.
errors.ark.exception=Unexpected response: {0}
errors.ark.registrationFailed=An error occurred while registering the ARK {0}. \
Please check the ARK metadata and try again.
errors.ark.tombstoneFailed=Error creating tombstone for ARK {0}.
errors.ark.tombstoneNotFound=The ARK provided either does not exist, or is not tombstoned.
errors.ark.collisionError=This ARK has already been taken. \
This error is usually transient and can be resolved by retrying the request.
errors.ark.gone=The ARK you provided is no longer available.
errors.ark.gone.explanation=This is a valid item identifier but the item is no longer available.

pagination.previousPage=Previous Page
pagination.nextPage=Next Page

Expand Down
13 changes: 13 additions & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ POST /dois/:prefix/*suffix/tombstone controllers.DoiController.t
+nocsrf
DELETE /dois/:prefix/*suffix/tombstone controllers.DoiController.deleteTombstone(prefix: String, suffix: String)

# ARK operations
GET /arks controllers.ArkController.index()
GET /arks/$prefix<[0-9]+>/*suffix controllers.ArkController.get(prefix: String, suffix: String)
GET /arks/*target controllers.ArkController.getByTarget(target: String)
+nocsrf
PUT /arks/:prefix/*suffix controllers.ArkController.update(prefix: String, suffix: String)
+nocsrf
DELETE /arks/:prefix/*suffix controllers.ArkController.delete(prefix: String, suffix: String)
+nocsrf
POST /arks/:prefix/*suffix/tombstone controllers.ArkController.tombstone(prefix: String, suffix: String)
+nocsrf
DELETE /arks/:prefix/*suffix/tombstone controllers.ArkController.deleteTombstone(prefix: String, suffix: String)

# Preview
GET /preview controllers.PreviewController.preview(url: String ?= "")

Expand Down
Loading