
HAL client handling HAL document parsing, RFC6570 URI-template expansion and HTTP communication with HAL-aware helpers, easy link/embedded navigation, multipart/file uploads, and structured error handling.
= HALDiSh — Kotlin Multiplatform HAL client :toc: left :toclevels: 3 :icons: font :source-highlighter: highlight.js
image:https://img.shields.io/maven-central/v/com.helpchoice.nahal/haldish.svg?label=Maven%20Central[Maven Central,link=https://central.sonatype.com/artifact/com.helpchoice.nahal/haldish] image:https://img.shields.io/badge/License-Apache%202.0-blue.svg[License,link=https://www.apache.org/licenses/LICENSE-2.0]
Haldish is a https://stateless.group/hal_specification.html[HAL (Hypertext Application Language)] client library for https://kotlinlang.org/docs/multiplatform.html[Kotlin Multiplatform]. It handles HAL document parsing, RFC 6570 URI template expansion, and HTTP communication on every supported platform.
This repository holds the library alone. The NaHAL navigator built on it — :core, the Compose
UI, the example plugins and the testkit — lives in ../nahal and consumes this project as the
published artifact com.helpchoice.nahal:haldish.
== Supported Platforms
|=== | Target | HTTP engine
| JVM | Ktor CIO
| JS (IR) — browser + Node.js | ktor-client-js
| Wasm/JS — browser | ktor-client-js
| macOS x64 / arm64 | Ktor Darwin
| iOS x64 / arm64 / Simulator arm64 | Ktor Darwin
| Linux x64 / arm64 | Ktor cURL
| Windows (mingwX64) | Ktor WinHTTP |===
YAML HAL format (application/hal+yaml) is supported on JVM and JS only. On native and Wasm targets, YAML responses are not parsed.
Android is not a build target, but the JVM artifact is consumable there — see <>.
== Dependency
Released on Maven Central as com.helpchoice.nahal:haldish. Current version: 2.1.0.
Two other channels carry the same release for consumers that cannot read Maven artifacts: npm,
as haldish and haldish-wasm (see <<JavaScript (Node.js)>> and <<WebAssembly (Browser)>>), and
Swift Package Manager, via the XCFramework attached to the GitHub Release for the version's tag
(see <<Swift (iOS / macOS)>>).
In a Kotlin Multiplatform project, declare it once in commonMain — Gradle module metadata
resolves the right per-target artifact (haldish-jvm, haldish-js, haldish-macosarm64, …)
for every target you build:
// build.gradle.kts repositories { mavenCentral() }
For a plain JVM (or Android) project, the same coordinates work — Gradle picks haldish-jvm:
Maven has no support for Gradle module metadata, so name the JVM artifact explicitly:
com.helpchoice.nahal haldish-jvm 2.1.0 ----Snapshots are not published. To try unreleased changes, build from source and install into the local Maven repository:
== Quick Start (Kotlin)
import com.helpchoice.nahal.haldish.http.HalHttpClient import com.helpchoice.nahal.haldish.uritemplate.expandHref
// HalHttpClient wraps a Ktor HttpClient and adds HAL-aware helpers.
// It implements AutoCloseable — use use or call close() when done.
val client = HalHttpClient()
// GET a HAL document (sends Accept: application/hal+json, hal+xml, hal+yaml, …) val root: HalDocument = client.getHal("https://api.example.com/")
// Navigate links val self: HalLink? = root.link("self") // first link for rel val admins: List = root.links("admin") // all links for rel
// Read embedded resources val orders: List = root.embedded("ea:order")
// Read plain properties val count: JsonElement? = root.properties["currentlyProcessing"]
=== URI Template Expansion
HAL links can be https://www.rfc-editor.org/rfc/rfc6570[RFC 6570 URI templates].
Use expandHref or UriTemplate to expand them before making a request.
import com.helpchoice.nahal.haldish.uritemplate.UriTemplate import com.helpchoice.nahal.haldish.uritemplate.UriTemplateVars import com.helpchoice.nahal.haldish.uritemplate.expandHref
// Scalar variable val link = root.link("search")!! // href: "/items{?q,page}" val url = link.expandHref("q" to "hal", "page" to 2) // → /items?q=hal&page=2
// List variable val vars = UriTemplateVars() .add("name", "Project A") .add("name", "Project B") .set("name_op", "in") val filtered = UriTemplate("/items{?name*,name_op}").expand(vars) // → /items?name=Project%20A&name=Project%20B&name_op=in
UriTemplateVars supports three value types:
set(name, scalar) — single string valueset(name, list) / add(name, value) — ordered listset(name, map) / put(name, field, value) — associative map=== Making Requests
import com.helpchoice.nahal.haldish.http.HalHttpClient import com.helpchoice.nahal.haldish.http.HalRequestBody
val client = HalHttpClient()
// Raw HTTP — returns HalHttpResponse (statusCode, headers, cookies, body, contentType) val response = client.get(url) val created = client.post(url, HalRequestBody.Json("""{"name":"foo"}""")) val updated = client.patch(url, HalRequestBody.Json("""{"name":"bar"}""")) val deleted = client.delete(url)
// File upload val bytes: ByteArray = java.io.File("photo.jpg").readBytes() client.post(url, HalRequestBody.Binary(bytes, "image/jpeg"))
// Multipart client.post(url, HalRequestBody.Multipart(listOf( MultipartPart("photo", photoBytes, fileName = "photo.jpg", contentType = "image/jpeg"), MultipartPart("metadata", metaBytes, contentType = "application/json"), )))
// Custom headers and cookies client.get(url, headers = mapOf("Authorization" to "Bearer token"), cookies = mapOf("session" to "abc123"), )
=== Error Handling
All exceptions extend HaldishException:
== Java
HalHttpClient is usable from Java. Kotlin suspend functions are called via BuildersKt.runBlocking:
try (HalHttpClient client = new HalHttpClient()) { HalDocument root = (HalDocument) BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, (scope, cont) -> client.getHal("https://api.example.com/", Map.of(), cont) ); String href = root.link("projects").getHref();
UriTemplateVars vars = new UriTemplateVars()
.set("name", Arrays.asList("Project A", "Project B"))
.set("name_op", "in");
String url = new UriTemplate(href).expand(vars);
A full walkthrough is in link:src/examples/java/src/main/java/CrudExample.java[], a standalone Gradle project — see <>.
== Android
There is no Android artifact. Android consumes the JVM one — the library is plain Kotlin/JVM with no manifest, resources, or Android APIs, and it is compiled to Java 11 bytecode so D8 can read it:
This is not a tested target. What follows is what the code does, not a support claim.
=== Supply an Android HTTP engine
HalHttpClient takes the engine as a constructor argument, so nothing forces you onto the JVM
default (Ktor CIO):
import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp
Pass your own client and defaultHttpClient() is never called, so CIO is never loaded. It still
arrives as a transitive dependency; R8 strips it once nothing references it. Excluding it outright
works too, but then constructing HalHttpClient() with no argument fails at runtime:
=== Keep rules for plugin discovery
Plugins are found with java.util.ServiceLoader, which works on Android, but R8 removes
implementations it cannot see referenced. If you ship a plugin, keep it and its no-arg
constructor:
Or skip discovery altogether and hand the plugin over directly — no keep rules needed:
=== Limitation: no plugin loading from a file
The HALDISH_PLUGIN_PATH environment variable, which loads a plugin JAR through
java.net.URLClassLoader, does not work on Android — ART executes DEX, not .class files, so
loading an external JAR would need DexClassLoader. The variable is never set on Android in
practice, so discovery simply falls through to ServiceLoader and then to the no-op plugin;
nothing fails. Use pluginOverride or a bundled ServiceLoader implementation instead.
== JavaScript (Node.js)
=== From Maven Central (Kotlin/JS projects)
A Kotlin/JS project takes the dependency and needs no local build of this repository:
=== From npm (plain JavaScript)
The package ships a facade that flattens the compiled output's Kotlin package namespaces, so both
entry styles resolve, and hand-written index.d.ts declarations give TypeScript consumers types.
Central's haldish-js-2.1.0.klib is Kotlin-compiler input only — the npm package is what plain
JavaScript consumes.
Publishing is wired through dev.petuska.npm.publish:
=== From source (plain JavaScript)
To build the library directly rather than installing the package:
./gradlew jsNodeProductionLibraryDistribution
CAUTION: The JS target currently emits UMD, not ES modules, so exports arrive nested under
their Kotlin package rather than as flat named exports. useEsModules() cannot be enabled yet —
see "Known limitation" below.
const { JsHalClient, JsHeaders, JsMultipartPart } = require('./build/compileSync/js/main/productionLibrary/kotlin/haldish.js') .com.helpchoice.nahal.haldish;
const client = new JsHalClient();
// All HTTP methods return Promise ({ statusCode, body }) const resp = await client.get('https://api.example.com/'); const root = JSON.parse(resp.body); const tmpl = root._links.projects.href;
// URI template expansion (builder pattern) client.varsAdd('name', 'Project A'); client.varsAdd('name', 'Project B'); client.varsSet('name_op', 'in'); const url = client.expandVars(tmpl); // expands and resets the builder
// Custom headers const headers = new JsHeaders() .set('Authorization', 'Bearer token') .set('Accept', 'application/hal+xml'); const r = await client.get(url, headers);
// File upload (bytes must be Int8Array) await client.postFile(url, int8Array, 'image/jpeg');
// Multipart const parts = [ new JsMultipartPart('photo', int8Array, 'image/jpeg', 'photo.jpg'), new JsMultipartPart('metadata', metaBytes, 'application/json'), ]; await client.postMultipart(url, parts);
Full example: link:src/examples/javascript/crud-example.mjs[], a standalone Gradle project — see
<>. It imports haldish as an ES module; the package's index.mjs facade re-exports
the UMD output, so this works today despite the limitation below.
=== Known limitation: no ES module output
Enabling useEsModules() on the js(IR) target would give flat named exports, .mjs files and
a cleaner .d.ts — but it currently breaks every JS test task. Under ES modules the compiler
emits xmlutil-core's eleven identically named top-level function appendChild(node) declarations
into a single module scope. That is legal inside the UMD wrapper's function scope and a
SyntaxError in an ES module, so both jsNodeTest and jsBrowserTest fail to parse the test
bundle. Release output only escapes it because dead-code elimination drops those functions.
Reproduced on Kotlin 2.1.20 and 2.2.21. Per-file output granularity swaps the failure for a stdlib metadata-initialisation crash; xmlutil 1.0.2 does not compile against the current parser sources. Until one of those paths opens, the JS target stays on UMD.
== WebAssembly (Browser)
=== From Maven Central (Kotlin/Wasm projects)
kotlin { @OptIn(ExperimentalWasmDsl::class) wasmJs { browser() }
sourceSets {
wasmJsMain.dependencies {
implementation("com.helpchoice.nahal:haldish:2.1.0")
}
}
Note that YamlHalParser is a stub on Wasm — kaml is unavailable there, so application/hal+yaml
responses are not parsed.
=== From npm (plain JavaScript)
Shipped as a package of its own rather than an entry point of haldish: the two APIs differ
(classes vs. module-level functions), so no exports condition could swap them transparently, and
bundling them would push the browser-only 622 KB .wasm onto every Node consumer.
=== From source (plain JavaScript)
To build the .mjs and its .wasm payload directly:
./gradlew wasmJsBrowserProductionLibraryDistribution
Unlike the JS target, Kotlin/Wasm always emits ES modules, so these import normally.
The Wasm module must be served over HTTP (not file://) because browsers block Wasm loading from local files:
./gradlew -p src/examples/wasm serve
Kotlin/Wasm restricts @JsExport to functions only (classes are not supported), so the Wasm API is exposed as module-level functions with shared state — the same pattern as the native C API.
After awaiting any HTTP call, read the result with wasmLastStatus() and wasmLastBody().
The generated .mjs uses top-level await to initialise the Wasm runtime; all exports are ready as soon as the import resolves.
import { wasmGet, wasmPost, wasmPatch, wasmDelete, wasmPostFile, wasmPartAdd, wasmPartsClear, wasmPostMultipart, wasmLastStatus, wasmLastBody, wasmHeaderSet, wasmHeadersClear, wasmVarsSet, wasmVarsAdd, wasmExpandVars, wasmClose, } from './build/compileSync/wasmJs/main/productionLibrary/kotlin/haldish-wasm-js.mjs';
// GET — read result after awaiting await wasmGet('https://api.example.com/'); const root = JSON.parse(wasmLastBody()); const tmpl = root._links.projects.href;
// URI template expansion (builder pattern, same as JS) wasmVarsAdd('name', 'Project A'); wasmVarsAdd('name', 'Project B'); wasmVarsSet('name_op', 'in'); const url = wasmExpandVars(tmpl); // expands and resets the builder
// Custom headers (consumed and cleared after the next HTTP call) wasmHeaderSet('Authorization', 'Bearer token'); wasmHeaderSet('Accept', 'application/hal+xml'); await wasmGet(url);
// File upload (bytes as Int8Array) await wasmPostFile(url, int8Array, 'image/jpeg');
// Multipart wasmPartAdd('photo', int8Array, 'image/jpeg', 'photo.jpg'); wasmPartAdd('metadata', metaBytes, 'application/json', null); await wasmPostMultipart(url);
Full example: link:src/examples/wasm/crud-example.html[], a standalone Gradle project — see <>.
== Swift (iOS / macOS)
Maven Central carries .klib files for the Apple targets — Kotlin-compiler input only. A Swift or
Obj-C app consumes the XCFramework instead, distributed through Swift Package Manager.
=== From Swift Package Manager
Or in Xcode: File → Add Package Dependencies… and paste the repository URL.
The package vends one product, Haldish, backed by a binaryTarget that SPM downloads from the
GitHub Release for the matching tag. Slices: iOS device (arm64), iOS simulator (arm64 + x86_64),
macOS (arm64 + x86_64). Deployment floors are iOS 12.0 and macOS 11.0. The framework is static, so
no embed-and-sign step is needed.
import Haldish
Kotlin names reach Swift through the generated Obj-C header, so the shapes differ from the Kotlin
API: the Haldish prefix is stripped by the swift_name attributes, overloads gain argument
labels (expand(vars:) vs expand(pairs:)), and companion stands in for a Kotlin companion
object. Haldish.framework/Headers/Haldish.h inside the XCFramework is the authoritative listing.
Kotlin suspend functions export as Obj-C completion-handler methods, which Swift re-imports as
async throws:
import Haldish
let client = HalHttpClient.companion.create() defer { client.close() }
let root = try await client.getHal(url: "http://localhost:8080", headers: [:]) let projects = root.link(rel: "projects")!
HalHttpClient.companion.create() is the Swift entry point — see the note in <> on why
HalHttpClient() is not available. Byte payloads cross as KotlinByteArray, which has to be
filled element by element; both example programs carry a Data ⇄ KotlinByteArray helper.
Runnable walkthroughs are in link:src/examples/swift[src/examples/swift], a standalone Swift
package: SimpleExample covers parsing, links, embedded resources, format detection and template
expansion with no server; CrudExample walks the same nine-step scenario as the Java, JavaScript
and Wasm examples.
=== Releasing a new XCFramework
SPM reads Package.swift from the git tag, not from the release assets, so the checksum has to
be committed before the tag is pushed:
./gradlew updateSwiftPackage # assemble + zip the XCFramework, write URL + SHA-256 git commit -am "Release 2.1.0" git tag -f v2.1.0 git push --follow-tags
publishXCFrameworkToGitHubRelease creates the release if the tag has none yet, replaces an
existing asset of the same name (re-runs are safe), and refuses to run until the tag is on
origin — GitHub would otherwise invent a tag at the default branch's head, pointing SPM at a
commit whose manifest carries a different checksum. The token needs repo (classic) or
Contents: read and write (fine-grained); GH_TOKEN and -PgithubToken=… also work.
Never hand-edit the url or checksum lines in Package.swift — a checksum that disagrees with
the uploaded zip makes SPM refuse to resolve the package. The zip is built with ditto rather
than a Gradle Zip task because the macOS slice is a versioned bundle whose symlinks a plain zip
would flatten.
== Native (C / C++)
=== From a GitHub Release
Maven Central carries .klib files for the native targets — Kotlin-compiler input only. A C or
C++ consumer needs the platform binary and the generated header, which ship as one zip per target
on the release for that version:
<target> is one of macosX64, macosArm64, linuxX64, mingwX64. Each zip unpacks to a flat
directory:
|=== | Target | Contents
| macosX64, macosArm64
| libhaldish.dylib, libhaldish_api.h
| linuxX64
| libhaldish.so, libhaldish_api.h
| mingwX64
| haldish.dll, libhaldish.dll.a (import library), haldish.def, libhaldish_api.h
|===
The header is called libhaldish_api.h in every zip. Kotlin/Native names it after the output
file, so a Windows build produces haldish_api.h — the packaging step renames it so one
#include works everywhere.
linuxArm64 is published as a klib only; the build declares no sharedLib binary for it.
=== From source
./gradlew linkReleaseSharedMacosArm64
./gradlew linkReleaseSharedLinuxX64
Output (e.g., macOS arm64):
Note that a from-source Windows build leaves the header named haldish_api.h.
=== Compile and link
On Windows, -lhaldish resolves the import library libhaldish.dll.a, which the mingw link step
emits alongside the DLL. PE has no rpath, so haldish.dll must sit next to the executable or on
PATH at run time. MSVC users can turn the bundled haldish.def into a .lib:
=== Cutting a release
Kotlin/Native cross-compiles the Linux and Windows binaries from macOS, but Apple targets only
build on a Mac, so the full set has to be cut there — publishNativeLibsToGitHubRelease refuses
to run on a host that cannot link all four.
A release these tasks create starts as a draft and is published only once every asset has
uploaded. Otherwise there is a window — five assets, roughly 30 MB — in which the release is
public but empty, and a consumer resolving the tag in that window gets a 404 on the zip it needs.
Prefer publishBinariesToGitHubRelease over running the two tasks in sequence: each publishes the
draft when it finishes, so the first would make the release visible while the second is still
uploading.
-PkeepDraft leaves the release unpublished, to look over before it goes public. A draft left
behind by an interrupted run is picked up and finished by the next run.
The C API (NativeCApi.kt) exposes functions via @CName:
// HTTP const char* haldish_get(const char* url); const char* haldish_post_json(const char* url, const char* json); const char* haldish_patch_json(const char* url, const char* json); int haldish_delete(const char* url); const char* haldish_post_file(const char* url, int8_t* data, int size, const char* contentType);
// Last-response metadata (read after any HTTP call) int haldish_last_status(); const char* haldish_last_content_type();
// Request headers (consumed on the next HTTP call) void haldish_headers_set(const char* key, const char* value); void haldish_headers_clear();
// Multipart builder void haldish_part_add(const char* name, int8_t* data, int size, const char* contentType, const char* fileName); void haldish_parts_clear(); const char* haldish_post_multipart(const char* url);
// HAL navigation (parse body, extract links) const char* haldish_link_href(const char* body, const char* contentType, const char* rel); const char* haldish_first_embedded_self(const char* body, const char* contentType, const char* embeddedRel);
// URI template expansion const char* haldish_expand(const char* tmpl); // no variables void haldish_vars_set(const char* key, const char* value); void haldish_vars_add(const char* key, const char* value); void haldish_vars_reset(); const char* haldish_expand_vars(const char* tmpl); // expand + reset builder
Full example: link:src/examples/cpp/crud_example.cpp[], a standalone Gradle project that runs the compile-and-link above for you — see <>.
== HAL Format Detection
When no Content-Type header is present, the parser sniffs the body:
|=== | Starts with | Detected format
| { or [
| JSON
| <
| XML
| anything else (non-empty) | YAML |===
Explicit content types take precedence: application/hal+json, application/json, application/hal+xml, application/xml, text/xml, application/hal+yaml, application/yaml, text/yaml.
== Building
./gradlew build
./gradlew jvmTest
== Examples
Every directory under src/examples is a complete Gradle project with its own
settings.gradle.kts, deliberately not part of this build. Each one consumes HALDiSh the way a
real consumer does — from Maven Central or npm — so copying a directory elsewhere leaves a
working project, and nothing here depends on the library's build tree.
|=== | Project | Consumes | Run
| link:src/examples/java[java]
| com.helpchoice.nahal:haldish-jvm from Maven Central
| gradle run
| link:src/examples/javascript[javascript]
| haldish from npm
| gradle runSimple / gradle runCrud
| link:src/examples/wasm[wasm]
| haldish-wasm from npm
| gradle serve, then open the page
| link:src/examples/swift[swift]
| Haldish.xcframework
| gradle runSimple / gradle runCrud
| link:src/examples/cpp[cpp]
| libhaldish + generated C header
| gradle runSimple / gradle runCrud
|===
Inside this repository the library's own wrapper drives them, so nothing extra has to be installed — Node is downloaded by the JS and Wasm builds:
The simple examples make no HTTP calls and assert their results; the crud ones walk a
nine-step scenario against a HAL server on http://localhost:8080 — see
https://github.com/C06A/MockingHAL[MockingHAL].
Each project pins the version it resolves in its gradle.properties, defaulting to the newest
release actually available in that channel. Override it to build against another:
For a version not yet on Maven Central, publish it locally first — the example's settings list
mavenLocal() ahead of mavenCentral():
Two examples are exceptions to "resolves from a registry", both because they consume a binary that no registry carries — they take it from a GitHub Release instead, falling back to building it from this checkout until one is published.
The C++ one resolves haldish-<target>-<version>.zip through an Ivy repository pointed at the
release URL, so Gradle's dependency cache handles it like any other artifact:
haldishVersion is empty in its gradle.properties today, because no release carries those
assets yet; setting it there makes the download the default. haldishReleaseUrl overrides the
root the assets hang off, for a mirror or a file:// tree.
The Swift one assembles the XCFramework from this checkout and symlinks it next to its
Package.swift, or takes an existing one with -PhaldishXCFramework=…; once a release carries
the zip, a consumer skips all of that and declares the published package instead.
Both spend several minutes in Kotlin/Native whenever they fall back to building.
Swift constructs the client through HalHttpClient.companion.create() rather than
HalHttpClient(): Kotlin's default argument httpClient = defaultHttpClient() does not survive
the Obj-C export — Obj-C has no default arguments — so the generated initializer demands a Ktor
HttpClient, which in turn demands an HttpClientEngine that the framework's Obj-C surface never
exposes. create() closes that hole.
== Related Tools
== License
link:https://www.apache.org/licenses/LICENSE-2.0[Apache License 2.0]
= HALDiSh — Kotlin Multiplatform HAL client :toc: left :toclevels: 3 :icons: font :source-highlighter: highlight.js
image:https://img.shields.io/maven-central/v/com.helpchoice.nahal/haldish.svg?label=Maven%20Central[Maven Central,link=https://central.sonatype.com/artifact/com.helpchoice.nahal/haldish] image:https://img.shields.io/badge/License-Apache%202.0-blue.svg[License,link=https://www.apache.org/licenses/LICENSE-2.0]
Haldish is a https://stateless.group/hal_specification.html[HAL (Hypertext Application Language)] client library for https://kotlinlang.org/docs/multiplatform.html[Kotlin Multiplatform]. It handles HAL document parsing, RFC 6570 URI template expansion, and HTTP communication on every supported platform.
This repository holds the library alone. The NaHAL navigator built on it — :core, the Compose
UI, the example plugins and the testkit — lives in ../nahal and consumes this project as the
published artifact com.helpchoice.nahal:haldish.
== Supported Platforms
|=== | Target | HTTP engine
| JVM | Ktor CIO
| JS (IR) — browser + Node.js | ktor-client-js
| Wasm/JS — browser | ktor-client-js
| macOS x64 / arm64 | Ktor Darwin
| iOS x64 / arm64 / Simulator arm64 | Ktor Darwin
| Linux x64 / arm64 | Ktor cURL
| Windows (mingwX64) | Ktor WinHTTP |===
YAML HAL format (application/hal+yaml) is supported on JVM and JS only. On native and Wasm targets, YAML responses are not parsed.
Android is not a build target, but the JVM artifact is consumable there — see <>.
== Dependency
Released on Maven Central as com.helpchoice.nahal:haldish. Current version: 2.1.0.
Two other channels carry the same release for consumers that cannot read Maven artifacts: npm,
as haldish and haldish-wasm (see <<JavaScript (Node.js)>> and <<WebAssembly (Browser)>>), and
Swift Package Manager, via the XCFramework attached to the GitHub Release for the version's tag
(see <<Swift (iOS / macOS)>>).
In a Kotlin Multiplatform project, declare it once in commonMain — Gradle module metadata
resolves the right per-target artifact (haldish-jvm, haldish-js, haldish-macosarm64, …)
for every target you build:
// build.gradle.kts repositories { mavenCentral() }
For a plain JVM (or Android) project, the same coordinates work — Gradle picks haldish-jvm:
Maven has no support for Gradle module metadata, so name the JVM artifact explicitly:
com.helpchoice.nahal haldish-jvm 2.1.0 ----Snapshots are not published. To try unreleased changes, build from source and install into the local Maven repository:
== Quick Start (Kotlin)
import com.helpchoice.nahal.haldish.http.HalHttpClient import com.helpchoice.nahal.haldish.uritemplate.expandHref
// HalHttpClient wraps a Ktor HttpClient and adds HAL-aware helpers.
// It implements AutoCloseable — use use or call close() when done.
val client = HalHttpClient()
// GET a HAL document (sends Accept: application/hal+json, hal+xml, hal+yaml, …) val root: HalDocument = client.getHal("https://api.example.com/")
// Navigate links val self: HalLink? = root.link("self") // first link for rel val admins: List = root.links("admin") // all links for rel
// Read embedded resources val orders: List = root.embedded("ea:order")
// Read plain properties val count: JsonElement? = root.properties["currentlyProcessing"]
=== URI Template Expansion
HAL links can be https://www.rfc-editor.org/rfc/rfc6570[RFC 6570 URI templates].
Use expandHref or UriTemplate to expand them before making a request.
import com.helpchoice.nahal.haldish.uritemplate.UriTemplate import com.helpchoice.nahal.haldish.uritemplate.UriTemplateVars import com.helpchoice.nahal.haldish.uritemplate.expandHref
// Scalar variable val link = root.link("search")!! // href: "/items{?q,page}" val url = link.expandHref("q" to "hal", "page" to 2) // → /items?q=hal&page=2
// List variable val vars = UriTemplateVars() .add("name", "Project A") .add("name", "Project B") .set("name_op", "in") val filtered = UriTemplate("/items{?name*,name_op}").expand(vars) // → /items?name=Project%20A&name=Project%20B&name_op=in
UriTemplateVars supports three value types:
set(name, scalar) — single string valueset(name, list) / add(name, value) — ordered listset(name, map) / put(name, field, value) — associative map=== Making Requests
import com.helpchoice.nahal.haldish.http.HalHttpClient import com.helpchoice.nahal.haldish.http.HalRequestBody
val client = HalHttpClient()
// Raw HTTP — returns HalHttpResponse (statusCode, headers, cookies, body, contentType) val response = client.get(url) val created = client.post(url, HalRequestBody.Json("""{"name":"foo"}""")) val updated = client.patch(url, HalRequestBody.Json("""{"name":"bar"}""")) val deleted = client.delete(url)
// File upload val bytes: ByteArray = java.io.File("photo.jpg").readBytes() client.post(url, HalRequestBody.Binary(bytes, "image/jpeg"))
// Multipart client.post(url, HalRequestBody.Multipart(listOf( MultipartPart("photo", photoBytes, fileName = "photo.jpg", contentType = "image/jpeg"), MultipartPart("metadata", metaBytes, contentType = "application/json"), )))
// Custom headers and cookies client.get(url, headers = mapOf("Authorization" to "Bearer token"), cookies = mapOf("session" to "abc123"), )
=== Error Handling
All exceptions extend HaldishException:
== Java
HalHttpClient is usable from Java. Kotlin suspend functions are called via BuildersKt.runBlocking:
try (HalHttpClient client = new HalHttpClient()) { HalDocument root = (HalDocument) BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, (scope, cont) -> client.getHal("https://api.example.com/", Map.of(), cont) ); String href = root.link("projects").getHref();
UriTemplateVars vars = new UriTemplateVars()
.set("name", Arrays.asList("Project A", "Project B"))
.set("name_op", "in");
String url = new UriTemplate(href).expand(vars);
A full walkthrough is in link:src/examples/java/src/main/java/CrudExample.java[], a standalone Gradle project — see <>.
== Android
There is no Android artifact. Android consumes the JVM one — the library is plain Kotlin/JVM with no manifest, resources, or Android APIs, and it is compiled to Java 11 bytecode so D8 can read it:
This is not a tested target. What follows is what the code does, not a support claim.
=== Supply an Android HTTP engine
HalHttpClient takes the engine as a constructor argument, so nothing forces you onto the JVM
default (Ktor CIO):
import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp
Pass your own client and defaultHttpClient() is never called, so CIO is never loaded. It still
arrives as a transitive dependency; R8 strips it once nothing references it. Excluding it outright
works too, but then constructing HalHttpClient() with no argument fails at runtime:
=== Keep rules for plugin discovery
Plugins are found with java.util.ServiceLoader, which works on Android, but R8 removes
implementations it cannot see referenced. If you ship a plugin, keep it and its no-arg
constructor:
Or skip discovery altogether and hand the plugin over directly — no keep rules needed:
=== Limitation: no plugin loading from a file
The HALDISH_PLUGIN_PATH environment variable, which loads a plugin JAR through
java.net.URLClassLoader, does not work on Android — ART executes DEX, not .class files, so
loading an external JAR would need DexClassLoader. The variable is never set on Android in
practice, so discovery simply falls through to ServiceLoader and then to the no-op plugin;
nothing fails. Use pluginOverride or a bundled ServiceLoader implementation instead.
== JavaScript (Node.js)
=== From Maven Central (Kotlin/JS projects)
A Kotlin/JS project takes the dependency and needs no local build of this repository:
=== From npm (plain JavaScript)
The package ships a facade that flattens the compiled output's Kotlin package namespaces, so both
entry styles resolve, and hand-written index.d.ts declarations give TypeScript consumers types.
Central's haldish-js-2.1.0.klib is Kotlin-compiler input only — the npm package is what plain
JavaScript consumes.
Publishing is wired through dev.petuska.npm.publish:
=== From source (plain JavaScript)
To build the library directly rather than installing the package:
./gradlew jsNodeProductionLibraryDistribution
CAUTION: The JS target currently emits UMD, not ES modules, so exports arrive nested under
their Kotlin package rather than as flat named exports. useEsModules() cannot be enabled yet —
see "Known limitation" below.
const { JsHalClient, JsHeaders, JsMultipartPart } = require('./build/compileSync/js/main/productionLibrary/kotlin/haldish.js') .com.helpchoice.nahal.haldish;
const client = new JsHalClient();
// All HTTP methods return Promise ({ statusCode, body }) const resp = await client.get('https://api.example.com/'); const root = JSON.parse(resp.body); const tmpl = root._links.projects.href;
// URI template expansion (builder pattern) client.varsAdd('name', 'Project A'); client.varsAdd('name', 'Project B'); client.varsSet('name_op', 'in'); const url = client.expandVars(tmpl); // expands and resets the builder
// Custom headers const headers = new JsHeaders() .set('Authorization', 'Bearer token') .set('Accept', 'application/hal+xml'); const r = await client.get(url, headers);
// File upload (bytes must be Int8Array) await client.postFile(url, int8Array, 'image/jpeg');
// Multipart const parts = [ new JsMultipartPart('photo', int8Array, 'image/jpeg', 'photo.jpg'), new JsMultipartPart('metadata', metaBytes, 'application/json'), ]; await client.postMultipart(url, parts);
Full example: link:src/examples/javascript/crud-example.mjs[], a standalone Gradle project — see
<>. It imports haldish as an ES module; the package's index.mjs facade re-exports
the UMD output, so this works today despite the limitation below.
=== Known limitation: no ES module output
Enabling useEsModules() on the js(IR) target would give flat named exports, .mjs files and
a cleaner .d.ts — but it currently breaks every JS test task. Under ES modules the compiler
emits xmlutil-core's eleven identically named top-level function appendChild(node) declarations
into a single module scope. That is legal inside the UMD wrapper's function scope and a
SyntaxError in an ES module, so both jsNodeTest and jsBrowserTest fail to parse the test
bundle. Release output only escapes it because dead-code elimination drops those functions.
Reproduced on Kotlin 2.1.20 and 2.2.21. Per-file output granularity swaps the failure for a stdlib metadata-initialisation crash; xmlutil 1.0.2 does not compile against the current parser sources. Until one of those paths opens, the JS target stays on UMD.
== WebAssembly (Browser)
=== From Maven Central (Kotlin/Wasm projects)
kotlin { @OptIn(ExperimentalWasmDsl::class) wasmJs { browser() }
sourceSets {
wasmJsMain.dependencies {
implementation("com.helpchoice.nahal:haldish:2.1.0")
}
}
Note that YamlHalParser is a stub on Wasm — kaml is unavailable there, so application/hal+yaml
responses are not parsed.
=== From npm (plain JavaScript)
Shipped as a package of its own rather than an entry point of haldish: the two APIs differ
(classes vs. module-level functions), so no exports condition could swap them transparently, and
bundling them would push the browser-only 622 KB .wasm onto every Node consumer.
=== From source (plain JavaScript)
To build the .mjs and its .wasm payload directly:
./gradlew wasmJsBrowserProductionLibraryDistribution
Unlike the JS target, Kotlin/Wasm always emits ES modules, so these import normally.
The Wasm module must be served over HTTP (not file://) because browsers block Wasm loading from local files:
./gradlew -p src/examples/wasm serve
Kotlin/Wasm restricts @JsExport to functions only (classes are not supported), so the Wasm API is exposed as module-level functions with shared state — the same pattern as the native C API.
After awaiting any HTTP call, read the result with wasmLastStatus() and wasmLastBody().
The generated .mjs uses top-level await to initialise the Wasm runtime; all exports are ready as soon as the import resolves.
import { wasmGet, wasmPost, wasmPatch, wasmDelete, wasmPostFile, wasmPartAdd, wasmPartsClear, wasmPostMultipart, wasmLastStatus, wasmLastBody, wasmHeaderSet, wasmHeadersClear, wasmVarsSet, wasmVarsAdd, wasmExpandVars, wasmClose, } from './build/compileSync/wasmJs/main/productionLibrary/kotlin/haldish-wasm-js.mjs';
// GET — read result after awaiting await wasmGet('https://api.example.com/'); const root = JSON.parse(wasmLastBody()); const tmpl = root._links.projects.href;
// URI template expansion (builder pattern, same as JS) wasmVarsAdd('name', 'Project A'); wasmVarsAdd('name', 'Project B'); wasmVarsSet('name_op', 'in'); const url = wasmExpandVars(tmpl); // expands and resets the builder
// Custom headers (consumed and cleared after the next HTTP call) wasmHeaderSet('Authorization', 'Bearer token'); wasmHeaderSet('Accept', 'application/hal+xml'); await wasmGet(url);
// File upload (bytes as Int8Array) await wasmPostFile(url, int8Array, 'image/jpeg');
// Multipart wasmPartAdd('photo', int8Array, 'image/jpeg', 'photo.jpg'); wasmPartAdd('metadata', metaBytes, 'application/json', null); await wasmPostMultipart(url);
Full example: link:src/examples/wasm/crud-example.html[], a standalone Gradle project — see <>.
== Swift (iOS / macOS)
Maven Central carries .klib files for the Apple targets — Kotlin-compiler input only. A Swift or
Obj-C app consumes the XCFramework instead, distributed through Swift Package Manager.
=== From Swift Package Manager
Or in Xcode: File → Add Package Dependencies… and paste the repository URL.
The package vends one product, Haldish, backed by a binaryTarget that SPM downloads from the
GitHub Release for the matching tag. Slices: iOS device (arm64), iOS simulator (arm64 + x86_64),
macOS (arm64 + x86_64). Deployment floors are iOS 12.0 and macOS 11.0. The framework is static, so
no embed-and-sign step is needed.
import Haldish
Kotlin names reach Swift through the generated Obj-C header, so the shapes differ from the Kotlin
API: the Haldish prefix is stripped by the swift_name attributes, overloads gain argument
labels (expand(vars:) vs expand(pairs:)), and companion stands in for a Kotlin companion
object. Haldish.framework/Headers/Haldish.h inside the XCFramework is the authoritative listing.
Kotlin suspend functions export as Obj-C completion-handler methods, which Swift re-imports as
async throws:
import Haldish
let client = HalHttpClient.companion.create() defer { client.close() }
let root = try await client.getHal(url: "http://localhost:8080", headers: [:]) let projects = root.link(rel: "projects")!
HalHttpClient.companion.create() is the Swift entry point — see the note in <> on why
HalHttpClient() is not available. Byte payloads cross as KotlinByteArray, which has to be
filled element by element; both example programs carry a Data ⇄ KotlinByteArray helper.
Runnable walkthroughs are in link:src/examples/swift[src/examples/swift], a standalone Swift
package: SimpleExample covers parsing, links, embedded resources, format detection and template
expansion with no server; CrudExample walks the same nine-step scenario as the Java, JavaScript
and Wasm examples.
=== Releasing a new XCFramework
SPM reads Package.swift from the git tag, not from the release assets, so the checksum has to
be committed before the tag is pushed:
./gradlew updateSwiftPackage # assemble + zip the XCFramework, write URL + SHA-256 git commit -am "Release 2.1.0" git tag -f v2.1.0 git push --follow-tags
publishXCFrameworkToGitHubRelease creates the release if the tag has none yet, replaces an
existing asset of the same name (re-runs are safe), and refuses to run until the tag is on
origin — GitHub would otherwise invent a tag at the default branch's head, pointing SPM at a
commit whose manifest carries a different checksum. The token needs repo (classic) or
Contents: read and write (fine-grained); GH_TOKEN and -PgithubToken=… also work.
Never hand-edit the url or checksum lines in Package.swift — a checksum that disagrees with
the uploaded zip makes SPM refuse to resolve the package. The zip is built with ditto rather
than a Gradle Zip task because the macOS slice is a versioned bundle whose symlinks a plain zip
would flatten.
== Native (C / C++)
=== From a GitHub Release
Maven Central carries .klib files for the native targets — Kotlin-compiler input only. A C or
C++ consumer needs the platform binary and the generated header, which ship as one zip per target
on the release for that version:
<target> is one of macosX64, macosArm64, linuxX64, mingwX64. Each zip unpacks to a flat
directory:
|=== | Target | Contents
| macosX64, macosArm64
| libhaldish.dylib, libhaldish_api.h
| linuxX64
| libhaldish.so, libhaldish_api.h
| mingwX64
| haldish.dll, libhaldish.dll.a (import library), haldish.def, libhaldish_api.h
|===
The header is called libhaldish_api.h in every zip. Kotlin/Native names it after the output
file, so a Windows build produces haldish_api.h — the packaging step renames it so one
#include works everywhere.
linuxArm64 is published as a klib only; the build declares no sharedLib binary for it.
=== From source
./gradlew linkReleaseSharedMacosArm64
./gradlew linkReleaseSharedLinuxX64
Output (e.g., macOS arm64):
Note that a from-source Windows build leaves the header named haldish_api.h.
=== Compile and link
On Windows, -lhaldish resolves the import library libhaldish.dll.a, which the mingw link step
emits alongside the DLL. PE has no rpath, so haldish.dll must sit next to the executable or on
PATH at run time. MSVC users can turn the bundled haldish.def into a .lib:
=== Cutting a release
Kotlin/Native cross-compiles the Linux and Windows binaries from macOS, but Apple targets only
build on a Mac, so the full set has to be cut there — publishNativeLibsToGitHubRelease refuses
to run on a host that cannot link all four.
A release these tasks create starts as a draft and is published only once every asset has
uploaded. Otherwise there is a window — five assets, roughly 30 MB — in which the release is
public but empty, and a consumer resolving the tag in that window gets a 404 on the zip it needs.
Prefer publishBinariesToGitHubRelease over running the two tasks in sequence: each publishes the
draft when it finishes, so the first would make the release visible while the second is still
uploading.
-PkeepDraft leaves the release unpublished, to look over before it goes public. A draft left
behind by an interrupted run is picked up and finished by the next run.
The C API (NativeCApi.kt) exposes functions via @CName:
// HTTP const char* haldish_get(const char* url); const char* haldish_post_json(const char* url, const char* json); const char* haldish_patch_json(const char* url, const char* json); int haldish_delete(const char* url); const char* haldish_post_file(const char* url, int8_t* data, int size, const char* contentType);
// Last-response metadata (read after any HTTP call) int haldish_last_status(); const char* haldish_last_content_type();
// Request headers (consumed on the next HTTP call) void haldish_headers_set(const char* key, const char* value); void haldish_headers_clear();
// Multipart builder void haldish_part_add(const char* name, int8_t* data, int size, const char* contentType, const char* fileName); void haldish_parts_clear(); const char* haldish_post_multipart(const char* url);
// HAL navigation (parse body, extract links) const char* haldish_link_href(const char* body, const char* contentType, const char* rel); const char* haldish_first_embedded_self(const char* body, const char* contentType, const char* embeddedRel);
// URI template expansion const char* haldish_expand(const char* tmpl); // no variables void haldish_vars_set(const char* key, const char* value); void haldish_vars_add(const char* key, const char* value); void haldish_vars_reset(); const char* haldish_expand_vars(const char* tmpl); // expand + reset builder
Full example: link:src/examples/cpp/crud_example.cpp[], a standalone Gradle project that runs the compile-and-link above for you — see <>.
== HAL Format Detection
When no Content-Type header is present, the parser sniffs the body:
|=== | Starts with | Detected format
| { or [
| JSON
| <
| XML
| anything else (non-empty) | YAML |===
Explicit content types take precedence: application/hal+json, application/json, application/hal+xml, application/xml, text/xml, application/hal+yaml, application/yaml, text/yaml.
== Building
./gradlew build
./gradlew jvmTest
== Examples
Every directory under src/examples is a complete Gradle project with its own
settings.gradle.kts, deliberately not part of this build. Each one consumes HALDiSh the way a
real consumer does — from Maven Central or npm — so copying a directory elsewhere leaves a
working project, and nothing here depends on the library's build tree.
|=== | Project | Consumes | Run
| link:src/examples/java[java]
| com.helpchoice.nahal:haldish-jvm from Maven Central
| gradle run
| link:src/examples/javascript[javascript]
| haldish from npm
| gradle runSimple / gradle runCrud
| link:src/examples/wasm[wasm]
| haldish-wasm from npm
| gradle serve, then open the page
| link:src/examples/swift[swift]
| Haldish.xcframework
| gradle runSimple / gradle runCrud
| link:src/examples/cpp[cpp]
| libhaldish + generated C header
| gradle runSimple / gradle runCrud
|===
Inside this repository the library's own wrapper drives them, so nothing extra has to be installed — Node is downloaded by the JS and Wasm builds:
The simple examples make no HTTP calls and assert their results; the crud ones walk a
nine-step scenario against a HAL server on http://localhost:8080 — see
https://github.com/C06A/MockingHAL[MockingHAL].
Each project pins the version it resolves in its gradle.properties, defaulting to the newest
release actually available in that channel. Override it to build against another:
For a version not yet on Maven Central, publish it locally first — the example's settings list
mavenLocal() ahead of mavenCentral():
Two examples are exceptions to "resolves from a registry", both because they consume a binary that no registry carries — they take it from a GitHub Release instead, falling back to building it from this checkout until one is published.
The C++ one resolves haldish-<target>-<version>.zip through an Ivy repository pointed at the
release URL, so Gradle's dependency cache handles it like any other artifact:
haldishVersion is empty in its gradle.properties today, because no release carries those
assets yet; setting it there makes the download the default. haldishReleaseUrl overrides the
root the assets hang off, for a mirror or a file:// tree.
The Swift one assembles the XCFramework from this checkout and symlinks it next to its
Package.swift, or takes an existing one with -PhaldishXCFramework=…; once a release carries
the zip, a consumer skips all of that and declares the published package instead.
Both spend several minutes in Kotlin/Native whenever they fall back to building.
Swift constructs the client through HalHttpClient.companion.create() rather than
HalHttpClient(): Kotlin's default argument httpClient = defaultHttpClient() does not survive
the Obj-C export — Obj-C has no default arguments — so the generated initializer demands a Ktor
HttpClient, which in turn demands an HttpClientEngine that the framework's Obj-C surface never
exposes. create() closes that hole.
== Related Tools
== License
link:https://www.apache.org/licenses/LICENSE-2.0[Apache License 2.0]