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
112 changes: 112 additions & 0 deletions admin/app/com/lucidchart/piezo/admin/controllers/JobDataMapPatch.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.lucidchart.piezo.admin.controllers

import org.quartz.JobKey
import play.api.libs.json.*

/**
* A request to set some keys in a single job's data map, leaving the rest of the job definition alone.
*
* Only the supplied keys are written. Keys already on the job that the request does not mention keep their values, and
* there is no way to ask this endpoint to remove a key or clear a map.
*
* @param jobKey
* the job to update
* @param entries
* the keys to set, in order
*/
case class JobDataMapPatch(jobKey: JobKey, entries: List[DataMap])

object JobDataMapPatch {

/**
* Parses the body of a bulk job data map update.
*
* The body looks like
* {{{
* {
* "jobs": [
* {
* "group": "sqs",
* "name": "enqueue-documents",
* "job-data-map": { "lastRunTime": "2026-08-30T17:00:00Z" }
* }
* ]
* }
* }}}
*
* `job-data-map` may also be given in the `[{"key": ..., "value": ...}]` shape that the job read endpoints return. It
* is required, so that a misspelled field name is a visible error rather than a request that reports success while
* changing nothing.
*
* Errors are collected across the whole `jobs` list, so a caller patching hundreds of jobs sees every bad entry at
* once rather than one per round trip.
*/
def parse(json: JsValue): JsResult[List[JobDataMapPatch]] = {
for {
jobs <- pathed((json \ "jobs").validate[List[JsValue]], JsPath \ "jobs")
patches <- traverse(jobs.zipWithIndex) { case (job, index) =>
pathed(parseJob(job), JsPath \ "jobs" \ index)
}
} yield patches
}

private def parseJob(json: JsValue): JsResult[JobDataMapPatch] = {
for {
group <- pathed((json \ "group").validate[String], JsPath \ "group")
name <- pathed((json \ "name").validate[String], JsPath \ "name")
entries <- pathed(parseEntries(json \ "job-data-map"), JsPath \ "job-data-map")
} yield JobDataMapPatch(new JobKey(name, group), entries)
}

private def parseEntries(dataMap: JsLookupResult): JsResult[List[DataMap]] = {
dataMap.toOption match {
case Some(obj: JsObject) =>
traverse(obj.fields.toList) { case (key, value) =>
pathed(parseEntry(key, value), JsPath \ key)
}
case Some(JsArray(pairs)) =>
traverse(pairs.toList.zipWithIndex) { case (pair, index) =>
pathed(parsePairEntry(pair), JsPath \ index)
}
case _ =>
JsError(JsPath, "Expected an object of key/value pairs or an array of {key, value} objects")
}
}

private def parsePairEntry(pair: JsValue): JsResult[DataMap] = {
for {
key <- pathed((pair \ "key").validate[String], JsPath \ "key")
value <- (pair \ "value").toOption
.map(JsSuccess(_))
.getOrElse(JsError(JsPath \ "value", "error.path.missing"))
entry <- pathed(parseEntry(key, value), JsPath \ "value")
} yield entry
}

private def parseEntry(key: String, value: JsValue): JsResult[DataMap] = {
value match {
case JsString(string) => JsSuccess(DataMap(key, string))
case JsNumber(number) => JsSuccess(DataMap(key, number.bigDecimal.toPlainString))
case JsBoolean(boolean) => JsSuccess(DataMap(key, boolean.toString))
case _ => JsError(JsPath, "Job data values must be a string, number, or boolean")
}
}

private def pathed[A](result: JsResult[A], path: JsPath): JsResult[A] = {
result match {
case JsSuccess(value, _) => JsSuccess(value)
case error: JsError => error.repath(path)
}
}

private def traverse[A, B](inputs: List[A])(f: A => JsResult[B]): JsResult[List[B]] = {
inputs.map(f).foldRight[JsResult[List[B]]](JsSuccess(Nil)) { (next, sofar) =>
(next, sofar) match {
case (JsSuccess(value, _), JsSuccess(rest, _)) => JsSuccess(value +: rest)
case (JsError(errors), JsError(restErrors)) => JsError(errors ++ restErrors)
case (JsError(errors), _) => JsError(errors)
case (_, error: JsError) => error
}
}
}
}
50 changes: 50 additions & 0 deletions admin/app/com/lucidchart/piezo/admin/controllers/Jobs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,56 @@ class Jobs(
)
}

