Exchange JSON with a client#

Run the scheduling backend for a browser Gantt: take project data and change sets as JSON, schedule them, and send the results back as JSON.

Prerequisites#

  • The org.dhtmlx:plen dependency, as shown in Quick start. The JSON API ships in the same JAR as the typed API, in package org.dhtmlx.plen. It needs no JSON library.
  • JSON in the Planning Engine format. Task, link and config fields are snake_case (start_date, use_calendars). Call options and result fields are camelCase (includeUnchanged, baselineId). Dates go in as local strings ("2026-01-05", "2026-01-05 08:00", T separator and seconds allowed) and come back as "YYYY-MM-DD HH:mm".

Steps#

  1. Create one JsonProject per open document. The constructor takes the project options as JSON. Pass "{}" (or call new JsonProject()) for strict validation, or {"validation":"repair"} to fix messy data instead of rejecting it:

    var project = new JsonProject("{\"validation\":\"repair\"}");
    

    The constructor is the only member that throws on bad input. If the options don't decode, it throws JsonProjectException. getErrorJson() returns the error envelope, and getError() returns the InputException.

  2. When the client loads a document, pass the project data JSON to solve and send the returned string back unchanged. includeUnchanged makes the result list every task, not only the ones that moved:

    String envelope = project.solve(dataJson, "{\"includeUnchanged\":true}");
    
  3. While the user drags or edits, pass the change set to preview. It schedules the change without keeping it:

    String envelope = project.preview("{\"tasks\":{\"update\":[{\"id\":\"a\",\"duration\":3}]}}", "{}");
    
  4. When the user commits, pass the same change set to apply, then call solve with null data to reschedule the accepted state:

    String report = project.apply(changesJson);
    String schedule = project.solve(null, "{}");
    

    Only the data argument of solve and analyze can be null. Pass "{}" when you have no options: a null options string throws NullPointerException.

  5. Lock the project around every call. A JsonProject isn't synchronized, and an HTTP server with a thread pool can run two requests for the same document at once. See Share a project between threads.

  6. On the client, check ok in every response and read result or error, as described in Read the envelope.

Full example#

A minimal server on the JDK's built-in com.sun.net.httpserver with three endpoints: load, preview and commit. Each one reads the request body as JSON text and returns the envelope as application/json:

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import org.dhtmlx.plen.JsonProject;

public class GanttServer {
  // One project per open document.
  static final ConcurrentHashMap<String, JsonProject> projects = new ConcurrentHashMap<>();

  public static void main(String[] args) throws Exception {
    var server = HttpServer.create(new InetSocketAddress(8080), 0);
    server.createContext("/projects/", GanttServer::handle);
    server.setExecutor(Executors.newFixedThreadPool(8));
    server.start();
  }

  // POST /projects/{id}/load | preview | apply
  static void handle(HttpExchange exchange) throws IOException {
    String[] parts = exchange.getRequestURI().getPath().split("/");
    if (!"POST".equals(exchange.getRequestMethod()) || parts.length != 4) {
      send(exchange, 404, "");
      return;
    }
    String id = parts[2];
    String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);

    if (parts[3].equals("load")) {
      // Load: schedule the full data and return every task's dates.
      var project =
          projects.computeIfAbsent(id, key -> new JsonProject("{\"validation\":\"repair\"}"));
      synchronized (project) {
        send(exchange, 200, project.solve(body, "{\"includeUnchanged\":true}"));
      }
      return;
    }

    var project = projects.get(id);
    if (project == null) {
      send(exchange, 404, "");
      return;
    }
    switch (parts[3]) {
      // Preview: schedule a change set without keeping it.
      case "preview" -> {
        synchronized (project) {
          send(exchange, 200, project.preview(body, "{\"reasons\":true}"));
        }
      }
      // Commit: apply a change set, reschedule, return both envelopes.
      case "apply" -> {
        String response;
        synchronized (project) {
          String report = project.apply(body);
          String schedule = project.solve(null, "{}");
          response = "{\"apply\":" + report + ",\"schedule\":" + schedule + "}";
        }
        send(exchange, 200, response);
      }
      default -> send(exchange, 404, "");
    }
  }

  static void send(HttpExchange exchange, int status, String json) throws IOException {
    byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().set("Content-Type", "application/json");
    exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length);
    try (var out = exchange.getResponseBody()) {
      out.write(bytes);
    }
  }
}

The same calls fit a Spring or Jakarta EE controller: take the request body as a String, call the JsonProject method inside the lock, and return the envelope string with the application/json content type.

Every call is synchronous and CPU-bound. For large projects, or for level, evaluate and suggestAssignments, keep the work off threads that must stay responsive. See Call from async code.

Read the envelope#

Every JsonProject method returns one of two shapes. Keys are sorted at every depth, and whole numbers are written as integers.

Success:

{"ok":true,"result":{"baselineId":"1-b0f04113e2b02bf4","conflicts":[],"converged":true,"cycles":[],"notes":[],"updates":[...]}}

Failure:

{"error":{"code":null,"kind":"validation","message":"task needs two of start_date / end_date / duration","path":"tasks[0].start_date"},"ok":false}

