A Clojure client library for the OpenADR 3 API, providing spec-driven HTTP access to VTN (Virtual Top Node) servers.
Add to your deps.edn:
{:deps {energy.grid-coordination/clj-oa3 {:mvn/version "0.3.0"}}}
coerce-payload multimethod (dispatches on :type)┌───────────────────────────────────────────────────────┐
│ openadr3.api │
│ │
│ Raw functions: get-programs, create-event, ... │
│ → return {:status 200 :body {...camelCase...}} │
│ │
│ Coerced functions: programs, events, vens, ... │
│ → return [#:openadr{:id "..." :created #zdt ...}] │
│ └── each entity carries :openadr/raw metadata │
├───────────────────────────────────────────────────────┤
│ openadr3.entities │
│ │
│ ->program, ->event, ->ven, ->resource, ->report, │
│ ->subscription, ->interval, ->interval-period │
│ │
│ Multimethods: coerce (by objectType), │
│ coerce-payload (by payload type) │
├───────────────────────────────────────────────────────┤
│ openadr3.entities.schema │
│ Coerced Malli schemas: Program, Event, Ven, ... │
├───────────────────────────────────────────────────────┤
│ openadr3.entities.schema.raw │
│ Raw Malli schemas: Program, Event, Ven, ... │
├───────────────────────────────────────────────────────┤
│ Martian + Hato (HTTP) │
│ OpenADR 3 OpenAPI spec (YAML) │
└───────────────────────────────────────────────────────┘
The OpenAPI spec files (versions 3.0.0, 3.0.1, 3.1.0) are embedded under resources/openadr3-specification/. No sibling repo or symlink is needed — git clone gives you everything.
The specs are sourced from grid-coordination/openadr3-specification and are copyright the OpenADR Alliance, licensed under Apache 2.0. See resources/openadr3-specification/ORIGIN.md for details.
(require '[openadr3.api :as api])
;; Create authenticated clients (uses embedded 3.1.0 spec by default)
(def ven (api/create-ven-client "my-ven-token" "http://localhost:8080/openadr3/3.1.0"))
(def bl (api/create-bl-client "my-bl-token" "http://localhost:8080/openadr3/3.1.0"))
;; Each client can target a different spec version
(def ven-301 (api/create-ven-client "my-ven-token" url {:spec-version "3.0.1"}))
(def bl-300 (api/create-bl-client "my-bl-token" url {:spec-version "3.0.0"}))
;; Custom User-Agent (default: "clj-oa3/0.1.0 (mac=<hex>)" with hostname/unknown fallback)
(def ven (api/create-ven-client "token" url {:user-agent "my-app/1.0 (contact@example.com)"}))
(api/get-programs bl)
;; => {:status 200 :body [{:id "abc123" :programName "MyProgram" ...}]}
(api/create-program bl {:programName "MyProgram"})
(api/get-events ven)
(api/get-vens bl)
(api/success? (api/get-programs bl)) ;=> true
(api/body (api/get-programs bl)) ;=> [{:id "abc123" ...}]
(api/programs bl)
;; => [#:openadr{:id "abc123"
;; :created #time/zoned-date-time "2023-06-15T09:30:00Z"
;; :modified #time/zoned-date-time "2023-06-15T09:30:00Z"
;; :object-type :openadr.object-type/program}
;; #:openadr.program{:name "MyProgram"}]
(api/program bl "abc123") ;; single entity
(api/events ven)
(api/vens bl)
(api/reports bl)
(api/subscriptions bl)
;; Access the original raw data from any coerced entity
(-> (first (api/programs bl)) meta :openadr/raw)
;; => {:id "abc123" :programName "MyProgram" :objectType "PROGRAM" ...}
All timestamps become java.time.ZonedDateTime, zoned to the offset present on the wire (see Time and timezones below). Durations become java.time.Duration. When an IntervalPeriod has both start and duration, :tick/beginning and :tick/end are assoc'd directly on the entity map, making it immediately usable as a tick interval:
;; IntervalPeriod with tick interval keys
(:openadr.event/interval-period event)
;; => {:openadr.interval-period/start #time/zoned-date-time "2023-06-15T09:30:00Z"
;; :openadr.interval-period/duration #object[Duration "PT1H"]
;; :tick/beginning #time/zoned-date-time "2023-06-15T09:30:00Z"
;; :tick/end #time/zoned-date-time "2023-06-15T10:30:00Z"}
;; Re-zone to a named IANA zone (preserves the same instant; useful when
;; subsequent arithmetic should respect DST):
(require '[openadr3.entities :as entities])
(entities/->zoned (:openadr/created program)
(java.time.ZoneId/of "America/Los_Angeles"))
;; If you need an Instant, call .toInstant on the ZonedDateTime:
(.toInstant (:openadr/created program))
OpenADR 3 specifies RFC 3339 ISO 8601 datetimes on the wire — a profile of ISO 8601 that allows arbitrary numeric offsets, not just Z. Examples that are all valid OpenADR 3 wire values:
| Wire | Meaning |
|---|---|
2026-05-03T00:00:00Z | UTC, "Zulu" |
2026-05-03T00:00:00+00:00 | UTC, explicit offset |
2026-05-03T00:00:00-07:00 | Pacific Daylight Time |
2026-05-03T00:00:00+09:00 | Japan Standard Time |
Every datetime field on a coerced entity (:openadr/created, :openadr/modified, :openadr.interval-period/start, :tick/beginning, :tick/end) is a java.time.ZonedDateTime zoned to the offset present on the wire. No IANA zone name is available from the wire, so the ZonedDateTime is anchored to the numeric offset itself — equivalent to the behaviour of python-oa3's Pendulum-based parser.
(:openadr/created program)
;; => #time/zoned-date-time "2026-05-03T00:00-07:00"
;; Round-trips cleanly through ISO_OFFSET_DATE_TIME:
(.format (:openadr/created program)
java.time.format.DateTimeFormatter/ISO_OFFSET_DATE_TIME)
;; => "2026-05-03T00:00:00-07:00"
Why ZonedDateTime, not Instant? Earlier versions of this library returned java.time.Instant (UTC) and rejected non-Z wire forms because Java's Instant/parse is strict about the Z suffix. A spec-compliant VTN that publishes 2026-05-03T00:00:00-07:00 would crash the parser. Returning ZonedDateTime preserves the wire offset, accepts the full RFC 3339 grammar, and matches sister-implementation behaviour. Callers that want an Instant can call .toInstant on the value.
VTN-RI compatibility. The OpenADR Alliance VTN reference implementation has historically emitted a non-standard space-separated form (2026-05-03 00:00:00, no T, no offset). This library normalises that form by inserting T and assuming UTC, so VTN-RI responses continue to parse without complaint.
ValuesMap payloads are coerced via the coerce-payload multimethod, dispatching on the :type string:
;; Built-in: PRICE and USAGE convert values to BigDecimal
(entities/coerce-payload {:type "PRICE" :values [42.5 43.1]})
;; => #:openadr.payload{:type :openadr.payload-type/price
;; :values [42.5M 43.1M]}
;; Extend for custom payload types
(defmethod entities/coerce-payload "GRID_CARBON"
[raw]
(-> {:openadr.payload/type :openadr.payload-type/grid-carbon
:openadr.payload/values (mapv double (:values raw))}
(with-meta {:openadr/raw raw})))
;; Dispatches on :objectType string
(entities/coerce {:objectType "PROGRAM" :id "test" :programName "Foo" ...})
;; => namespaced Program entity
(entities/coerce {:objectType "EVENT" :id "test" :programID "p1" ...})
;; => namespaced Event entity
| Client | Type | Scopes |
|---|---|---|
| VEN | :ven | read_all, read_targets, read_ven_objects, write_reports, write_subscriptions, write_vens |
| BL | :bl | read_all, read_bl, write_programs, write_events, write_subscriptions, write_vens |
(api/client-type ven) ;=> :ven
(api/scopes ven) ;=> #{"read_all" "read_targets" ...}
;; Check if a client can call an endpoint
(api/authorized? (api/scopes ven) (api/endpoint-scopes ven :search-all-events))
| Function | Description |
|---|---|
spec-versions | Map of version string to classpath resource path |
spec-path | Resolve version string to spec file path (default 3.1.0) |
read-openapi-spec | Bootstrap Martian client from explicit spec file path |
create-ven-client | Create authenticated VEN client (opts: :spec-version, :http-client, :user-agent) |
create-bl-client | Create authenticated BL client (opts: :spec-version, :http-client, :user-agent) |
{:status :body})| Resource | List | Get by ID | Search | Create | Update | Delete | Find by Name |
|---|---|---|---|---|---|---|---|
| Programs | get-programs | get-program-by-id | search-programs | create-program | update-program | delete-program | find-program-by-name |
| Events | get-events | get-event-by-id | search-events | create-event | update-event | delete-event | — |
| VENs | get-vens | get-ven-by-id | — | create-ven | update-ven | delete-ven | find-ven-by-name |
| Resources | — | get-resource-by-id | search-ven-resources | create-resource | update-resource | delete-resource | — |
| Reports | get-reports | get-report-by-id | search-reports | create-report | update-report | delete-report | — |
| Subscriptions | get-subscriptions | get-subscription-by-id | search-subscriptions | create-subscription | update-subscription | delete-subscription | — |
:openadr/raw metadata)| Function | Returns |
|---|---|
programs / program | Program entities |
events / event | Event entities (with intervals + payloads) |
vens / ven | VEN entities |
reports / report | Report entities (with resources + intervals) |
subscriptions / subscription | Subscription entities |
| Function | Description |
|---|---|
success? | True if 2xx status |
body | Extract :body from response |
Topic discovery for all notifier endpoints. Functions follow the pattern get-mqtt-topics-*:
get-mqtt-topics-programs, get-mqtt-topics-program, get-mqtt-topics-program-events, get-mqtt-topics-ven, get-mqtt-topics-ven-programs, get-mqtt-topics-ven-events, get-mqtt-topics-ven-resources, get-mqtt-topics-events, get-mqtt-topics-reports, get-mqtt-topics-subscriptions, get-mqtt-topics-vens, get-mqtt-topics-resources
| Function | Description |
|---|---|
all-routes | List all route-name keywords |
get-handler | Get handler for a route-name |
endpoint-scopes | Get required scopes for an endpoint |
authorized? | Check if client scopes satisfy endpoint requirements |
get-unauthenticated-routes | List routes requiring no auth |
client-type | Get client type (:ven or :bl) |
scopes | Get client's OAuth2 scopes |
| Function | Description |
|---|---|
get-auth-server | Get auth server info |
get-token | Fetch OAuth2 token via client credentials |
| Function | Description |
|---|---|
->program, ->event, ->ven, ->resource, ->report, ->subscription | Coerce raw API map to namespaced entity |
->interval, ->interval-period | Coerce nested structures |
coerce | Generic multimethod (dispatches on :objectType) |
coerce-payload | Extensible multimethod (dispatches on payload :type) |
->zoned | Convert Instant to ZonedDateTime |
validate-raw-* | Malli validation for raw API maps |
Schemas live in dedicated namespaces, separate from the coercion machinery. Consumers can depend on schemas for validation, generative testing, or documentation without pulling in the transformation code.
openadr3.entities.schema ;; coerced entity schemas (the public contract)
openadr3.entities.schema.raw ;; raw API schemas (boundary validation)
openadr3.entities.schema — Coerced schemas describing the Clojure-native shape: namespaced keywords, ZonedDateTimes, Durations, tick intervals.
(require '[openadr3.entities.schema :as schema])
schema/Program ;; [:map [:openadr/id :string] [:openadr/created <ZonedDateTime>] ...]
schema/Event ;; [:map [:openadr.event/program-id :string] ...]
schema/Ven
schema/Resource
schema/Report
schema/Subscription
schema/Notification
schema/IntervalPeriod
schema/Interval
schema/Payload
openadr3.entities.schema.raw — Raw schemas mirroring the JSON shape exactly (camelCase keys, string values). Useful at the API boundary but rarely needed by downstream code.
(require '[openadr3.entities.schema.raw :as raw])
raw/Program ;; [:map [:id :string] [:programName :string] ...]
raw/Event
raw/Ven
raw/Resource
raw/Report
raw/Subscription
raw/Notification
raw/IntervalPeriod
raw/Interval
raw/ValuesMap
The validate-raw-* functions in openadr3.entities use the raw schemas for boundary validation:
(entities/validate-raw-program raw-map) ;; nil on success, Malli explanation on failure
clojure -M:nrepl
# nREPL port written to .nrepl-port
clojure -M:test
Uses Kaocha. Test suites: :api, :entities, :schema.
(def ven (api/create-ven-client "ven_token" vtn-url))
(def bl (api/create-bl-client "bl_token" vtn-url))
(api/programs bl) ;; coerced entities
(api/get-programs bl) ;; raw HTTP response
(-> (first (api/programs bl)) meta :openadr/raw) ;; round-trip
(sort (api/all-routes ven)) ;; 45 routes
| Library | Purpose |
|---|---|
| Martian | OpenAPI spec-driven HTTP client |
| Hato | HTTP client (Java 11+ HttpClient) |
| Malli | Schema validation |
| tick | Time intervals (Allen's interval algebra) |
| medley | Utility functions |
| camel-snake-kebab | Case conversion |
| Repo | Description |
|---|---|
| clj-oa3-client | Component lifecycle wrapper for constructing and managing OA3 clients |
| clj-oa3-test | OpenADR 3 integration tests |
Issues, Discussions, and pull requests are welcome — see CONTRIBUTING.md for the workflow (and the dev commands: tests, lint, nREPL). In short:
MIT License — Copyright (c) 2026 Clark Communications Corporation
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |