Use the client¶
satisfactory-client is a typed client for the model API. It uses the JDK's java.net.http.HttpClient
and depends on nothing but the wire types, so it works in any JVM program: a plain Scala application, a
script, or an ankka service. An ankka application that wants agent tools, webhook verification and a
fake for its tests adds ankka-satisfactory, which builds on this client.
Add the dependency¶
The client is published for Scala 3 under the com.thinkmorestupidless organisation:
libraryDependencies += "com.thinkmorestupidless" %% "satisfactory-client" % "<version>"
Create a client and choose a model¶
A client is constructed with the API's base URL and a tenant's API key. model returns a ModelClient
for one model and version; its paths come from the model's own descriptor, so the entity name
(schedules for employee scheduling, route-plans for vehicle routing) is never spelled in your code:
import satisfactory.client.*
import satisfactory.protocol.*
val client = SatisfactoryClient("https://api-satisfactory.example.com", sys.env("SAT_KEY"))
val scheduling = client.model("employee-scheduling", "v1")
SatisfactoryClient takes two optional parameters: the HttpClient to use, and a per-request timeout
(60 seconds by default). Every request carries the key in the X-API-KEY header.
Submit, wait, fetch¶
submit posts a SubmitRequest and returns the new dataset's Metadata at once; solving happens on a
worker. awaitFinal polls the metadata until the dataset reaches a final status, with an interval that
grows by half each time up to 30 seconds, and returns the last metadata it saw whether or not the status
is final. get fetches the best solution so far with its metrics and KPIs. This is the client suite's
first test, which runs against the real API:
private lazy val client = SatisfactoryClient(baseUrl, keyA)
private lazy val scheduling = client.model("employee-scheduling", "v1")
test("describe, demo data, submit, poll, fetch") {
assertEquals(scheduling.descriptor.entity, "schedules")
assertEquals(scheduling.demoData().map(_.id), List("SMALL", "LARGE"))
val demo = scheduling.demoRequest("SMALL")
val request = demo.copy(config = Some(ModelConfiguration(run = Some(RunConfiguration(termination = Some(TerminationConfig(spentLimit = Some("PT2S"))))))))
val created = scheduling.submit(request, SubmitOptions(name = Some("from-client"), tags = List("client"), gzip = true))
assertEquals(created.name, Some("from-client"))
val done = scheduling.awaitFinal(created.id, poll = 250.millis)
assertEquals(done.solverStatus, SolvingStatus.Completed)
val result = scheduling.get(created.id)
assert(result.modelOutput.exists(_.asString.contains("\"employee\"")))
assert(scheduling.scoreAnalysis(created.id).constraints.nonEmpty)
assert(scheduling.list(ListFilter(tags = List("client"))).content.exists(_.id == created.id))
assertEquals(client.aboutMe().tenantId, "t_a")
}
SubmitOptions carries what the API takes as query parameters: name, operation (SOLVE or NONE),
configurationId, priority, tags, and two things the API takes as headers: idempotencyKey, sent as
Idempotency-Key so a retried submit returns the same dataset, and gzip, which compresses the body and
sets Content-Encoding: gzip.
The request's config is optional and every field in it is optional. The termination block is where a
solve's length is set: spentLimit and unimprovedSpentLimit are ISO-8601 durations, and
stepCountLimit and moveCountLimit are hardware-independent counts.
Submit and poll explains each field.
Stream progress¶
events opens the dataset's server-sent event stream and returns a blocking iterator of StreamFrame
values. An Update carries the frame's sequence number and the dataset's metadata; a Heartbeat says the
connection is alive. The iterator ends after the final frame, and ends at once for a dataset that is
already final. Pass after to resume from a sequence number:
test("the event iterator unwraps frames, ends on the final one, and resumes after a given seq") {
val id = scheduling.submit(SubmitRequest(scheduling.demoInput("SMALL"), Some(ModelConfiguration(run = Some(RunConfiguration(termination = Some(TerminationConfig(spentLimit = Some("PT4S"))))))))).id
val frames = scheduling.events(id).toList
val updates = frames.collect { case u: StreamFrame.Update => u }
assert(updates.nonEmpty)
assertEquals(updates.last.metadata.solverStatus, SolvingStatus.Completed)
assertEquals(updates.map(_.seq), updates.map(_.seq).sorted.distinct)
assertEquals(scheduling.events(id).toList, Nil, "a final dataset's stream is gone: the iterator just ends")
val resumed = scheduling.events(id, after = Some(updates.head.seq)).toList.collect { case u: StreamFrame.Update => u }
assert(resumed.nonEmpty && resumed.forall(_.seq > updates.head.seq), resumed.toString)
}
The iterator reconnects on a dropped connection from the last sequence number it saw, and gives up after
twenty reconnects that yield no frame. Close it early with close().
eventsPublisher exposes the same frames as a java.util.concurrent.Flow.Publisher, for code that
already consumes reactive streams:
test("the publisher delivers the same frames to a reactive subscriber") {
val id = scheduling.submit(SubmitRequest(scheduling.demoInput("SMALL"), Some(ModelConfiguration(run = Some(RunConfiguration(termination = Some(TerminationConfig(spentLimit = Some("PT2S"))))))))).id
val received = CopyOnWriteArrayList[StreamFrame]()
val finished = CountDownLatch(1)
scheduling.eventsPublisher(id).subscribe(new Flow.Subscriber[StreamFrame]:
def onSubscribe(s: Flow.Subscription): Unit = s.request(Long.MaxValue)
def onNext(item: StreamFrame): Unit = received.add(item): Unit
def onError(t: Throwable): Unit = finished.countDown()
def onComplete(): Unit = finished.countDown()
)
assert(finished.await(60, TimeUnit.SECONDS))
assert(received.asScala.exists { case u: StreamFrame.Update => u.metadata.solverStatus == SolvingStatus.Completed; case _ => false })
}
The frames on the wire are Metadata, never solutions. Fetch the solution with get when the score says
it is worth fetching. Stream progress describes the stream itself.
Derive a new dataset¶
A dataset is never changed in place. fromInput and fromPatch create a child dataset with parentId
set to the source, and deriving from a dataset that is still solving supersedes it: the parent completes
with its best solution and its metadata names the child in supersededBy. terminate stops a solve and
returns the dataset with its best solution kept; purge removes the bodies from storage, after which
get answers 410, and restore brings them back while retention lasts:
test("lineage, terminate, metadata, purge and restore") {
val parent = scheduling.submit(SubmitRequest(scheduling.demoInput("SMALL"), Some(ModelConfiguration(run = Some(RunConfiguration(termination = Some(TerminationConfig(spentLimit = Some("PT60S"))))))))).id
val _ = scheduling.events(parent).find { case u: StreamFrame.Update => u.metadata.score.isDefined && u.metadata.solverStatus == SolvingStatus.Active; case _ => false }
val child = scheduling.fromPatch(parent, Nil, config = Some(ModelConfiguration(run = Some(RunConfiguration(termination = Some(TerminationConfig(spentLimit = Some("PT1S"))))))))
assertEquals(child.parentId, Some(parent))
assertEquals(scheduling.awaitFinal(parent, 250.millis).supersededBy, Some(child.id))
val done = scheduling.awaitFinal(child.id, 250.millis)
assertEquals(scheduling.terminate(child.id).metadata.seq, done.seq, "terminating a final dataset changes nothing")
assertEquals(scheduling.updateMetadata(child.id, name = Some("renamed")).name, Some("renamed"))
scheduling.purge(child.id)
assertEquals(intercept[SatisfactoryError](scheduling.get(child.id)).status, 410)
scheduling.restore(child.id)
assert(scheduling.get(child.id).modelOutput.isDefined)
}
fromPatch takes a list of PatchOp values, each an op (add, remove or replace), a path into
the dataset such as /employees/[id=Lee]/tags, and a value. Lineage instead of mutation
explains when to use each of the three.
Handle a refusal¶
Every method throws SatisfactoryError for a response with a status of 400 or above. It carries the
HTTP status and the API's ErrorInfo: an id, a code, a message and a list of details. A
refusal that ankka's runtime makes before the request reaches satisfactory, such as a 401 from the access
control list, has no ErrorInfo; the client builds one whose code is http-<status> and whose message
is ankka's:
test("refusals are typed: ErrorInfo from the API, and ankka's own 401") {
val invalid = intercept[SatisfactoryError](scheduling.submit(SubmitRequest(RawJson("""{"shifts":"no"}"""))))
assertEquals(invalid.status, 400)
assertEquals(invalid.info.code, "validation")
val unauthorised = intercept[SatisfactoryError](SatisfactoryClient(baseUrl, "sk_nope").aboutMe())
assertEquals(unauthorised.status, 401)
assert(unauthorised.info.message.nonEmpty)
}
Every method¶
| Method | Calls |
|---|---|
describe() and descriptor |
the model's descriptor: schemas, constraints, KPIs, issue types |
demoData(), demoRequest(id), demoInput(id) |
the model's demo datasets |
submit(request, opts) |
POST /{entity} |
solve(id, priority) |
POST /{entity}/{id}, for a dataset submitted with operation=NONE |
get(id), metadata(id), input(id), modelRequest(id), config(id) |
the dataset, its metadata, its input, the whole request, the resolved configuration |
validationResult(id), scoreAnalysis(id, includeJustifications), logs(id) |
the model's validation issues, the per-constraint score, the solve's log |
analyze(request, configurationId) |
POST /{entity}/score-analysis: scores a plan without creating a dataset |
list(filter) |
GET /{entity} with statuses, tags, page and size |
updateMetadata(id, name, tags) |
PATCH /{entity}/{id}/metadata |
terminate(id, force), purge(id), restore(id) |
DELETE /{entity}/{id}, DELETE /{entity}/{id}/purge, PUT /{entity}/{id} |
fromInput(id, select, config, opts), fromPatch(id, patch, select, config, opts) |
a child dataset from the parent's input or output, or from a patch |
awaitFinal(id, poll, max) |
polls metadata(id) until a final status |
events(id, after, statuses, followLineage), eventsPublisher(...) |
the server-sent event stream |
client.aboutMe() |
GET /api/aboutme: the key's tenant and role |
The status constants are on SolvingStatus (SolvingStatus.Completed is "SOLVING_COMPLETED"), the
operations on Operation, and the select values on Select. Every wire type is a case class in
satisfactory.protocol; Model API lists the fields.