Engine errors never escape as exceptions. Read error.kind to decide what to do:

kind Cause path code
"parse" JSON that doesn't decode: bad syntax, duplicate keys, a wrong type, an unknown option or change-set key null null
"validation" Data the engine rejects, such as a task with fewer than two of start, end and duration The field, e.g. "tasks[0].start_date". null when the decoder rejects a value null
"project" A call the project's state doesn't allow null "no-baseline", "stale-baseline" or "needs-resync"

Handle the project codes on the client:

  • no-baseline: the project hasn't been solved yet. Send the full data to the load endpoint first.
  • stale-baseline: the baselineId the client sent in the options is out of date. Reload the document.
  • needs-resync: an apply rejected part of a change set. The apply envelope has "needsResync":true and lists the rejections in rejected. Until you send the full data to solve again, solve(null, …), apply and level fail with this code. See Recover from rejected changes.

Each error kind maps to an exception in the typed API: "parse" and "validation" to InputException, "project" to ProjectException. See Error handling.

Unknown fields in task, link, resource and assignment records are ignored, so you can send records with their extra UI properties (text, progress, custom fields). Call options and the change set's own keys are strict: an unknown key there fails with "parse".

The JsonProject methods#

Each method takes JSON text and returns the envelope string:

Method Input Result
solve(data, options) Project data, or null to re-solve the accepted data Schedule result
preview(changes, options) Change set Schedule result, not accepted
analyze(data, options) Project data, or null Schedule result with analytics, not accepted
apply(changes) Change set Apply report
level(options) Leveling options Leveling result, accepted
loadProfile(options) Load-profile query Load profile
evaluate(candidates, options) Candidate list Evaluate result
slots(pool, requirements) Worker pool and requirement list Slot list
suggestAssignments(slots, options) Slot list Suggest result

copy() returns an independent JsonProject. Use it to preview a leveling run or a multi-step edit before you accept it. The fields of each input and result are the wire names of the typed records. See Reading results and the API reference.

Schedule once, without a project#

For a stateless endpoint, call Wire.schedule. It decodes the data and options, schedules them in a fresh project and returns the envelope:

import org.dhtmlx.plen.Wire;

String envelope = Wire.schedule(dataJson, "{}");

Wire.schedule always uses strict validation.

Decode JSON into typed records#

To keep working with the typed Project, decode the JSON with Decode and wrap each call with Wire.envelopeText:

import java.util.Map;
import org.dhtmlx.plen.*;

ProjectData data = Decode.fromJson(dataJson, ProjectData.class);
SolveOptions options = Decode.fromJson("{\"includeUnchanged\":true}", SolveOptions.class);

var project = new Project();
String envelope = Wire.envelopeText(() -> project.solve(data, options));

// A change set that is already parsed, e.g. part of a larger request body.
var body = (Map<?, ?>) Json.parse(requestJson);
SchedulingChanges changes = Decode.changes(body.get("changes"));
String report = Wire.envelopeText(() -> project.apply(changes));
  • Decode.fromJson(text, type) decodes ProjectData, SchedulingChanges, every options type, and the records nested in them. Decode.list(text, type) decodes a JSON array of records, such as candidates, slots or requirements.
  • Decode.project, Decode.changes, Decode.solveOptions and the other named decoders take a tree you've already parsed with Json.parse. Decode.fromValue(value, type) does the same for any record type.
  • A decode failure throws InputException with kind PARSE (or VALIDATION for a value the decoder rejects) and a null path.
  • Wire.envelopeText(run) runs the call and returns {"ok":true,"result":…}, or the error envelope if run throws a PlenException. Any other exception propagates. Decode inside run to get decode errors as envelopes too.
  • Wire.errorText(exception) builds the error envelope for an exception you caught yourself, and Wire.resultText(value) builds the success envelope for a typed result.
  • Encode.text(result) returns the bare result JSON, without the envelope, for logging or for a response format of your own.

To mix typed and JSON calls on one project, create it with Wire.newProject(optionsJson) and pass it to Wire.solve(project, …), Wire.apply(project, …) and the other static Wire methods. They return the same envelopes as the JsonProject methods.

Wire.envelope and Wire.error return the envelopes as a parsed tree: Map<String, Object>, List<Object>, String, Boolean, null, and numbers as Long or Double. Read numbers as Number. To send an envelope, send the envelopeText / errorText string.

Result#

The load endpoint answers with every task's dates:

{"ok":true,"result":{"baselineId":"1-b0f04113e2b02bf4","conflicts":[],"converged":true,"cycles":[],"notes":[],"updates":[{"duration":2,"end_date":"2026-01-07 08:00","id":"a","kind":"task","start_date":"2026-01-05 08:00"},{"duration":1,"end_date":"2026-01-08 08:00","id":"b","kind":"task","start_date":"2026-01-07 08:00"}]}}

That's the response for two tasks a (2 days) and b (1 day) linked finish-to-start, both starting on 2026-01-05 08:00. Each updates entry uses the task-data field names, so the client can write it straight back to its gantt task. The baselineId differs on every run.