|
| 1 | +package controllers |
| 2 | + |
| 3 | +import actions.{ApiSecurityAction, PdsAuthAction} |
| 4 | +import jakarta.inject.{Inject, Singleton} |
| 5 | +import play.api.Logging |
| 6 | +import play.api.libs.json.{Json, OFormat} |
| 7 | +import play.api.mvc.* |
| 8 | +import services.{HeartbeatRequest, PdsFleetService, SubmissionProvenanceService} |
| 9 | + |
| 10 | +import java.util.UUID |
| 11 | +import scala.concurrent.ExecutionContext |
| 12 | + |
| 13 | +@Singleton |
| 14 | +class PdsFleetApiController @Inject()( |
| 15 | + val controllerComponents: ControllerComponents, |
| 16 | + pdsAuth: PdsAuthAction, |
| 17 | + secureApi: ApiSecurityAction, |
| 18 | + fleetService: PdsFleetService, |
| 19 | + submissionService: SubmissionProvenanceService |
| 20 | + )(implicit ec: ExecutionContext) extends BaseController with Logging { |
| 21 | + |
| 22 | + // --- PDS-authenticated endpoints (called by edge nodes) --- |
| 23 | + |
| 24 | + case class HeartbeatPayload( |
| 25 | + status: String, |
| 26 | + softwareVersion: Option[String] = None, |
| 27 | + loadMetrics: Option[play.api.libs.json.JsValue] = None, |
| 28 | + processingQueueSize: Option[Int] = None, |
| 29 | + errorMessage: Option[String] = None, |
| 30 | + lastCommitCid: Option[String] = None, |
| 31 | + lastCommitRev: Option[String] = None |
| 32 | + ) |
| 33 | + object HeartbeatPayload { implicit val format: OFormat[HeartbeatPayload] = Json.format } |
| 34 | + |
| 35 | + def heartbeat(): Action[HeartbeatPayload] = pdsAuth.jsonAction[HeartbeatPayload].async { request => |
| 36 | + val node = request.pdsNode |
| 37 | + val payload = request.body |
| 38 | + val hbRequest = HeartbeatRequest( |
| 39 | + did = node.did, |
| 40 | + pdsUrl = node.pdsUrl, |
| 41 | + handle = node.handle, |
| 42 | + nodeName = node.nodeName, |
| 43 | + softwareVersion = payload.softwareVersion, |
| 44 | + status = payload.status, |
| 45 | + loadMetrics = payload.loadMetrics, |
| 46 | + processingQueueSize = payload.processingQueueSize, |
| 47 | + lastCommitCid = payload.lastCommitCid, |
| 48 | + lastCommitRev = payload.lastCommitRev, |
| 49 | + errorMessage = payload.errorMessage |
| 50 | + ) |
| 51 | + |
| 52 | + fleetService.processHeartbeat(hbRequest).map { |
| 53 | + case Right(updatedNode) => Ok(Json.toJson(updatedNode)) |
| 54 | + case Left(error) => BadRequest(Json.obj("error" -> error)) |
| 55 | + } |
| 56 | + } |
| 57 | + |
| 58 | + case class SubmissionPayload( |
| 59 | + submissionType: String, |
| 60 | + proposedValue: String, |
| 61 | + biosampleId: Option[Int] = None, |
| 62 | + biosampleGuid: Option[UUID] = None, |
| 63 | + confidenceScore: Option[Double] = None, |
| 64 | + algorithmVersion: Option[String] = None, |
| 65 | + softwareVersion: Option[String] = None, |
| 66 | + payload: Option[play.api.libs.json.JsValue] = None, |
| 67 | + atUri: Option[String] = None, |
| 68 | + atCid: Option[String] = None |
| 69 | + ) |
| 70 | + object SubmissionPayload { implicit val format: OFormat[SubmissionPayload] = Json.format } |
| 71 | + |
| 72 | + def submitData(): Action[SubmissionPayload] = pdsAuth.jsonAction[SubmissionPayload].async { request => |
| 73 | + val node = request.pdsNode |
| 74 | + val p = request.body |
| 75 | + |
| 76 | + submissionService.recordSubmission( |
| 77 | + did = node.did, |
| 78 | + submissionType = p.submissionType, |
| 79 | + proposedValue = p.proposedValue, |
| 80 | + biosampleId = p.biosampleId, |
| 81 | + biosampleGuid = p.biosampleGuid, |
| 82 | + confidenceScore = p.confidenceScore, |
| 83 | + algorithmVersion = p.algorithmVersion, |
| 84 | + softwareVersion = p.softwareVersion, |
| 85 | + payload = p.payload, |
| 86 | + atUri = p.atUri, |
| 87 | + atCid = p.atCid |
| 88 | + ).map { |
| 89 | + case Right(submission) => Created(Json.toJson(submission)) |
| 90 | + case Left(error) => BadRequest(Json.obj("error" -> error)) |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + // --- Admin-authenticated endpoints (X-API-Key secured) --- |
| 95 | + |
| 96 | + def getFleetSummary: Action[AnyContent] = secureApi.async { _ => |
| 97 | + fleetService.getFleetSummary.map(summary => Ok(Json.toJson(summary))) |
| 98 | + } |
| 99 | + |
| 100 | + def listNodes(status: Option[String]): Action[AnyContent] = secureApi.async { _ => |
| 101 | + fleetService.listNodes(status).map { nodes => |
| 102 | + Ok(Json.obj("nodes" -> nodes, "total" -> nodes.size)) |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + def getNode(did: String): Action[AnyContent] = secureApi.async { _ => |
| 107 | + fleetService.getNode(did).map { |
| 108 | + case Some(node) => Ok(Json.toJson(node)) |
| 109 | + case None => NotFound(Json.obj("error" -> s"Node not found: $did")) |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + def removeNode(did: String): Action[AnyContent] = secureApi.async { _ => |
| 114 | + fleetService.removeNode(did).map { |
| 115 | + case Right(_) => Ok(Json.obj("removed" -> true)) |
| 116 | + case Left(error) => NotFound(Json.obj("error" -> error)) |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + def markStaleOffline(): Action[AnyContent] = secureApi.async { _ => |
| 121 | + fleetService.markStaleNodesOffline().map { count => |
| 122 | + Ok(Json.obj("markedOffline" -> count)) |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + def getPendingSubmissions(submissionType: Option[String], limit: Int): Action[AnyContent] = secureApi.async { _ => |
| 127 | + submissionService.getPendingSubmissions(submissionType, limit).map { submissions => |
| 128 | + Ok(Json.obj("submissions" -> submissions, "total" -> submissions.size)) |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + case class ReviewRequest(reviewedBy: String, notes: Option[String] = None) |
| 133 | + object ReviewRequest { implicit val format: OFormat[ReviewRequest] = Json.format } |
| 134 | + |
| 135 | + def acceptSubmission(id: Int): Action[ReviewRequest] = secureApi.jsonAction[ReviewRequest].async { request => |
| 136 | + submissionService.acceptSubmission(id, request.body.reviewedBy, request.body.notes).map { |
| 137 | + case Right(_) => Ok(Json.obj("accepted" -> true)) |
| 138 | + case Left(error) => BadRequest(Json.obj("error" -> error)) |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | + def rejectSubmission(id: Int): Action[ReviewRequest] = secureApi.jsonAction[ReviewRequest].async { request => |
| 143 | + submissionService.rejectSubmission(id, request.body.reviewedBy, request.body.notes).map { |
| 144 | + case Right(_) => Ok(Json.obj("rejected" -> true)) |
| 145 | + case Left(error) => BadRequest(Json.obj("error" -> error)) |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + def getNodeSubmissionSummary(did: String): Action[AnyContent] = secureApi.async { _ => |
| 150 | + submissionService.getNodeSubmissionSummary(did).map { |
| 151 | + case Right(summary) => Ok(Json.toJson(summary)) |
| 152 | + case Left(error) => NotFound(Json.obj("error" -> error)) |
| 153 | + } |
| 154 | + } |
| 155 | +} |
0 commit comments