EazyDraw

← Automation API

EazyDraw Automation API — Current Surface

Status: initial implementation, direct-distribution macOS only. For review of structure and naming before extending.

Machine-readable contract: spec/openapi.yaml (OpenAPI 3.1) is generated from this document and the handler code. This file (API.md) remains the source of truth; the OpenAPI spec is the derived contract for client/tool generation. JSON keys in the spec are the actual on-the-wire names (e.g. lowercase uuid, DocumentFileName).

Server

  • Embedded GCDWebServer in DKDAppDelegate, owned process-lifetime via the _httpServer ivar.
  • Started/stopped from applyHTTPServerSettings (called from applicationDidFinishLaunching and from loadAPISettings:).
  • Bind: BindToLocalhost = YES. No Bonjour. Port from [apiSettings apiPort].
  • Failure to start populates a full NSError (own domain EazyDrawErrorDomainStrG when GCDWebServer returns none) and is shown via [NSApp presentError:]. Never crashes.

Versioning

All resource paths under /v1. The /status endpoint is intentionally unversioned (it describes the running app, not a versioned resource).

Authentication

All endpoints require a bearer token. Clients supply it in an HTTP header on every request:

Authorization: Bearer 7b3f2a9e4c1d8b6a5f0e3c2d1b8a6f4e0123456789abcdef0123456789abcdef
  • Format. 32 random bytes (SecRandomCopyBytes), lower-case hex encoded — 64 characters. Generated client-side never; only EazyDraw produces tokens.
  • Storage. Keychain item, kSecClassGenericPassword, service com.eazydraw.api, account bearer-token. Accessibility kSecAttrAccessibleWhenUnlocked. The token is not stored in the app's plist or any preferences file.
  • Comparison. Constant-time byte compare (DKDAPIKeychain constantTimeEqualsTokenA:tokenB:) on the server. The token never appears in URL query strings — only in the Authorization header — so it stays out of access logs and shell history.
  • No token → server refuses to start. If no token exists in Keychain, applyHTTPServerSettings will not bring the server up and surfaces an NSError directing the user to API Settings → Generate Token. Authenticating an absent endpoint is impossible by construction.
  • Missing or wrong header → 401. Response body is GCDWebServer's default error HTML with message Missing or invalid bearer token. The response includes WWW-Authenticate: Bearer realm="EazyDraw" so HTTP clients know what scheme to use.
  • Single token. One configured token at a time, no scopes, no expiry. Regenerating in API Settings invalidates the old token immediately — in-flight clients begin receiving 401 until they update.

Python example

import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {token}"   # token from API Settings → Copy
r = session.get("http://localhost:52737/v1/drawings")
r.raise_for_status()
print(r.json())

Threat model

The token protects against:

  • Other local processes or other user accounts on the same machine driving EazyDraw via the API.
  • Browser-side requests from a page tricked into hitting http://localhost:<port>. CORS doesn't cover this fully (extensions, misconfigurations); the bearer header a malicious page can't supply without already having stolen the token.
  • An off-host exposure later if BindToLocalhost is ever turned off (e.g. for testing) — auth is the same regardless of bind address.

The token does not protect against:

  • Code running as your user that can read your Keychain — that code can read the token directly. Localhost APIs cannot escape this in general.
  • Token leakage via shell history, log files, or accidental commits — keep tokens out of URLs and out of repo-tracked configuration files.
  • MITM on the wire when going off-host — out of scope while we are localhost-only. TLS becomes the answer if/when that changes.

Endpoints

MethodPathBody shape (200)
GET/status{ status, version, build }
GET/v1/drawings{ drawings: [ <drawing> ] }
POST/v1/drawings<drawing> — opens the file at the supplied path
DELETE/v1/drawings/{uuid}<drawing> — closes the drawing (200 OK)
GET/v1/libraries{ libraries: [ <library> ] }
POST/v1/libraries<library> — opens the library file at the supplied path
DELETE/v1/libraries/{uuid}<library> — closes the open palette window for the library (200 OK)
GET/v1/libraries/{uuid}<library>
GET/v1/libraries/{uuid}/elements[ <element> ]
GET/v1/libraries/{uuid}/stateDKDLib.propertyListRepresentation (forced flat) — full library state
GET/v1/libraries/{uuid}/elements/{uuid}/stateDKDLibElement.propertyListRepresentation — full element state
GET/v1/drawings/{uuid}<drawing>
PATCH/v1/drawings/{uuid}<drawing> — set layerSelect and/or apply a named layerConfiguration
GET/v1/drawings/{uuid}/layer-configurations[ <layer-configuration> ] — the drawing's saved layer arrangements
GET/v1/drawings/{uuid}/pages/setupDKDPagesSpec.propertyListRepresentation
GET/v1/drawings/{uuid}/pages/layoutDKDPrintInfo.propertyListRepresentation
GET/v1/drawings/{uuid}/gridsDKDGridPair.propertyListRepresentation
GET/v1/drawings/{uuid}/propertiesDKDProperties.propertyListRepresentation
PATCH/v1/drawings/{uuid}/propertiesDKDProperties.propertyListRepresentation — set metadata (merge)
GET/v1/drawings/{uuid}/scale[[doc activeLayer] dkdScale].propertyListRepresentation
GET/v1/drawings/{uuid}/window[doc docWindowConfiguratonDictionary]
GET/v1/drawings/{uuid}/layers[ <layer> ]
POST/v1/drawings/{uuid}/layers<layer> — create a layer at the front, active by default (201)
GET/v1/drawings/{uuid}/layers/{uuid}<layer>
PATCH/v1/drawings/{uuid}/layers/{uuid}<layer> — set layer state (on / off / active)
POST/v1/drawings/{uuid}/layers/{uuid}/order<layer> — restack the layer: front / back / forward / backward
GET/v1/drawings/{uuid}/layers/{uuid}/scale[layer dkdScale].propertyListRepresentation
GET/v1/drawings/{uuid}/layers/{uuid}/color-modification[layer layerColorMod].propertyListRepresentation
GET/v1/drawings/{uuid}/layers/{uuid}/state[layer propertyListRepresentation] — full layer state (flat)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics[ <graphic> ]
POST/v1/drawings/{uuid}/layers/{uuid}/graphics<graphic> — places a library element on the layer (201)
POST/v1/drawings/{uuid}/layers/{uuid}/imagesinsert content at front of layer — raster <graphic>+image, or PDF {pageCount, graphics[]} (201)
PUT/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/image (or nested)<graphic> + image meta — fill a named placeholder slot with an image
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/flip (or nested)<graphic> — flip (mirror) horizontal / vertical / mirror
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/order (or nested)<graphic> — z-order: front / back / forward / backward
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/move-to-layer (or nested)<graphic> — move the graphic to another layer
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/attributes<graphic> — apply a library attribute-action transfer (200)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — set absolute bounds (position + size) and/or rotation
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — single graphic dict
DELETE/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — pre-delete dict; removes the graphic
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/graphics[ <graphic> ] — children of the named DKDGroup
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/graphics/{uuid}/graphics (and deeper)[ <graphic> ] — children of the deepest named group
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<text> — DKDTextArea content + layout metrics
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<text> — set text + uniform style overrides
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/annotation (or nested)<annotation> — host graphic's annotation state
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/annotation/text (or nested)<annotation> — set annotation plain text
GET/v1/drawings/{uuid}/export/{format}bytes — full drawing rendered
GET/v1/drawings/{uuid}/layers/{uuid}/export/{format}bytes — same render as drawing in v1 (see notes)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/export/{format} (any nesting depth)bytes — single graphic at its bounds
GET/v1/libraries/{uuid}/export/{format} / /v1/libraries/{uuid}/elements/{uuid}/export/{format}501 Not Implemented (deferred)

UUID matching is case-insensitive against [doc documentUUID], [layer layerUUID], and [graphic graphicUUID].

Recursive group traversal

The path grammar for descending into nested groups is:

/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics(/{Gn}/graphics)*

Each {Gn} after the first must be a child of the preceding group's groupArray. Every {Gn} along the chain — including the last one whose children are returned — must resolve to a DKDGroup (or subclass). Nesting is unlimited; the same shape applies at every depth.

The terminal segment is always /graphics, which returns the array of immediate children. To check whether a graphic has children before recursing, look at graphicsCount in the parent's listing: present (any value, including 0) ⇒ it is a group and the recursive endpoint will resolve; absent ⇒ it is a leaf graphic and the recursive endpoint will 404. An empty group correctly returns [], distinct from a 404 for "not a group".

<drawing> shape

{
  "uuid":             "<DKDDocument documentUUID>",
  "DisplayName":      "<NSDocument displayName>",
  "DocumentFileName": "<doc.fileURL.path or empty string>",
  "layerSelect":      "active-only | show-others | select-others | show-all | select-all"
}

layerSelect is the document's layer‑visibility mode (DKDDocument.layerSelect, reported as a kebab name; advanced directional modes report other). See Layer visibility below.

Keys: UUID_Key ("uuid", lowercase), Key_HTTP_Server_displayName ("DisplayName"), DocFileNameKey ("DocumentFileName"). The first and third reuse persistence keys already in the codebase; the middle one is HTTP-specific.

If a document has no UUID at the time it is requested via /v1/drawings or /v1/drawings/{uuid}, one is assigned via [docProperties setAPI_UUID:YES] so subsequent calls are stable.

POST /v1/drawings — open a file

