-
Notifications
You must be signed in to change notification settings - Fork 28
Create a new piezo endpoint for updating data #136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
| } | ||
|
|
||
| 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(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| 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") | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.