def patchJobDataMaps: Action[JsValue] = Action(parse.json(maxFormSize)) { request =>
JobDataMapPatch.parse(request.body) match {
case error: JsError =>
BadRequest(
Json.obj(
"message" -> "Could not parse job data map updates",
"errors" -> JsError.toJson(error),
),
)
case JsSuccess(patches, _) =>
val (failures, updated) = patches.map(applyJobDataMapPatch).partitionMap(identity)
val body = Json.obj(
"count" -> patches.size,
"updated" -> updated.map(jobKeyJson),
"failures" -> failures,
)
if (failures.isEmpty) Ok(body) else MultiStatus(body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm curious who will be calling this endpoint? Other services or will it be public? If the latter, failures need to be sanitized to ensure no sensitive data leaks out.

}
}

private def applyJobDataMapPatch(patch: JobDataMapPatch): Either[JsObject, JobKey] = {
val jobKey = patch.jobKey
try {
Option(scheduler.getJobDetail(jobKey)) match {
case None =>
Left(patchFailure(jobKey, "Job %s %s not found".format(jobKey.getGroup, jobKey.getName)))
case Some(jobDetail) =>
patch.entries.foreach(entry => jobDetail.getJobDataMap.put(entry.key, entry.value))
scheduler.addJob(jobDetail, true, true)
logger.info("Updated job data map for job %s %s".format(jobKey.getGroup, jobKey.getName))
Right(jobKey)
}
} catch {
case e: Exception =>
val errorMsg = "Exception caught updating the job data map of job %s %s. -- %s".format(
jobKey.getGroup,
jobKey.getName,
e.getLocalizedMessage(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is this exception passed through to the HTTP response? If so we need to verify nothing sensitive is exposed.

)
logger.error(errorMsg, e)
Left(patchFailure(jobKey, errorMsg))
}
}

private def jobKeyJson(jobKey: JobKey): JsObject =
Json.obj("jobGroup" -> jobKey.getGroup, "jobName" -> jobKey.getName)

private def patchFailure(jobKey: JobKey, errorMessage: String): JsObject =
jobKeyJson(jobKey) ++ Json.obj("errorMessage" -> errorMessage)

def jobNameTypeAhead(group: String): Action[AnyContent] =
Action { request =>
val jobs =
Expand Down
2 changes: 2 additions & 0 deletions admin/conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ POST /data/jobs com.lucidchart.piezo.admin.controller
GET /data/jobs com.lucidchart.piezo.admin.controllers.Jobs.getJobsDetail
GET /data/jobs/:group/:name com.lucidchart.piezo.admin.controllers.Jobs.getJobDetail(group: String, name: String)

POST /data/jobs/job-data-map com.lucidchart.piezo.admin.controllers.Jobs.patchJobDataMaps

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Curious why this is a POST vs a PATCH request which the controller method implies.


GET /typeahead/jobs/:group com.lucidchart.piezo.admin.controllers.Jobs.jobNameTypeAhead(group: String)

GET /triggers com.lucidchart.piezo.admin.controllers.Triggers.getIndex
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.lucidchart.piezo.admin.controllers

import org.specs2.mutable.*
import play.api.libs.json.*

class JobDataMapPatchTest extends Specification {

private def parseOne(json: JsValue): JobDataMapPatch = {
JobDataMapPatch.parse(json) match {
case JsSuccess(List(patch), _) => patch
case other => throw new AssertionError(s"Expected exactly one patch, got $other")
}
}

private def body(dataMap: JsValue): JsValue = {
Json.obj("jobs" -> Json.arr(Json.obj("group" -> "g", "name" -> "n", "job-data-map" -> dataMap)))
}

"JobDataMapPatch.parse" should {

"read the job key and the keys to set" in {
val patch = parseOne(body(Json.obj("a" -> "1")))
patch.jobKey.getGroup must equalTo("g")
patch.jobKey.getName must equalTo("n")
patch.entries must equalTo(List(DataMap("a", "1")))
}

"stringify numbers and booleans" in {
val entries = parseOne(body(Json.obj("i" -> 1756400000000L, "d" -> 1.5, "b" -> true))).entries
entries must containTheSameElementsAs(
List(DataMap("i", "1756400000000"), DataMap("d", "1.5"), DataMap("b", "true")),
)
}

"not use scientific notation for large numbers" in {
parseOne(body(Json.obj("i" -> BigDecimal("1e20")))).entries must equalTo(
List(DataMap("i", "100000000000000000000")),
)
}

"accept the key/value array shape returned by the read endpoints" in {
val dataMap = Json.arr(Json.obj("key" -> "a", "value" -> "1"), Json.obj("key" -> "b", "value" -> "2"))
parseOne(body(dataMap)).entries must equalTo(List(DataMap("a", "1"), DataMap("b", "2")))
}

"accept an empty data map as a job with nothing to change" in {
parseOne(body(Json.obj())).entries must beEmpty
}

"reject a null value rather than read it as a removal" in {
JobDataMapPatch.parse(body(Json.obj("a" -> JsNull))) must beAnInstanceOf[JsError]
}

"reject a null value in the key/value array shape" in {
val dataMap = Json.arr(Json.obj("key" -> "a", "value" -> JsNull))
JobDataMapPatch.parse(body(dataMap)) must beAnInstanceOf[JsError]
}

"reject a value that is not a scalar" in {
JobDataMapPatch.parse(body(Json.obj("a" -> Json.obj("b" -> "1")))) must beAnInstanceOf[JsError]
}

"reject a job with no data map, so a misspelled field is not a silent no-op" in {
val json = Json.obj("jobs" -> Json.arr(Json.obj("group" -> "g", "name" -> "n", "jobDataMap" -> Json.obj())))
JobDataMapPatch.parse(json) must beAnInstanceOf[JsError]
}

"reject a body with no jobs field" in {
JobDataMapPatch.parse(Json.obj()) must beAnInstanceOf[JsError]
}

"report every bad job in one response" in {
val json = Json.obj(
"jobs" -> Json.arr(
Json.obj("name" -> "missing-group", "job-data-map" -> Json.obj()),
Json.obj("group" -> "g", "name" -> "n", "job-data-map" -> Json.obj()),
Json.obj("group" -> "g", "name" -> "n", "job-data-map" -> Json.obj("a" -> Json.arr())),
),
)
JobDataMapPatch.parse(json) match {
case JsError(errors) =>
errors.map(_._1.toJsonString).toList must containTheSameElementsAs(
List("obj.jobs[0].group", "obj.jobs[2].job-data-map.a"),
)
case other => throw new AssertionError(s"Expected errors, got $other")
}
}
}
}
Loading
Loading