Opens any file type EazyDraw can read (drawings in .ezdraw, .ezddata, .ezdjson; importable .svg, .pdf, .dxf, image formats, etc. — anything the app's CFBundleDocumentTypes declares). The result is a drawing window on screen, the same as a File → Open from the menu.

Import sizes the page to the content. When the file is an importable (non-EazyDraw) type, opening creates a new drawing whose page geometry matches the content: a PNG/JPEG/TIFF becomes a one-page drawing the size of the image; an SVG a page the size of the artwork; a multi-page PDF a drawing with one page per PDF page, at the PDF's page size (DKDDocBitmapImageOperation / DKDDocPDFOperation / DKDDocSVGOperation). This is the *open as a new drawing* path; the complement — dropping content into an existing drawing without changing its page geometry — is POST /v1/drawings/{D}/layers/{L}/images.

Request body (Content-Type: application/json recommended; the handler reads the body bytes directly so the header is not strictly required):

{
  "path": "/absolute/path/to/file.ezdraw"
}

path is a required string. Tilde expansion is performed (~/Documents/... is fine). Relative paths are rejected — there's no well-defined "current directory" for the API.

Success responses carry the same <drawing> dict shape /v1/drawings/{uuid} returns, so the response is immediately usable in subsequent GETs:

  • 201 Created — file was not previously open; a new drawing window is now on screen.
  • 200 OK — file was already open in EazyDraw; the existing window is returned (no new window is created, no in-flight changes are disturbed). Matches NSDocumentController openDocumentWithContentsOfURL:display:completionHandler: semantics: documentWasAlreadyOpen == YES.

Error responses are JSON { "error": "...", "path": "..." } (path included when relevant):

  • 400 Bad Request — empty body, body is not JSON, body is not a JSON object, missing/empty path field, or path is not absolute / tilde-prefixed.
  • 404 Not Foundpath resolves but no file exists there.
  • 422 Unprocessable Entity — file exists but NSDocumentController could not open it (unknown type, corrupt, unsupported version). The NSError's localizedDescription is returned in error.
  • 500 Internal Server Error — open completion did not fire within 30 seconds, or returned a document that was not a DKDDocument, or response build failed. The 30-second timeout is generous for typical drawings; extremely large drawings can theoretically exceed it.

Threading note. The handler dispatches openDocumentWithContentsOfURL:display:completionHandler: onto the main thread and waits on a dispatch_semaphore for the async completion. The request thread blocks until the window is up. Typical opens complete in well under a second; the 30-second timeout exists only as a safety net.

Python example:

import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {token}"

r = session.post("http://localhost:52737/v1/drawings",
                 json={"path": "~/Documents/sketch.ezdraw"})
r.raise_for_status()
drawing = r.json()
print(drawing["uuid"], "was already open" if r.status_code == 200 else "newly opened")

DELETE /v1/drawings/{uuid} — close a drawing

Closes the drawing identified by {uuid} and removes its window from the screen. No request body, no query parameters.

Success — 200 OK. Body is the <drawing> dict for the drawing that was just closed. The dict is built before [doc close] is called so the response is complete. The UUID will no longer resolve in subsequent GETs.

Errors:

  • 400 Bad Request — missing UUID in path (should not happen given the regex, but handled defensively).
  • 401 Unauthorized — missing/bad bearer token.
  • 404 Not Found — no open drawing matches the supplied UUID. Body: { "error": "Drawing not found" }.
  • 500 Internal Server Error — response build failed.

macOS autosave note. DKDDocument autosaves changes in place (the standard modern NSDocument behavior). Edits made between open and close are typically already on disk by the time the close request runs. The API therefore does not offer a "close without saving" mode — there is nothing reliable to discard. To roll a drawing back to a prior state, use EazyDraw's File → Revert To before closing. An earlier revision of this endpoint accepted ?force=true to gate dirty-document behavior; testing showed the dirty check is essentially never true under autosave-in-place, so the flag has been removed.

Threading. The walk-and-close runs entirely inside one dispatch_sync(dispatch_get_main_queue(), ...) block: find the window, build the dict, and call [doc close] atomically with respect to other API requests. [doc close] is synchronous — when it returns, the window controllers are gone and the document is removed from NSDocumentController.

curl:

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:52737/v1/drawings/$UUID

Python:

r = session.delete(f"http://localhost:52737/v1/drawings/{uuid}")
r.raise_for_status()

<library> shape

{
  "uuid":             "<DKDLib.libraryUUID>",
  "DisplayName":      "<DKDLib.titleLib>",
  "DocumentFileName": "<DKDLib.fullFilePathLib>",
  "inMenu":           <Bool>,
  "paletteOpen":      <Bool>,
  "elementsCount":    <NSUInteger>
}

elementsCount is [[lib libCollectionElement] count] — the number of DKDLibElement items in the library's root collection. Mirrors graphicsCount on <layer> (libraries have no layer level, so this sits directly on the library).

The <library> shape mirrors the <drawing> shape's first three keys (same key names — uuid, DisplayName, DocumentFileName — for consistency across resource types) and adds two state booleans:

  • inMenutrue when the library is in _libMenus, the AppDelegate's array of active menu-system libraries (built-ins the user hasn't deactivated, plus user-added libraries currently on the Library menu).
  • paletteOpentrue when at least one window's controller is a DKDLibPalette whose dkdLib matches this library. Library palette windows are NSPanels and may be hidden when EazyDraw is not the active app; this flag reflects controller presence and is not filtered by [NSWindow isVisible], so the result is stable regardless of EazyDraw's foreground state.

A library can have any combination of the two flags set: in-menu only, palette-open only, or both. The collection is deduplicated so each library appears at most once. Dedup keys, in order of preference: pointer identity of the DKDLib instance, then fullFilePathLib string equality.

The 6 built-in libraries (Math, Charting, Stellate, Tools, Technical, CharacterBuilderLibrary) are returned only when active in the menu system; deactivated built-ins do not appear unless their palette window is open.

If a library has no UUID at the time it is requested, one is assigned via [lib setLibraryUUID:] so subsequent calls are stable.

POST /v1/libraries — open a library file

Opens an EazyDraw library file (.ezdrawlib, .ezddatalib, .ezdrawjsonlib) and brings up its palette window. The handler is structurally identical to POST /v1/drawingsNSDocumentController openDocumentWithContentsOfURL:display:completionHandler: routes by UTI to DKDLibDocument, whose -makeWindowControllers instantiates the DKDLibPalette and shows it.

Request body: same shape as POST /v1/drawings.

{
  "path": "/absolute/path/to/my.ezdrawlib"
}

path is required; tilde-expanded; absolute (or tilde-prefixed). Relative paths are rejected.

Success responses return the same <library> dict shape as /v1/libraries entries:

  • 201 Created — file was not previously open; a new palette window is now on screen.
  • 200 OK — file was already open in EazyDraw (matched by URL); the existing palette is returned. Same documentWasAlreadyOpen semantics as POST /v1/drawings.

The returned <library> dict reflects current state:

  • inMenu: true only if the library happens to already be in _libMenus (the active menu-system list). Opening via POST /v1/libraries does not add the library to the menu — the API only opens the palette. Use the existing Library menu UI ("Add to Menu") if menu membership is desired.
  • paletteOpen: true (the palette is what we just opened or refocused).

Errors: same matrix as POST /v1/drawings:

  • 400 — missing/malformed body, missing/empty path, relative path.
  • 404 — file does not exist on disk at the supplied path.
  • 422 — file exists but NSDocumentController couldn't open it (wrong UTI, corrupt library, or the opened object wasn't a DKDLibDocument). JSON body carries the NSError's localizedDescription.
  • 500 — open completion timed out (30s safety net) or response build failed.

curl:

curl -i -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path":"~/Library/Application Support/EazyDraw/MyMenuLibraries/Math.ezdrawlib"}' \
  http://localhost:52737/v1/libraries

Python:

r = session.post("http://localhost:52737/v1/libraries",
                 json={"path": "~/Documents/widgets.ezdrawlib"})
r.raise_for_status()
lib = r.json()
print(lib["uuid"], "newly opened" if r.status_code == 201 else "already open")

DELETE /v1/libraries/{uuid} — close a library palette

Closes the open palette window for the library identified by {uuid} and removes it from the screen. No request body, no query parameters.

Scope. This endpoint operates only on the palette window. It does not mutate _libMenus (the active menu-system list). Menu membership is user-managed state — added or removed via the EazyDraw Library menu UI — and the API does not touch it. If you DELETE a library that has both a menu entry and an open palette, only the palette is closed; the library remains accessible via the menu and continues to appear in GET /v1/libraries with inMenu: true, paletteOpen: false.

Lookup. The handler resolves {uuid} against both sources in order: _libMenus first, then open palette windows. Once the DKDLib is found, the matching palette is located by pointer equality first, then by fullFilePathLib equality — the path fallback handles the case where a menu entry and a palette-only entry refer to the same file as two different DKDLib instances (the same dedup logic GET /v1/libraries uses to coalesce them into one response entry).

Idempotent. Calling DELETE on a library that has no palette open (menu-only) is a no-op and returns 200 OK with the dict. Safe to retry.

Success — 200 OK. Body is the <library> dict for the library, built before [libDoc close] is called, so the paletteOpen field reflects pre-close state. The UUID continues to resolve via subsequent GETs as long as the library remains in _libMenus.

Errors:

  • 400 — missing UUID in path (defensive).
  • 401 — auth.
  • 404{uuid} did not match any library in either _libMenus or any open palette. Body: { "error": "Library not found" }.
  • 500 — response build failure.

macOS autosave note. Same as for DELETE /v1/drawings/{uuid}: DKDLibDocument autosaves in place, so any edits made via the palette flow to disk before close. No force/dirty machinery. To roll a library back, use Revert To.

Threading. Lookup, dict build, and [libDoc close] all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block — atomic against other API requests. libDoc is obtained via [palette document] (the standard NSWindowController accessor).

curl:

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:52737/v1/libraries/$UUID

Python:

r = session.delete(f"http://localhost:52737/v1/libraries/{uuid}")
r.raise_for_status()

<element> shape (entry in /v1/libraries/{uuid}/elements)

Every element entry carries these common fields:

{
  "index":       <NSUInteger>,
  "uuid":        "<DKDLibElement.libraryUUID>",
  "elementType": "graphic" | "create-tool" | "arrange-tool" | "attribute-action",
  "nameElement": "<DKDLibElement.nameElement>"   // omitted if nil
}

elementType is the API-facing kebab-case kind, not the internal DKDLib*Element class name. The four values map to the underlying DKDLibElement subclasses (and the attribute-action exception) as follows:

elementType: "graphic"

The element is a DKDLibGraphicElement whose embedded graphic is not an attribute-transfer template. Adds the same per-graphic fields used in /v1/drawings/{D}/layers/{L}/graphics, with two omissions specific to library graphics:

{
  ...common fields...,
  "elementType":   "graphic",
  "graphicUUID":   "<inner DKDGraphic.graphicUUID>",
  "class":         "<NSStringFromClass(inner graphic)>",
  "nameGraphic":   "<...>",        // omitted if nil
  "graphicsCount": <NSUInteger>    // present iff inner graphic is DKDGroup
}

Omitted compared to the layer-level graphic shape: graphicTagString and hiddenBounds — neither is meaningful in a library context.

The element-level index here is the position in the library's elements array. The graphic itself does not get a separate index field; it has only one slot inside the element.

elementType: "attribute-action"

