Liking cljdoc? Tell your friends :D

clj-oa3

Clojars Project md-docs build-provenance

A Clojure client library for the OpenADR 3 API, providing spec-driven HTTP access to VTN (Virtual Top Node) servers.

Installation

Add to your deps.edn:

{:deps {energy.grid-coordination/clj-oa3 {:mvn/version "0.3.0"}}}

Features

  • Spec-driven HTTP client built on Martian — the OpenAPI spec is the single source of truth
  • Two-layer data model — raw API responses (camelCase JSON) and coerced Clojure entities (namespaced keywords, ZonedDateTimes, Durations, tick intervals)
  • VEN and BL client types with OAuth2 scope metadata
  • Full CRUD for all OpenADR 3 resources: programs, events, VENs, resources, reports, subscriptions
  • MQTT topic discovery for all notifier endpoints
  • Extensible payload coercion via coerce-payload multimethod (dispatches on :type)
  • Malli schemas for both raw and coerced entity validation
  • Route introspection and scope-based authorization checks

Architecture

┌───────────────────────────────────────────────────────┐
│                  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)              │
└───────────────────────────────────────────────────────┘

OpenADR 3 Specification

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.

Quick Start

(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)"}))

Raw API (HTTP responses)

(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" ...}]

Coerced Entities (namespaced Clojure maps)

(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" ...}

Entity Coercion Details

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))

Time and timezones

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:

WireMeaning
2026-05-03T00:00:00ZUTC, "Zulu"
2026-05-03T00:00:00+00:00UTC, explicit offset
2026-05-03T00:00:00-07:00Pacific Daylight Time
2026-05-03T00:00:00+09:00Japan 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.

Payload Coercion (extensible)

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})))

Generic Coercion (by objectType)

;; 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 Types

ClientTypeScopes
VEN:venread_all, read_targets, read_ven_objects, write_reports, write_subscriptions, write_vens
BL:blread_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))

API Reference

Client Creation

FunctionDescription
spec-versionsMap of version string to classpath resource path
spec-pathResolve version string to spec file path (default 3.1.0)
read-openapi-specBootstrap Martian client from explicit spec file path
create-ven-clientCreate authenticated VEN client (opts: :spec-version, :http-client, :user-agent)
create-bl-clientCreate authenticated BL client (opts: :spec-version, :http-client, :user-agent)

Raw CRUD (returns {:status :body})

ResourceListGet by IDSearchCreateUpdateDeleteFind by Name
Programsget-programsget-program-by-idsearch-programscreate-programupdate-programdelete-programfind-program-by-name
Eventsget-eventsget-event-by-idsearch-eventscreate-eventupdate-eventdelete-event
VENsget-vensget-ven-by-idcreate-venupdate-vendelete-venfind-ven-by-name
Resourcesget-resource-by-idsearch-ven-resourcescreate-resourceupdate-resourcedelete-resource
Reportsget-reportsget-report-by-idsearch-reportscreate-reportupdate-reportdelete-report
Subscriptionsget-subscriptionsget-subscription-by-idsearch-subscriptionscreate-subscriptionupdate-subscriptiondelete-subscription

Coerced Entities (returns namespaced maps with :openadr/raw metadata)

FunctionReturns
programs / programProgram entities
events / eventEvent entities (with intervals + payloads)
vens / venVEN entities
reports / reportReport entities (with resources + intervals)
subscriptions / subscriptionSubscription entities

Response Helpers

FunctionDescription
success?True if 2xx status
bodyExtract :body from response

MQTT Topics

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

Introspection

FunctionDescription
all-routesList all route-name keywords
get-handlerGet handler for a route-name
endpoint-scopesGet required scopes for an endpoint
authorized?Check if client scopes satisfy endpoint requirements
get-unauthenticated-routesList routes requiring no auth
client-typeGet client type (:ven or :bl)
scopesGet client's OAuth2 scopes

Authentication

FunctionDescription
get-auth-serverGet auth server info
get-tokenFetch OAuth2 token via client credentials

Entity Coercion (openadr3.entities)

FunctionDescription
->program, ->event, ->ven, ->resource, ->report, ->subscriptionCoerce raw API map to namespaced entity
->interval, ->interval-periodCoerce nested structures
coerceGeneric multimethod (dispatches on :objectType)
coerce-payloadExtensible multimethod (dispatches on payload :type)
->zonedConvert Instant to ZonedDateTime
validate-raw-*Malli validation for raw API maps

Malli Schemas

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

Development

Start nREPL

clojure -M:nrepl
# nREPL port written to .nrepl-port

Run Tests

clojure -M:test

Uses Kaocha. Test suites: :api, :entities, :schema.

Dev REPL

(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

Dependencies

LibraryPurpose
MartianOpenAPI spec-driven HTTP client
HatoHTTP client (Java 11+ HttpClient)
MalliSchema validation
tickTime intervals (Allen's interval algebra)
medleyUtility functions
camel-snake-kebabCase conversion

Related Repos

RepoDescription
clj-oa3-clientComponent lifecycle wrapper for constructing and managing OA3 clients
clj-oa3-testOpenADR 3 integration tests

Contributing

Issues, Discussions, and pull requests are welcome — see CONTRIBUTING.md for the workflow (and the dev commands: tests, lint, nREPL). In short:

  • Questions, API/design discussion, spec-interpretation gapsDiscussions
  • Confirmed bugs, coercion/schema fixes, doc errorsIssues
  • Patches → pull requests; please open a Discussion or Issue first for non-trivial changes (new endpoint coverage, new spec versions, new schema fields, new coercion behavior)

License

MIT License — Copyright (c) 2026 Clark Communications Corporation

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close