The element is a DKDLibGraphicElement whose embedded graphic represents an attribute-transfer template rather than a drawable shape. Detected as: graphic is not a DKDGroup, has a non-nil dkdTransfer, and that transfer has at least one of these scope flags set: brushScopeTransfer, shadowScopeTransfer, gradientScopeTransfer, hatchScopeTransfer, dashesScopeTransfer, patternScopeTransfer, arrowsScopeTransfer, colorAndStyleScopeTransfer, dimensionScopeTransfer.

{
  ...common fields...,
  "elementType": "attribute-action"
}

Currently only the kind label is exposed. A future endpoint will report which transfer scopes are active for a given attribute-action element.

elementType: "create-tool"

The element is a DKDLibCreateToolElement — a graphic-creation tool. Adds the underlying graphic class string the tool produces:

{
  ...common fields...,
  "elementType": "create-tool",
  "toolClass":   "<DKDLibCreateToolElement.classStringLibCreateToolElement>"
}

elementType: "arrange-tool"

The element is a DKDLibArrangeToolElement — an arrange/align/distribute action tool. Adds the localized tool name resolved via [DKDArrangePalette arrageToolNameWithTag:] from the element's arrangeButtonTag:

{
  ...common fields...,
  "elementType": "arrange-tool",
  "toolName":    "<localized arrange-tool title, e.g. \"Align Left Edges\">"
}

If a DKDLibElement is encountered that is none of the three known subclasses, elementType falls back to NSStringFromClass([el class]) so the response remains well-formed.

If an element has no UUID at the time it is requested, one is assigned via [el setLibraryUUID:] so subsequent calls are stable.

POST /v1/drawings/{D}/layers/{L}/graphics — place a library element

Adds a new graphic to the layer by invoking the library element's *use* action — the same operation the Use button in a DKDLibPalette performs. The source is identified by libraryUUID + elementUUID in the body.

Request body:

{
  "libraryUUID": "<DKDLib.libraryUUID>",
  "elementUUID": "<DKDLibElement.libraryUUID>"
}

Both fields are required, non-empty strings. The library is located by walking _libMenus first, then open palette windows (same dual-source lookup as GET /v1/libraries).

Allowed element types:

elementType (from GET /v1/libraries/{L}/elements)Behavior
graphicA copy of the library's embedded graphic is placed on the target layer.
create-toolA fresh default graphic of the tool's class is instantiated and placed. Initial size depends on the drawing's current zoom (the existing DKDLibPalette use-action does this).
arrange-toolRejected with 422. Arrange tools don't place graphics.
attribute-actionRejected with 422. Attribute-action elements modify a graphic's attributes; they don't add graphics. Use POST .../graphics/{uuid}/attributes (below) to apply the transfer to a target graphic.

Success — 201 Created. Response body is the standard <graphic> dict shape — index, graphicUUID (freshly generated, never matches the library's source UUID), class, graphicTagString, nameGraphic (set to the library element's nameElement), hiddenBounds, and graphicsCount if the placed graphic is a DKDGroup. The returned graphicUUID is immediately usable for follow-up GET / DELETE / future morph calls.

Position and size. Initial position and size come from the existing DKDLibPalette use-action machinery. For graphic elements, they reflect the library author's design (the graphic's saved bounds). For create-tool elements, they reflect the drawing's zoom state at the time of placement. Neither is predictable in detail — a follow-up position / size / rotation endpoint (planned, modeled on EazyDraw's Morph palette) will let clients place a graphic and then move it precisely.

Errors:

  • 400 Bad Request — missing UUIDs in path, empty body, body is not a JSON object, or missing/empty libraryUUID / elementUUID.
  • 401 Unauthorized — bearer token missing or wrong.
  • 404 Not Found — drawing, layer, library, or element UUID does not resolve. Body: { "error": "Drawing not found" | "Layer not found" | "Library not found" | "Library element not found" }.
  • 422 Unprocessable Entity — element is arrange-tool / attribute-action, or use action produced no graphic for unexpected reasons.
  • 500 Internal Server Error — response build failure.

Fresh UUIDs. After the graphic is added to the target layer, [newGraphic recurseNewUUIDs] runs to assign fresh graphicUUID values for the new graphic and every descendant (groups, sub-graphics). The library's source UUIDs are never reused — a library can be placed any number of times into one or more drawings and each placement has a distinct identity. (The companion recurseUUID: toggle method, used by the Library and Properties panels, retains its original "assign-if-missing / clear" semantics; recurseNewUUIDs is a new method specifically for force-regeneration after placement.)

Threading. Lookup of doc/layer/lib/element, classification, property-list build, setActiveLayer: save-and-restore, the use-action call (-[DKDDocumentView addGraphicsWithArrayOfPropertyLists:]), and recurseNewUUIDs all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block. The target document's activeLayer is briefly set to the API-supplied target layer for the duration of the add, then restored — this routes the new graphic to the right layer using the existing use-action infrastructure without permanently disturbing UI state.

curl:

curl -i -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"libraryUUID":"'"$LIB_UUID"'","elementUUID":"'"$EL_UUID"'"}' \
  http://localhost:52737/v1/drawings/$DRAWING_UUID/layers/$LAYER_UUID/graphics

Python:

r = session.post(
    f"http://localhost:52737/v1/drawings/{drawing_uuid}/layers/{layer_uuid}/graphics",
    json={"libraryUUID": lib_uuid, "elementUUID": element_uuid},
)
r.raise_for_status()
new_g = r.json()
print("Placed:", new_g["class"], "uuid:", new_g["graphicUUID"], "bounds:", new_g["hiddenBounds"])

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — fetch a single graphic

Returns the standard <graphic> dict for a single graphic identified by UUID chain. Same chain grammar as the morph, export, and DELETE endpoints — one UUID for a top-level layer graphic, additional UUIDs to descend into groups.

Success — 200 OK. Body is the standard <graphic> dict (same shape as one entry in GET /v1/drawings/{D}/layers/{L}/graphics). The index field reflects the graphic's position in its immediate container (layer or parent group), not a global drawing-wide index.

Errors: 400 (missing UUIDs), 401 (auth), 404 (any UUID along the chain does not resolve, or an intermediate non-group blocks descent), 500 (response build failure).

Use case: when you have a UUID from a prior call (POST use, PATCH morph, or a listing) and you want to refetch the current state without re-listing the layer. Cheaper than layer_graphics() for spot-checks; especially useful after operations whose response represents pre-state (DELETE) or in-flight state.

g = ed.graphic(d_uuid, l_uuid, g_uuid)
print(g["class"], g["hiddenBounds"])

DELETE /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — remove a graphic

Removes the graphic identified by UUID chain. The remove is registered with the document's undo manager, so Edit → Undo in the EazyDraw UI restores the graphic at the same z-order index in its original container.

Success — 200 OK. Body is the standard <graphic> dict for the pre-delete state. The graphicUUID will no longer resolve in subsequent calls (until an undo restores it).

Container handling:

  • Top-level layer graphic (chain length == 1): removed via [doc removeGraphic:atIndex:] which handles connection cleanup, contained text pairs, layer panel sync, and undo registration.
  • Nested in a group (chain length > 1): removed from its immediate parent group's groupArray via [parentGroup groupRemoveGraphicAtIndex:]. Same undo registration pattern.

In both cases the index field in the response dict reflects the position in the immediate container at the time of delete.

Errors: 400 (missing UUIDs), 401 (auth), 404 (any UUID does not resolve, or intermediate non-group blocks descent), 500 (response build failure).

Threading. Resolution, dict build, and removal all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block — atomic against other API requests.

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G
deleted = ed.delete_graphic(d_uuid, l_uuid, g_uuid)
print("Removed", deleted["class"], "at z-order", deleted["index"])

POST /v1/drawings/{D}/layers/{L}/graphics/{G}/attributes — apply a transfer

Applies a library attribute-action element's DKDTransfer (fill color, line color/style, gradient, hatch, dashes, arrows, brush, shadow, dimension scopes) to a target graphic — the programmatic equivalent of dragging that library swatch onto the graphic, or selecting the graphic and clicking the element's Use button.

Request body:

{
  "libraryUUID": "<DKDLib.libraryUUID>",
  "elementUUID": "<DKDLibElement.libraryUUID>"
}

Both required, non-empty strings. The element must classify as attribute-action — a DKDLibGraphicElement whose embedded graphic is not a DKDGroup, carries a non-nil dkdTransfer, and has at least one active scope flag (brushScopeTransfer, shadowScopeTransfer, gradientScopeTransfer, hatchScopeTransfer, dashesScopeTransfer, patternScopeTransfer, arrowsScopeTransfer, colorAndStyleScopeTransfer, dimensionScopeTransfer). A graphic / create-tool / arrange-tool element returns 422 (use POST .../graphics to place those).

v1 — top-level graphics only. The target is a single top-level layer graphic; a nested (grouped) UUID chain returns 422. The transfer is applied by the same mechanism as a drag-drop (DKDTransferApply dropTransfer): save the current selection, select the target graphic, run the per-scope inspector-panel applies against it, then restore the prior selection. On-canvas selection must be able to address the target, which today means a top-level graphic. (A brief selection change may be visible to a user watching the document.)

Success — 200 OK. Body is the standard <graphic> dict; the graphicUUID is preserved (the graphic is restyled in place, not replaced). The summary dict does not include attribute values — to see the resulting fill/line/etc. inline, GET .../graphics/{G}/export/native or the layer /state.

Undo. Registered as a single "Transfer" step by applyTransferFromGraphic: (the action name reflects the scopes applied), so Edit → Undo reverts the change.

Locks. Only the layer's layerLock blocks the apply — 423 Locked, with ?force=true to override. The graphic's moveLock / sizeLock / deleteLock do not apply: an attribute change is neither a move, a resize, nor a delete.

Errors:

  • 400 Bad Request — missing path UUIDs; empty/malformed body; missing/empty libraryUUID / elementUUID.
  • 401 Unauthorized — bearer token missing or wrong.
  • 404 Not Found — drawing, layer, top-level graphic, library, or element UUID does not resolve.
  • 422 Unprocessable Entity — element is not an attribute-action (it's a graphic / create-tool / arrange-tool); or the target is a nested graphic (v1 limitation).
  • 423 Locked — the target layer is locked; retry with ?force=true.
  • 500 Internal Server Error — response build failed.

Threading. Resolution, the select → applyTransferFromGraphic: → restore-selection sequence, and the dict build all run inside one dispatch_sync(dispatch_get_main_queue(), …) block — atomic against other API requests.

Python:

g = ed.apply_attributes(d_uuid, l_uuid, g_uuid, lib_uuid, el_uuid)
print(g["class"], g["graphicUUID"])
# to confirm the applied attributes, read the flat state:
native = ed.export_graphic(d_uuid, l_uuid, g_uuid, fmt="native")

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/order — z-order

Changes a graphic's front‑to‑back position within its layer (the layer's graphics array order; 0 = bottom). Reuses -[DKDDocument sendGraphic:toIndex:].

Body: { "to": "front" | "back" | "forward" | "backward" }front/back to the top/bottom of the layer, forward/backward one step. 200 OK — the <graphic> dict (its index reflects the new z‑position). Undoable; layerLock‑ gated. v1 top‑level graphics (nested → 422).

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/move-to-layer — move to a layer

Moves a graphic from its current layer to another, via -[DKDDocument moveGraphic:toLayer:index:] (undo‑aware). Useful for parking a graphic on a working layer or bringing it onto a visible one.

Body: { "layer": "<destination name or uuid>", "index"?: <Number> } — the destination is matched against each layer's uuid (case‑insensitive) then layerName; index is the z‑position in the destination (default: top). 200 OK — the <graphic> dict; graphicUUID is preserved, so after the move address it under the *destination* layer's uuid. Undoable; layerLock on both source and destination is honored (423 + ?force=true). v1 top‑level graphics.

Errors: 400 (UUIDs/body/missing layer), 401, 404 (graphic chain or destination layer unresolved), 422 (nested graphic, or already on that layer), 423 (locked), 500.

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/flip — flip a graphic

Mirrors a graphic in place — the common vector‑editing flip, and the fix for an upside‑down image (something an AI can spot in a render but EazyDraw itself cannot perceive). Reuses the flip menu command's primitive (flipGraphicsWithFlipSpec:), so it handles the same geometry, grid‑reference, and undo behavior as the UI.

Request body: { "axis": "horizontal" | "vertical" | "mirror" }horizontal mirrors left↔right, vertical top↔bottom, mirror both (180°).

Success — 200 OK. The standard <graphic> dict; the bounding box is unchanged (a flip preserves position and size), and graphicUUID is preserved. One undoable "Flip …" step. Honors layerLock (423 + ?force=true); a flip is neither a move, resize, nor delete, so the per‑graphic move/size/delete locks do not apply.

Errors: 400 (missing UUIDs / body / bad axis), 401, 404 (chain unresolved), 423 (layer locked), 500.

POST /v1/drawings/{D}/layers/{L}/images — insert content (raster or PDF)

Inserts content onto the layer at the front of its z-order (above existing content). This is the *insert into an existing drawing* path; to bring a file in as a new drawing sized to its content, use POST /v1/drawings (see below). The destination is the layer in the URL — make that layer active (the agent drives the destination by choosing the active layer). Page geometry is never changed here.

Request body:

{
  "imageBase64": "<bytes>",   // OR "path": "~/sig.pdf" (a file the EazyDraw process can read)
  "quality":     "screen | retina | print",   // raster only — resample ceiling 72 / 144 / 300; default retina
  "width":       <Number>,    // raster only — display width in document points; default = native pixel size
  "x": <Number>, "y": <Number>,   // raster only — lower-left origin; default centered on the visible view
  "name":        "<string>"   // optional — makes the graphic an addressable named slot
}

Provide imageBase64 (the contract for remote/MCP callers; same Base64 the API *returns* image data as) or a local path (tilde‑expanded; read server‑side).

Raster (PNG/JPEG/TIFF/HEIC/…) — routed via NSImage initDKDWithData:. One bitmap graphic at its native pixel size (1 px → 1 pt), matching how opening the image sizes a page; pass width (document points) to scale it down. Height follows the source aspect (never distorted). scaleFract = (targetDPI × displayWidthPoints / 72) / sourcePixelsWide, clamped ≤ 1 (never upsamples); resampled to that fraction and tagged at targetDPI. Centered on the visible view unless x/y given.

  • Success 201 Created — the <graphic> dict plus an image block:

{ "pixelsWide", "pixelsHigh", "dpi", "bytes" }. Undoable "Insert Image".

PDF — detected by NSPDFImageRep. Comes in as vector, one DKDPDFImage graphic per page at native page size (no scaling), all pages sharing one data blob. Pages are laid onto the drawing's existing page grid via pointWithPageNumber: (page *i* → drawing page *i*); pages past the current grid extend below the canvas — growing the page count is the user's call (not done here). width/x/y/quality are ignored. With more than one page the name is suffixed -1, -2, … so each page stays individually addressable; every page also gets its own graphicUUID.

  • Success 201 Created{ "pageCount": <N>, "graphics": [ <graphic>, … ] },

each graphic dict carrying an extra "pageNumber" (0-based). Undoable "Insert PDF".

Both honor the layer's layerLock (423 + ?force=true).

Errors: 400 (missing path UUIDs / body / neither imageBase64 nor path / bad Base64), 401, 404 (drawing/layer not found; or path unreadable), 422 (image data could not be decoded), 423 (layer locked), 500.

PUT /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/image — fill a named slot

The image Fill keystone (the analog of set_text). The target is a named placeholder — a plain rectangle a designer drew where the image goes (no need to author a PNG placeholder), or an existing image. The incoming image is fitted into the placeholder's rectangle preserving aspect (contain: min(rectW/imgW, rectH/imgH)), centered on the placeholder's center, then the placeholder is replaced — the new image keeps the placeholder's nameGraphic and graphicUUID, so the slot stays addressable by the same name for the next fill.

Body is the same as the insert endpoint (imageBase64 or path, plus quality screen/retina/print); resampling/DPI policy is identical, sized to the fitted rectangle. Success 200 OK — the new <graphic> dict + image block. One undoable "Fill Image" step. v1: top-level placeholders only (a nested chain returns 422). Honors layerLock (423 + ?force=true).

Errors: 400 (path/body), 401, 404 (graphic chain unresolved / unreadable path), 422 (nested placeholder, undecodable image, or zero-size placeholder), 423 (locked), 500.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — morph a graphic

Sets the absolute bounds (position + size) and/or rotation of an existing graphic. The path identifies a single graphic, either at the top of a layer or nested in groups (same UUID-chain grammar as the recursive group-children GET and the graphic-export endpoints).

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G}                          # top-level
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}           # nested
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/graphics/{G3}   # deeper, and so on

Request body: bounds and/or rotationat least one required:

{
  "bounds": {                  // optional; if present, all four fields required
    "x":      <Number>,
    "y":      <Number>,
    "width":  <Number, > 0>,
    "height": <Number, > 0>
  },
  "rotation": <Number>         // optional; degrees, about the graphic's geometric center
}

x and y are document-space coordinates; width and height must be positive. rotation is in degrees and spins the graphic about its own geometric center (nativeCenterGraphic), matching the Morph panel and the general expectation for grouped/nested content. Omit bounds to rotate in place; omit rotation for a pure resize/move. A custom pivot point (e.g. via each graphic's DKDGridReference snap point, or an arbitrary point) is a future enhancement.

Success — 200 OK. Body is the standard <graphic> dict reflecting the post-morph state: graphicUUID is preserved, but class may change (see "Class swap" below). Note hiddenBounds is the axis-aligned bounding box, so it grows when a graphic is rotated — it is not the requested bounds once rotation is non-zero.

Class swap (minimum geometric generalization). Some EazyDraw graphics cannot represent themselves under a given transform and convert to a more general class, preserving the graphicUUID and z-order index:

  • Scaling: a DKDCircle asymmetrically scaled converts to DKDOval (acceptsScalingTransform / conversionClassForScaling / convertForScaling).
  • Rotation: a graphic with no angle parameter converts before rotating — e.g. a DKDRectangle (which deliberately has no rotation handle) becomes a DKDRotatedRect (acceptsRotationTransform / conversionClassForRotation / convertForRotation, applied via +[DKDTransMorphPanel performRequiredRotationConversionsWithGraphicView:]). Graphics that already carry an angle (DKDTextArea, DKDRotatedRect) rotate in place. Arc/pie-type graphics morph as Bezier geometry about their center.

The class field in the response shows the post-swap class. For v1 both swaps are top-level layer graphics only: a nested graphic that would require a class swap returns 422 "Nested graphic class swap not supported in v1" — uniform-scale, pure-translation, and rotation that needs no class change still work for nested graphics.

Transform sequence. Inside one dispatch_sync(dispatch_get_main_queue(), ...), with the target temporarily selected: (1) scale sX = newW/oldW, sY = newH/oldH if not unity; (2) translate (dx, dy) so the box origin lands at the requested x, y; (3) rotate last — setPureRotation + rotateByDegrees:-rotation, applied via [gView morphSelectedGraphicsWithTransform:includeText:YES] which rotates about the graphic's native center. Selection is saved and restored.

Locks. moveLock (when origin changes), sizeLock (when size changes or when rotating — there is no separate rotate-lock in v1), and the layer's layerLock. 423 Locked + ?force=true to override.

Errors:

  • 400 Bad Request — missing path UUIDs; empty/malformed body; neither bounds nor a non-zero rotation; bounds present but missing/non-numeric x/y/width/height or non-positive width/height; rotation not a number.
  • 401 Unauthorized — bearer token missing or wrong.
  • 404 Not Found — drawing/layer/graphic chain does not resolve.
  • 422 Unprocessable Entity — graphic does not accept the scale/rotation and has no conversion class; source graphic has non-positive bounds; nested graphic would require a class swap (v1 limitation); or class conversion produced no replacement.
  • 423 Locked — target graphic or its layer is locked against the change; retry with ?force=true.
  • 500 Internal Server Error — response build failure.

curl:

# resize + reposition
curl -i -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"bounds":{"x":100,"y":100,"width":200,"height":150}}' \
  http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G
# rotate 30 degrees in place
curl -i -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"rotation":30}' \
  http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G

Python:

g = ed.morph_graphic(d_uuid, l_uuid, g_uuid,
                     x=100, y=100, width=200, height=150)
g = ed.morph_graphic(d_uuid, l_uuid, g_uuid, rotation=30)   # rotate in place
print(g["class"], g["hiddenBounds"])
# A DKDRectangle rotated comes back class "DKDRotatedRect"; graphicUUID unchanged.

Text endpoints — DKDTextArea

Programmatic control of text-box content. The target is any DKDTextArea graphic, addressed by the same UUID-chain grammar as the morph/GET/DELETE graphic endpoints (top-level or nested in groups).

Model: a DKDTextArea is a first-class DKDGraphic carrying an NSAttributedString. The intended workflow is template-driven: a designer places a styled placeholder text box (font, size, color, alignment, tab stops all set in the EazyDraw UI); the API then feeds plain text into it. Styling is inherited from the placeholder's run-0 attributes — the API does not require the client to specify appearance. Optional uniform overrides adjust the whole box when needed. There are no per-character / per-range style controls in this version (see "Out of scope").

GET .../graphics/{G_chain}/text

Returns the current text and live layout metrics.

{
  "text": "Quarterly\nSchematic",
  "class": "DKDTextArea",
  "layout": {
    "bounds":       { "x": 100, "y": 120, "width": 180, "height": 48 },
    "requiredSize": { "width": 142.0, "height": 44.0 },   // size text needs at the current width
    "fits":         true,                                  // requiredSize.height <= bounds.height
    "charCount":    19,
    "paragraphCount": 2
  },
  "base": {                          // resolved run-0 attributes (the inherited base)
    "fontFamily": "Helvetica Neue",
    "fontSize":   18.0,
    "bold":       false,
    "italic":     false,
    "color":      "#1A1A1A",
    "alignment":  "left"
  },
  "flow": {                          // text-flow / linked-chain state
    "allowTextLink": false,          // is the box flow-enabled (the "allow text link" switch)
    "linked":        false,          // part of a linked chain (has a pre or post connector)
    "isLead":        false,          // chain head (no incoming link, has an outgoing one)
    "chainLength":   1,              // boxes in the chain (1 if not linked)
    "chainFits":     true            // the whole chain holds the text (no box overflows)
  }
}

requiredSize is -[DKDTextArea requiredSize:] evaluated at the box's current width — the "give a width, get the needed height" primitive for fitting text to a design area. fits compares that height to the current box height.

flow is the upstream clue that long text can *flow* instead of overflowing: when linked is true, setting text on the lead distributes it across the chain (see Text flow below). chainFits is the chain-wide analog of fitsfalse means even the whole chain can't hold the text (shrink the font, or enlarge the boxes). allowTextLink mirrors EazyDraw's Text ▸ Allow Text Link switch.

Errors: 400 (missing UUIDs), 401, 404 (chain does not resolve), 422 (the graphic is not a DKDTextArea), 500.

PATCH .../graphics/{G_chain}/text

Sets text and/or uniform style. Body — all fields optional, but at least one of text or a style override (or autoHeight) must be present:

KeyTypeEffect
textstringReplace the string (may contain \n and \t). Omit to keep the existing string and only restyle.
fontFamilystringFont family, via NSFontManager.
fontSizenumberPoint size (> 0).
boldboolAdd/remove the bold trait.
italicboolAdd/remove the italic trait.
alignmentstringleft \center \right \justified \natural.
kerningstringdefault \off \tight \loose (approximate point-based mapping).
colorstring#RRGGBB or #RRGGBBAA.
autoHeightboolWhen true, grow the box height to fit the text at the current width (setTextAreaSize), as a single undo step with the text change. Ignored when the box flows (a linked chain reflows instead of growing).
allowTextLinkboolSet/clear the box's flow switch (-[DKDTextArea allowTextLink], EazyDraw's Text ▸ Allow Text Link). With a linked chain present, enabling it makes set-text reflow across the chain.

Uniform semantics. The new content is built by taking the placeholder's run-0 attributes, applying the supplied overrides, and laying that uniform style across the entire string. A box with mixed runs is flattened to the run-0 style. This is the Level-1 contract; structural rich text is out of scope.

Tabs / columns. Because the run-0 paragraph style (including tab stops) is inherited, \t-delimited input lays out into the columns the template designer set up — tables, invoices, packing lists work with no table API.

Fit policy. Without autoHeight, the text is set at the current box; if it overflows, the response reports fits:false and requiredSize so the client (or Claude, with a PNG render) decides how to fit — resize the box (PATCH bounds) or resend with a smaller fontSize. EazyDraw never silently auto-fits.

Text flow (linked chains). When a box is flow-enabled (allowTextLink) and linked to following boxes, setting text on the lead makes the whole chain reflow: the text is laid out across the linked boxes (each sized to itself) and each box's slice is written back — attributes preserved (the chain shares one text stream, so styling is not lost the way a manual split would lose it). This reuses the same engine as interactive editing (-[DKDTextArea fullReflowText], gated by reflowApplies = allowTextLink && a post-link connector).

  • Setting text on the lead replaces the whole flowed stream: the handler clears

the downstream boxes first, then reflows, so repeated fills don't double-count.

  • The response's flow.chainFits reports whether the whole chain holds the text

(the per-box layout.fits will read true for the lead even when the tail overflows, because after reflow the lead only holds its own slice — use chainFits for the real answer).

  • v1 distributes across the existing linked boxes; it does not yet create new

boxes when the chain overflows (auto-grow is a planned follow-up). Address the lead box for whole-stream replacement.

  • Linking boxes themselves (drawing the DKDTextLinkPath connectors) is done in

the EazyDraw UI today; the API toggles allowTextLink and drives the reflow.

Success — 200 OK. Body is the <text> dict (same shape as GET), reflecting post-change state — so the fit metrics come back in the same call.

Undo. One discrete step named "Set Text" (swapWithUndoDKDContents:); with autoHeight, the height change is grouped into the same step.

Locks. Checked via the standard helper: the layer's layerLock blocks any change; the graphic's sizeLock blocks only when autoHeight would resize. A pure text/style change is not gated by moveLock/sizeLock. 423 Locked with ?force=true to override.

Errors: 400 (missing UUIDs; empty/malformed body; text not a string; nothing actionable supplied), 401, 404, 422 (not a DKDTextArea), 423 (locked), 500.

t = ed.set_text(d, l, g, "Widget\t3\t$9.00\nGrommet\t12\t$1.20", autoHeight=True)
print(t["layout"]["fits"], t["layout"]["requiredSize"])
t = ed.set_text(d, l, g, font_size=10)        # restyle only, keep the string

Annotation endpoints — DKDAnnotation

A DKDAnnotation is not a graphic; it is owned by a DKDBezier (or subclass), so it is addressed as a sub-resource of its host graphic. Annotation support is plain text only — strings are short and may follow a curve, so there is no size/fit contract; the visual result is confirmed via a PNG export render.

GET .../graphics/{G_chain}/annotation

{
  "class": "DKDRectangle",
  "hasAnnotation": true,
  "text": "Section A",
  "annotationFormat": "Box",      // un-localized DKDAnnotationFormat name
  "annotationShow":   "Yes"       // un-localized DKDAnnotationShow name
}

If the graphic has no annotation (or cannot carry one), hasAnnotation is false and the text/format/show keys are omitted — still 200 OK.

Errors: 400, 401, 404, 500. (No 422: a graphic with no annotation reports hasAnnotation:false rather than erroring.)

PATCH .../graphics/{G_chain}/annotation/text

Body: { "text": "<string>" } (required). Sets the annotation's text, inheriting the existing annotation's run-0 attributes, then rebuilds and redisplays it (touchBarSetAnnotationTextWithUndo:). Requires an existing annotation on the target (template-placed); a graphic without one returns 422 "Graphic has no annotation target".

Success — 200 OK. Body is the <annotation> dict (post-change). Undo: one step, "Set Annotation Text". Locks: layerLock only; 423 + ?force=true.

Errors: 400 (missing UUIDs; text missing/not a string), 401, 404, 422 (no annotation target), 423 (locked), 500.

Export endpoints

Direct GET of a rendered representation. Path grammar:

/v1/drawings/{D}/export/{format}
/v1/drawings/{D}/layers/{L}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/.../graphics/{Gn}/export/{format}
/v1/libraries/{L}/export/{format}                                            (501)
/v1/libraries/{L}/elements/{E}/export/{format}                               (501)

{format} is one of: native, svg, pdf, png, jpg. Case-insensitive.

Format → on-the-wire mapping

formatContent-TypeFile extensionSource
nativeapplication/json.ezdjsonDKDFileType_EZDJSON — flat (un-optimized) JSON dictionary. Drawings: full document via [doc documentDictionaryForDocumentIncludeLayers:YES outputFileType:DKDFileType_EZDJSON] with serialization temporarily forced to DKDSerialization_Flat. Graphics: [g propertyListRepresentation], the same dict the file format embeds, JSON-encoded.
svgimage/svg+xml.svg[doc svgDataForDocument]. For graphics, the document's SVG path is invoked while the graphic is temporarily the only selected graphic and exportContents is Graphics_Selected — see "Selection mutation" below.
pdfapplication/pdf.pdfDrawings: [doc pdfDataMultiPagination:SinglePagePDFPagination withError:&err]. Graphics: [doc pdfDataWithGraphics:@[g]] (no selection mutation — direct entry point).
pngimage/png.png[expCtrlr exportDataWithType:DKDFileType_PNG] with exportContents set per resource (Drawing_Full for drawing/layer, Graphics_Selected with the target selected for graphic). PNG color space, alpha, bits-per-color, and antialias settings come from the document's DKDExport defaults.
jpgimage/jpeg.jpgSame as PNG but DKDFileType_JPG. JPG color space and compression come from DKDExport defaults.

Response headers

  • Content-Type set per the table above.
  • Content-Disposition: attachment; filename="<safe-name>.<ext>" so curl -O, browser saves, and requests's iter_content all get a sensible filename. Filename composition:
    • drawing: <displayName>.<ext>
    • layer: <displayName>-<layerName>.<ext>
    • graphic: <displayName>-<graphicNameOrUUID>.<ext>
  • Path-unsafe characters (/ \ : * ? " < > |) in the filename component are replaced with _.

v1 limitations and behavior

  • Layer endpoint renders the full drawing. Per the spec note "For Layers and Drawings we use Full Drawing Area", v1 uses the existing Drawing_Full export mode at the layer endpoint. The bytes returned for /drawings/{D}/export/{f} and /drawings/{D}/layers/{L}/export/{f} are identical for the same document state. Layer-specific filtering (render only the named layer's graphics on a full-drawing canvas) is a future enhancement and would require temporary layer-state mutation.
  • Selection mutation for graphic raster/SVG. PNG, JPG, and SVG of a single graphic are produced by:
    1. Save current [gView selectedGraphics]
    2. clearSelection, then selectGraphics:@[targetGraphic]
    3. Save current [exp exportContents], set to ExportContents_Graphics_Selected
    4. Call the export
    5. Restore both, in @finally

This runs inside dispatch_sync(main_queue, ...) so it is atomic against other API calls. UI selection updates may be visible briefly to a user watching the document. Native and PDF for graphics use direct entry points and do not mutate selection.

  • Library export deferred. /v1/libraries/{uuid}/export/{format} and /v1/libraries/{uuid}/elements/{uuid}/export/{format} return 501 Not Implemented. Implementing this requires a render path that does not assume a host DKDDocument (library elements live under a DKDLib/DKDLibPalette, not a document). Slated for a follow-up.
  • Detailed export parameters not exposed. DPI / expand factor, color space, SVG profile, SVG glyph mode, JPG compression, PNG alpha and bits-per-color all use the document's current DKDExport defaults. A future revision will accept query-string overrides (e.g. ?dpi=300&colorSpace=srgb).

Status codes

  • 200 — bytes returned with the correct Content-Type.
  • 400 Bad Request{format} is not one of the five recognized values, or (for the recursive graphic endpoint) the UUID chain is empty.
  • 404 Not Found — drawing / layer / graphic at the named UUID(s) does not resolve.
  • 500 Internal Server Error — export pipeline returned nil bytes.
  • 501 Not Implemented — library export endpoints (deferred).

Layer visibility — PATCH /v1/drawings/{D}/layers/{L} and PATCH /v1/drawings/{D}

EazyDraw layer visibility is two‑dimensional: each layer has a LayerState (On / Off / Active, exactly one active = the destination for new content), and the document has a LayerSelect mode governing which non‑active layers show. Export is WYSIWYG — graphics gate their own drawing on visibleLayer:, so what renders to PDF = the active layer + (ON layers, only if layerSelect permits). With one layer this collapses and never matters. This is the basis for conditional regions: park an optional clause on its own layer and toggle it.

PATCH /v1/drawings/{D}/layers/{L} { "layerState": "on" | "off" | "active" }

  • off hides the layer (drops its content from the render); on shows it;

active makes it the destination (the previous active becomes on).

  • The active layer's on/off can't be changed (it's always shown/editable) —

422 "make another layer active first". Setting an already‑active layer active is a no‑op. 200 OK — the updated <layer> dict.

PATCH /v1/drawings/{D} { "layerSelect": "active-only" | "show-others" | "select-others" | "show-all" | "select-all" }

  • active-only → only the active layer shows; show-others → ON layers visible;

select-others → ON layers visible and editable; show-all / select-all → include OFF layers. Set show-others/select-others so a layer you turn ON actually appears in the render. 200 OK — the updated <drawing> dict.

Both are undoable (self‑registering inverse + screen refresh) and mark the document dirty. Layer state is not gated by layerLock (visibility is meta, not a content edit).

POST /v1/drawings/{D}/layers/{L}/order — restack a layer

Moves a whole layer in the document's front‑to‑back stack — the order the Layers drawer table shows (front layer at the top) and the order layers paint (front on top of the layers beneath it). This restacks the entire layer; POST .../graphics/{G_chain}/order restacks one graphic *within* its layer.

Request body { "to": "front" | "back" | "forward" | "backward" }

  • front → top of the Layers table, drawn on top of every layer below it

(internal index 0).

  • back → bottom of the table (internal index count − 1).
  • forward → one step toward the front; backward → one step toward the back.

Already at the end → clamped (no‑op move, still 200).

Backed by -[DKDDocument moveLayer:toIndex:], which registers its own undo inverse; the handler wraps it in a discrete undo group, refreshes the screen and the Layers drawer popup (dkdAPIRefreshLayerVisibility), and marks the document dirty. Action name "Layer To Front" / "Layer To Back" / "Layer Forward" / "Layer Backward".

> Front/back vs. the internal index. The vocabulary is viewer‑consistent: > *front* = on top = top of the Layers table. Internally that is array index 0, > and the draw loop iterates the layer array in reverse so index 0 paints last > (on top). Callers never see the index — they say front/back/forward/ > backward.

Not gated by layerLock (restacking is document structure, like layer visibility, not a content edit). 200 OK — the updated <layer> dict.

Errors: 400 body missing or to not one of the four words; 404 drawing or layer not found; 500 response build failed.

POST /v1/drawings/{D}/layers — create a layer

Adds a new layer at the front of the stack (top of the Layers table) and, by default, makes it the active layer so the next inserted content lands on it. Mirrors the UI New Layer command (-[DKDLayersDataSource addLayerAction]): the new layer inherits the active layer's dkdScale. Use it to stack content — e.g. create a layer for an incoming PDF backdrop, or the layer a template needs above a PDF for its fill slots.

Request body (optional — send {} or no body for defaults):

{
  "name":   "<string>",   // optional; auto-assigned if omitted, de-duplicated either way
  "active": <Bool>        // optional; default true — make the new layer active
}

Backed by -[DKDDocument insertLayer:atIndex:] (registers its own undo inverse) at index 0, plus dkdAPISetActiveLayer: when active (self‑registering inverse to the prior active); wrapped in a discrete undo group with a screen + Layers‑drawer refresh and dirty mark. Action name "New Layer". The new layer is assigned a layerUUID so it is immediately addressable. Not gated by layerLock.

Success — 201 Created. Body is the new <layer> dict.

Errors: 400 drawing UUID missing, or body present but not a JSON object; 404 drawing not found; 500 response build failed.

Layer configurations — GET .../layer-configurations and apply via PATCH /v1/drawings/{D}

A layer configuration (DKDLayersConfiguration) is a saved, named snapshot of a whole layer arrangement — each layer's on/off state, hide-dimensions, lock, and color modification, plus the layer order and the document's layerSelect mode. Users build them in the Layers drawer ("draft", "final", "client-review", …) to switch a drawing between presentations. These endpoints let an agent list them and apply one, so a user can set up and *see* a configuration with AI help.

GET /v1/drawings/{D}/layer-configurations → the saved configs:

[
  {
    "name": "client-review",
    "layerSelect": "show-others",
    "layers": [
      { "layerName": "Base",     "layerState": "On",  "hideDimensions": false, "layerLock": false },
      { "layerName": "Markup",   "layerState": "Off", "hideDimensions": true,  "layerLock": false }
    ]
  }
]

layerState is the un-localized name (On/Off/Active); layerSelect uses the API vocabulary (show-others, …). The layers order is the configuration's layer order.

PATCH /v1/drawings/{D} { "layerConfiguration": "<name>" } — apply it. This is the HTTP analog of the UI's Load Layer Configuration: it calls -[DKDLayersConfiguration applyToDocument:], setting every layer's state / hide-dimensions / lock / color-mod, reordering the layers, and setting layerSelect. It changes the live document (visible on screen) and is undoable — wrapped in a self-registering helper (dkdAPIApplyLayersConfiguration:) that snapshots the current arrangement as a config, registers the inverse, then applies the new one, so undo and redo both restore the full layout. Action name "Apply Layer Configuration". Render afterward (export png) to *see* the result. 200 OK — the updated <drawing>.

layerSelect and layerConfiguration may be sent together (config applied first); at least one is required. 404 if no saved config has that name. (Saving/deleting configurations is not yet exposed — build them in the UI for now.)

<layer> shape

{
  "layerName":      "<DKDLayer.layerName>",
  "layerState":     "<nameForLayerState(state, NO)>",
  "graphicsCount":  <NSUInteger>
}

layerState values are the un-localized names: NotSet, On, Off, Active. graphicsCount is [layer.layerGraphics count].

If a layer has no UUID at the time it is requested, one is assigned via [layer setLayerUUID:] so subsequent calls are stable.

<graphic> shape (entry in /layers/{uuid}/graphics)

{
  "index":            <NSUInteger>,
  "graphicUUID":      "<DKDGraphic.graphicUUID>",
  "class":            "<NSStringFromClass([g class])>",
  "graphicTagString": "<DKDGraphic.graphicTagString>",
  "nameGraphic":      "<DKDGraphic.nameGraphic>",
  "hiddenBounds":     { "x": <Number>, "y": <Number>, "width": <Number>, "height": <Number> },
  "graphicsCount":    <NSUInteger>,
  "locks":            { "deleteLock": <Bool>, "moveLock": <Bool>, "sizeLock": <Bool>, "layerLock": <Bool> }
}

Array order in /graphics is [layer layerGraphics] order; this is the layer z-order / draw order. index is the position in that array (0 is drawn first, highest index is drawn last / on top within the layer).

nameGraphic is omitted if the graphic has no name set. graphicTagString is the empty string when not set.

graphicsCount is only present when the graphic is a DKDGroup (or subclass thereof), and reports [[g groupArray] count] — the number of immediate children in the group. Non-group graphics omit the key entirely. The presence of the key is itself the "this is a group" signal; in Python, g.get("graphicsCount") returns None for leaf graphics and an integer for groups. An empty group reports 0.

locks is always present. Three flags come from the graphic's DKDLock (deleteLock, moveLock, sizeLock); the fourth (layerLock) is the containing layer's lock. Clients can read this dict before attempting a mutation to know whether it will be blocked — see Lock policy below. (sizeLock is the general-purpose size lock that the API checks. DKDLock also carries DKDLine Pin flags — LockFlag_Lock_Angle, LockFlag_Lock_Length, LockFlag_Lock_Center — which are intentionally not exposed or enforced by the API; see Pin is not an API lock under Lock policy.)

If a graphic has no UUID at the time it is requested, one is assigned via [g setGraphicUUID:] so subsequent calls are stable.

Sub-resource bodies

pages/setup, pages/layout, grids, properties, scale, window return the corresponding model object's propertyListRepresentation (or docWindowConfiguratonDictionary for window) verbatim. Keys and value formats match the on-disk drawing file format.

pages is a parent segment for page-related sub-resources; it has no body of its own (no GET handler at /v1/drawings/{uuid}/pages itself yet).

/state — full model state (read backstop)

The /state endpoints return an object's complete propertyListRepresentation as JSON — the same dictionary the on-disk file format embeds. They are the backstop: when a structured endpoint doesn't expose a particular attribute, GET the full state and read whatever you need. Keys are EazyDraw on-disk names (PascalCase / mixed), not the camelCase HTTP vocabulary — they are human/AI-readable but not Python-idiomatic.

EndpointBodyLookup
GET /v1/drawings/{D}/layers/{L}/state[layer propertyListRepresentation]404 Layer not found
GET /v1/libraries/{L}/state[lib propertyListRepresentation]404 Library not found
GET /v1/libraries/{L}/elements/{E}/state[element propertyListRepresentation]{E} matched case-insensitively against each DKDLibElement.libraryUUID; 404 Library or element not found

DKDGraphic / DKDGroup / DKDDocument full state is already reachable via GET .../export/native (DKDFileType_EZDJSON), so these three close the remaining gaps (layer, library, library element).

Flat (inline attributes, no hash refs). The dumps resolve attributes inline so a reader never has to chase hash references into an archive store:

  • Layer and element: a bare propertyListRepresentation serializes graphics with a nil archiveStoreRef, so attributes are already inline — no special handling.
  • Library: DKDLib.propertyListRepresentation builds a hash-ref archive store when its serialization is Optimized. The handler therefore saves the lib's serialization, forces DKDSerialization_Flat, builds the dict, and restores — the same flat-forcing export/native uses on documents.

These are GETs: read-only, never blocked by locks, no document mutation.

Binary data → Base64. A library element can carry a button-icon image (DKDImageNSData), and some graphics embed image data. All API JSON responses funnel through one encoder (_dkd_jsonResponseWithObject:): JSON-safe payloads use NSJSONSerialization unchanged, but a dictionary containing NSData (which NSJSONSerialization rejects) falls back to EazyDraw's jsonDataWithDictionary, which Base64-encodes the bytes — the same representation the on-disk .ezdjson and export/native produce. So image bytes come back as Base64 strings, never an error.

PATCH /v1/drawings/{D}/properties — set document properties

> Status: implemented and verified on Version-26 (tested via tools/python/test_properties.py). The API's first non-graphic mutation and the template for read+write on flat (non-hash-optimized) model entities.

Sets document metadata on [doc docProperties]. Merge semantics — only the keys present in the body are changed; omitted keys are left untouched. Companion to GET /v1/drawings/{D}/properties, which returns the same dict shape, so you can read, edit, and send the same dict back.

Request body — any subset of the writable metadata keys. Keys are the verbatim propertyListRepresentation keys (PascalCase, on-disk form), the same vocabulary GET returns. (A clean camelCase external vocabulary with a mapping layer is a known future revision; until then the on-disk keys are the contract.)

JSON keyJSON typeModel setter
TitlePropertystringsetTitleProperty:
AuthorsPropertyarray of stringssetAuthorsProperty:
KeywordsPropertyarray of stringssetKeywordsProperty:
DescriptionPropertystringsetDescriptionProperty:
CommentPropertystringsetCommentProperty:
OrganizationsPropertyarray of stringssetOrganizationsProperty:
CopyrightPropertystringsetCopyrightProperty:
ProjectsPropertyarray of stringssetProjectsProperty:
VersionPropertystringsetVersionProperty:
PATCH /v1/drawings/{D}/properties
{
  "TitleProperty":   "Quarterly Schematic",
  "AuthorsProperty": ["Dave Mattson"],
  "CopyrightProperty": "2026"
}
  • Omit a key to leave it unchanged. Clear a field by sending "" (string fields) or [] (array fields).
  • Array fields must be JSON arrays whose elements are all strings.

Implementation note — do NOT route through loadPropertyListRepresentation:. The handler must apply changes via the individual setters. DKDProperties.loadPropertyListRepresentation: (DKDProperties.m:418) is a full-replace that resets QuickLook settings and the serialization mode to defaults when their keys are absent — a metadata-only PATCH sent through it would silently clobber QuickLookContent / QuickLookFormat and force Serialization to Flat. Setter-based merge avoids this.

Deferred keys — accepted, ignored in v1. The GET response also carries QuickLookContent, QuickLookPage, QuickLookFormat, QuickLookUseSmallerOf_JPG_PDF, Serialization, and uuid. So a client can GET, edit one metadata field, and PATCH the whole dict back. In v1 these six keys are accepted and ignored (not an error, so round-trips don't break) but not written. They are behavior switches rather than metadata: QuickLook configuration; the Optimized↔Flat Serialization toggle (the on-disk attribute-optimization store); and uuid (DKDProperties.uuidProperties, the yes/no for whether the document assigns persistent UUIDs to graphics and layers). Each warrants its own deliberate design before becoming writable.

Success — 200 OK. Body is the full updated DKDProperties.propertyListRepresentation (same shape as GET), reflecting post-merge state.

Undo / persistence. Mirror the Properties inspector (DKDPropertiesPanel OK path): snapshot the full propertyListRepresentation before the merge, apply via the setters, snapshot it after, then register a symmetric swap on [doc undoManager] with action name "Change Drawing Properties" — undo restores the before-snapshot, redo the after. Registering the undo marks the document dirty (the panel's immediate-apply toggles also call [doc updateChangeCount:NSChangeDone] explicitly; doing both is harmless). The inspector's -swapProperties:oldProperties: lives on the panel and is not reachable headlessly, so the handler needs an equivalent swap target — recommended: a small DKDDocument-level swap method as the prepareWithInvocationTarget: target. That swap applies the full before/after snapshots via loadPropertyListRepresentation: (safe — complete dicts), which is distinct from the partial PATCH apply that uses the individual setters.

Errors:

  • 400 Bad Request — body empty or not a JSON object; a string field given a non-string; an array field given a non-array or an array containing a non-string element; or an unrecognized key (not one of the nine writable keys or the five accepted-and-ignored keys — this guards against PascalCase typos silently no-op'ing).
  • 401 Unauthorized — bearer token missing or wrong.
  • 404 Not Found — no open drawing with that UUID. Body: { "error": "Drawing not found" }.
  • 500 Internal Server Error — response build failed.

No lock checks apply — DKDProperties is document metadata, not a graphic or layer, so there is no 423/force path.

Threading. Lookup, setter application, undo registration, and response build run inside one dispatch_sync(dispatch_get_main_queue(), …) block, like the other mutating handlers.

curl:

curl -i -X PATCH -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"TitleProperty":"Quarterly Schematic","AuthorsProperty":["Dave Mattson"]}' \
  http://localhost:52737/v1/drawings/$D/properties

Python:

props = ed.set_properties(d_uuid, {
    "TitleProperty":   "Quarterly Schematic",
    "AuthorsProperty": ["Dave Mattson"],
})
print(props["TitleProperty"])

Lock policy

Every DKDGraphic carries a DKDLock exposing three boolean flags the API enforces (deleteLock, moveLock, sizeLock, plus others not yet wired into the API), and every DKDLayer carries layerLock. These are deliberate user-set protections — set in the EazyDraw UI to keep a graphic or whole layer from being changed accidentally. The API respects them by default and rejects mutations that would violate a lock, returning 423 Locked with a body that explains which lock is in the way:

HTTP/1.1 423 Locked
Content-Type: application/json

{
  "error": "Graphic has delete-lock. Retry with ?force=true to override.",
  "locks": { "deleteLock": true, "moveLock": false, "sizeLock": false, "layerLock": false }
}

The same locks sub-dict appears in every <graphic> response, so a polite client can read locks before attempting a change and skip the round-trip on known-locked items.

Per-endpoint lock checks:

EndpointChecks
POST /v1/drawings/{D}/layers/{L}/graphicsdestination layer's layerLock
PATCH .../graphics/{G_chain} (bounds)moveLock (when origin changes), sizeLock (when size changes), graphic's layerLock
DELETE .../graphics/{G_chain}deleteLock, graphic's layerLock

The PATCH check is granular: a pure translation against a graphic with only sizeLock set is allowed, and a pure resize against a graphic with only moveLock set is allowed. The check fires only when the requested change touches the locked dimension.

Override: all three lock-checking endpoints accept the query parameter ?force=true to bypass. The token is the authorization boundary; force is the explicit "I know what I'm doing" gesture. Use it deliberately in scripts that genuinely need to write through locks (bulk cleanup, migration). The Python client exposes this as a force=False keyword argument:

ed.delete_graphic(d, l, g_uuid)                # 423 if delete-locked
ed.delete_graphic(d, l, g_uuid, force=True)    # always deletes
ed.morph_graphic(d, l, g_uuid, x=10, y=10, width=20, height=20, force=True)
ed.use_library_element(d, l, lib, el, force=True)  # add to a locked layer

GET endpoints are never blocked by locks. Read-only by definition.

Pin is not an API lock. DKDLine (and line-like geometry) supports a UI concept called Pin that constrains interactive endpoint editing along three axes — Angle, Length, and Center — stored on DKDLock as LockFlag_Lock_Angle (0x04), LockFlag_Lock_Length (0x08), and LockFlag_Lock_Center (0x10). These are deliberately not part of API lock enforcement: pinning a line's angle does not make a rotating morph return 423, and pinning its center does not block a move. Pin is an *interactive editing aid* — it shapes how handle drags behave in the EazyDraw UI — not a formal "protect from change" lock like moveLock / sizeLock / deleteLock / layerLock. The API treats Pin as out of scope by design; a script that must respect a line's pin should read it from the graphic's /state and honor it client-side. (Mapping Pin onto API semantics — e.g. should pinned-length veto a non-uniform scale? — is intentionally deferred; it has no clean, unsurprising answer.)

Status codes

  • 200 — found and serialized (GETs); or, for POST /v1/drawings, the file was already open.
  • 201 CreatedPOST /v1/drawings opened a new window for a file that wasn't already open.
  • 400 Bad Request — for the recursive group-children endpoint, the UUID chain was empty. For POST /v1/drawings and POST /v1/libraries, the body was missing/malformed, the path field was missing/empty, or path was not absolute/tilde-prefixed. For DELETE /v1/drawings/{uuid} and DELETE /v1/libraries/{uuid}, missing UUID in the path (defensive — the regex normally guards this).
  • 401 Unauthorized — missing Authorization header or token doesn't match the configured value. Response includes WWW-Authenticate: Bearer realm="EazyDraw". Message: Missing or invalid bearer token.
  • 404 — no open drawing with that UUID, no layer with the given UUID inside the drawing, no library with that UUID in either _libMenus or open palette windows, or (for the recursive group-children endpoint) some UUID along the chain did not resolve / pointed at a non-group graphic. For POST /v1/drawings and POST /v1/libraries, the supplied path did not exist on disk. Body is GCDWebServer's default error HTML with message Drawing not found, Layer not found, Library not found, or Group not found, or graphic is not a group; for POST endpoints the body is JSON { "error": "...", "path": "..." }.
  • 422 Unprocessable EntityPOST /v1/drawings or POST /v1/libraries: file exists but NSDocumentController could not open it, or the opened object was not the expected document subclass. JSON body carries the NSError's localizedDescription. For POST /v1/drawings/{D}/layers/{L}/graphics: library element is arrange-tool or attribute-action, or the use action otherwise produced no graphic. For PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}: graphic does not accept scaling and has no conversion class, source bounds are non-positive, or a nested graphic would require a class swap (v1 limitation).
  • 423 LockedPOST /v1/drawings/{D}/layers/{L}/graphics, PATCH .../graphics/{G_chain}, or DELETE .../graphics/{G_chain}: target graphic or its layer is locked against the requested mutation. JSON body: { "error": "...", "locks": { ... } }. Retry with ?force=true to override. See Lock policy above.
  • 500 — the response encoder (_dkd_jsonResponseWithObject:) could not produce JSON. Note NSData (e.g. image bytes) is not a 500 — it is Base64-encoded via EazyDraw's jsonDataWithDictionary; a 500 here means even that fallback failed. Message: Failed to serialize JSON response. For POST /v1/drawings / POST /v1/libraries, also: open completion timed out (30s) or response build failed; JSON body.

Threading

Request handlers run on a background GCD queue. All AppKit / model access is funnelled through dispatch_sync(dispatch_get_main_queue(), …) inside the shared helper _dkd_extractFromDocumentUUID:extractor:, so extractor blocks always run on the main thread and the response is serialized off-main only after the dictionary is built.

Naming conventions (authoritative)

These rules apply to all new endpoints, JSON keys, and Key_HTTP_Server_* constants. Match these exactly when extending the API; deviations are legacy compatibility, not patterns to copy.

URL paths

  • All lowercase. No PascalCase or camelCase in path segments. Example: /v1/drawings/{uuid}/layers/{uuid}, never /Layers/.
  • kebab-case for compound words. Multi-word path segments are joined with hyphens. Example: /color-modification, not /colorModification or /color_modification.
  • Plural collection nouns; UUID for the singular item. /drawings (collection) ↔ /drawings/{uuid} (one). /layers/layers/{uuid}. The collection segment never appears in the singular form.
  • Sub-resources hang off the singular item. /v1/drawings/{uuid}/layers/{uuid}/scale, never /v1/drawings/{uuid}/scale-of-layer/{uuid}.
  • Versioning prefix /v1 for all resource endpoints. /status is intentionally unversioned (liveness probe); if version/build semantics ever change, add /v1/status alongside rather than breaking /status.

JSON response keys

  • camelCase for all new keys: layerName, layerState, graphicsCount, graphicUUID, graphicTagString, nameGraphic, hiddenBounds, displayName (when newly minted).
  • Match the DKD model property name when the key reflects a model attribute. The HTTP key for [g graphicUUID] is "graphicUUID", for [g hiddenBounds] is "hiddenBounds". This makes Key_HTTP_Server_* ↔ getter ↔ JSON key trivially traceable.
  • Single-word keys are lowercase: index, class, status, version, build, drawings, layers, graphics, x, y, width, height.
  • Legacy keys retained for stability (do not propagate the style — these predate the convention):
    • "uuid" (lowercase) — the UUID_Key constant, used at every level for the resource's own UUID
    • "DisplayName" (PascalCase) — on the <drawing> and <library> dicts
    • "DocumentFileName" (PascalCase) — the file path, on the <drawing> and <library> dicts

New keys at any level must be camelCase even when they sit alongside these.

Code constants (Key_HTTP_Server_*)

  • One constant per JSON key. The constant value is the JSON key string verbatim (Key_HTTP_Server_graphicUUID = @"graphicUUID").
  • Constants double as URL segments only when single-word. Key_HTTP_Server_drawings, Key_HTTP_Server_status, Key_HTTP_Server_v1 appear in both URL composition and response keys. Multi-word URL segments (color-modification) are inline string literals at the registration site — do not introduce a constant whose JSON-key value would be "color-modification".
  • Path-only segments without a JSON-key analogue (e.g. layers, graphics as URL components when the same word also names a JSON key with the same value) reuse the JSON-key constant. Do not create a parallel Path_* family.

Value formats

  • Bounds and rects in JSON use the sub-dictionary form { "x", "y", "width", "height" } with NSNumber doubles. This is the API-facing format. The on-disk propertyListRepresentation may continue to use svgStringWithRectangle strings — that is a serialization detail, not the HTTP contract.
  • Layer-state strings are the un-localized names: NotSet, On, Off, Active. Never localized in API responses.
  • UUIDs are uppercase hex with dashes, as produced by [NSUUID UUIDString]. Matching against client-supplied UUIDs is case-insensitive.
  • Numbers: counts and indices are plain JSON numbers (NSNumber unsignedInteger); coordinate/dimension values are doubles.

When the convention and an existing model getter disagree

The model getter wins for the JSON key (graphicTagString, not a paraphrase) — the goal is debuggability across the stack. If a model name violates camelCase or is awkward (e.g. all-caps acronym), keep the model name and document the exception here rather than diverging silently.

Configuration keys (extern NSStrings)

Defined in DKDAppDelegate+HTTP_Server.h:

Key_HTTP_Server_v1               @"v1"
Key_HTTP_Server_drawings         @"drawings"
Key_HTTP_Server_libraries        @"libraries"
Key_HTTP_Server_inMenu           @"inMenu"
Key_HTTP_Server_paletteOpen      @"paletteOpen"
Key_HTTP_Server_elementsCount    @"elementsCount"
Key_HTTP_Server_elementType      @"elementType"
Key_HTTP_Server_nameElement      @"nameElement"
Key_HTTP_Server_toolClass        @"toolClass"
Key_HTTP_Server_toolName         @"toolName"
Key_HTTP_Server_elementType_graphic           @"graphic"
Key_HTTP_Server_elementType_create_tool       @"create-tool"
Key_HTTP_Server_elementType_arrange_tool      @"arrange-tool"
Key_HTTP_Server_elementType_attribute_action  @"attribute-action"
Key_HTTP_Server_export           @"export"
Key_HTTP_Server_format_native    @"native"
Key_HTTP_Server_format_svg       @"svg"
Key_HTTP_Server_format_pdf       @"pdf"
Key_HTTP_Server_format_png       @"png"
Key_HTTP_Server_format_jpg       @"jpg"
Key_HTTP_Server_status           @"status"
Key_HTTP_Server_AppVersion       @"version"
Key_HTTP_Server_AppBuild         @"build"
Key_HTTP_Server_displayName      @"DisplayName"
Key_HTTP_Server_layerName        @"layerName"
Key_HTTP_Server_layerState       @"layerState"
Key_HTTP_Server_graphicsCount    @"graphicsCount"
Key_HTTP_Server_index            @"index"
Key_HTTP_Server_graphicUUID      @"graphicUUID"
Key_HTTP_Server_class            @"class"
Key_HTTP_Server_graphicTagString @"graphicTagString"
Key_HTTP_Server_nameGraphic      @"nameGraphic"
Key_HTTP_Server_hiddenBounds     @"hiddenBounds"
Key_HTTP_Server_x                @"x"
Key_HTTP_Server_y                @"y"
Key_HTTP_Server_width            @"width"
Key_HTTP_Server_height           @"height"
Key_HTTP_Server_text             @"text"
Key_HTTP_Server_fontFamily       @"fontFamily"
Key_HTTP_Server_fontSize         @"fontSize"
Key_HTTP_Server_bold             @"bold"
Key_HTTP_Server_italic           @"italic"
Key_HTTP_Server_alignment        @"alignment"
Key_HTTP_Server_kerning          @"kerning"
Key_HTTP_Server_color            @"color"
Key_HTTP_Server_autoHeight       @"autoHeight"
Key_HTTP_Server_layout           @"layout"
Key_HTTP_Server_base             @"base"
Key_HTTP_Server_bounds           @"bounds"
Key_HTTP_Server_requiredSize     @"requiredSize"
Key_HTTP_Server_fits             @"fits"
Key_HTTP_Server_charCount        @"charCount"
Key_HTTP_Server_paragraphCount   @"paragraphCount"
Key_HTTP_Server_annotation       @"annotation"
Key_HTTP_Server_hasAnnotation    @"hasAnnotation"
Key_HTTP_Server_annotationFormat @"annotationFormat"
Key_HTTP_Server_annotationShow   @"annotationShow"

v1 and drawings are used for both URL composition and as response keys; the others are response keys only.

Out of scope so far

  • Rich / structured text — per-character or per-paragraph runs on DKDTextArea. v1 is uniform (Level 1): run-0 attributes + whole-box overrides. If added later, the representation will be Markdown/HTML → NSAttributedString, not character-offset spans.
  • Text measure endpoint — deferred; the PATCH .../text response already returns requiredSize/fits, which covers the client-driven fit loop. A candidate-string measure (no commit) may come later.
  • Annotation styling — annotation endpoints are plain-text only (no uniform overrides); size/fit is not a contract (text may follow a curve).
  • Other mutation verbs (PUT; POST for open and library-element placement, DELETE for close, PATCH for graphic bounds are implemented — see above)
  • Rotation about a custom pivotPATCH .../graphics/{chain} rotation is implemented about each graphic's geometric center (nativeCenterGraphic). A caller-supplied pivot (graphic DKDGridReference snap point, bounds corner, or arbitrary document point) is a future enhancement.
  • Class swap for nested graphics during morph (v1 supports only top-level class swap, for both scale and rotation; a nested graphic that would need a class change returns 422)
  • Attribute-action transfer onto a nested (grouped) graphic — POST .../graphics/{uuid}/attributes is implemented for top-level layer graphics; a nested target returns 422 (deferred, like the morph nested class-swap limitation)
  • Per-graphic resources addressable by graphicUUID (only the array form is exposed)
  • App Store (sandboxed) distribution — XPC alternative pending evaluation
  • iOS / VisionPro — pending evaluation
  • Library element export — render path needs to be designed (no host DKDDocument); 501 in v1
  • Layer-specific filtered export — currently returns full drawing render; layer-only filtering deferred
  • Detailed export parameters (DPI, color space, SVG profile/glyphs, JPG compression, PNG alpha/bits-per-color) — uses document's current DKDExport defaults; query-string overrides deferred
  • Document properties write — specced above as the next endpoint (PATCH /v1/drawings/{uuid}/properties, proposed). v1 covers the nine metadata fields only
  • QuickLook settings, the Serialization (Optimized↔Flat) toggle, and the uuid (persistent-UUID yes/no) toggle write — accepted-and-ignored by the properties PATCH; a separate endpoint is deferred
  • camelCase JSON key mapping layer — properties (and the other verbatim-plist sub-resources) currently use on-disk PascalCase keys; a clean external camelCase vocabulary with a mapping layer is a planned future revision