hb_singleton.erl - TABM Message Parser
Overview
Purpose: Parse AO-Core HTTP API requests in TABM format into executable message sequences
Module: hb_singleton
Format: Transform-Apply-Bind-Map (TABM) singleton message syntax
This module translates singleton TABM messages (containing keys and a path field with optional query parameters) into ordered lists of AO-Core messages. TABM provides a URL-like syntax for expressing complex message sequences with inline parameters, device specifications, and nested resolutions.
Dependencies
- HyperBEAM:
hb_util,hb_maps,hb_path,hb_ao,hb_escape - Erlang/OTP:
cowboy_req - Records:
#tx{}frominclude/hb.hrl
Public Functions Overview
%% Parsing
-spec from(TABMMessage, Opts) -> [AOMessage].
-spec from_path(Path) -> {ok, PathParts, QueryParams}.
%% Encoding
-spec to([AOMessage]) -> TABMMessage.Public Functions
1. from/2
-spec from(TABMMessage, Opts) -> [AOMessage]
when
TABMMessage :: map() | binary(),
Opts :: map(),
AOMessage :: map().Description: Convert a TABM singleton message into an ordered list of executable AO-Core messages. Supports complex syntax including scoped keys, typed values, nested resolutions, and device specifications.
Input Formats:- Binary path:
<<"/a/b/c">> - Map with path:
#{<<"path">> => <<"/a/b/c">>, ...}
-module(hb_singleton_from_test).
-include_lib("eunit/include/eunit.hrl").
from_simple_path_test() ->
Req = #{<<"path">> => <<"/a/b/c">>},
Msgs = hb_singleton:from(Req, #{}),
?assertEqual(4, length(Msgs)),
[Base, Msg1, Msg2, Msg3] = Msgs,
?assert(is_map(Base)),
?assertEqual(<<"a">>, maps:get(<<"path">>, Msg1)),
?assertEqual(<<"b">>, maps:get(<<"path">>, Msg2)),
?assertEqual(<<"c">>, maps:get(<<"path">>, Msg3)).
from_binary_path_test() ->
Msgs = hb_singleton:from(<<"/test/path">>, #{}),
?assertEqual(3, length(Msgs)),
[_, Msg1, Msg2] = Msgs,
?assertEqual(<<"test">>, maps:get(<<"path">>, Msg1)),
?assertEqual(<<"path">>, maps:get(<<"path">>, Msg2)).
from_id_base_test() ->
ID = <<"IYkkrqlZNW_J-4T-5eFApZOMRl5P4VjvrcOXWvIqB1Q">>,
Req = #{
<<"path">> => <<"/", ID/binary, "/some-other">>,
<<"method">> => <<"GET">>
},
Msgs = hb_singleton:from(Req, #{}),
?assertEqual(2, length(Msgs)),
[Base, Msg2] = Msgs,
?assertEqual(Base, ID),
?assertEqual(<<"GET">>, maps:get(<<"method">>, Msg2)),
?assertEqual(<<"some-other">>, maps:get(<<"path">>, Msg2)).
from_global_keys_test() ->
Req = #{
<<"path">> => <<"/a/b/c">>,
<<"test-key">> => <<"test-value">>
},
Msgs = hb_singleton:from(Req, #{}),
?assertEqual(4, length(Msgs)),
[_Base, Msg1, Msg2, Msg3] = Msgs,
% Global keys appear in all messages
?assertEqual(<<"test-value">>, maps:get(<<"test-key">>, Msg1)),
?assertEqual(<<"test-value">>, maps:get(<<"test-key">>, Msg2)),
?assertEqual(<<"test-value">>, maps:get(<<"test-key">>, Msg3)).2. from_path/1
-spec from_path(Path) -> {ok, PathParts, QueryParams}
when
Path :: binary(),
PathParts :: [binary()],
QueryParams :: map().Description: Parse a relative reference into path parts and query parameters. Handles URL-encoded strings and query string parsing.
Test Code:-module(hb_singleton_from_path_test).
-include_lib("eunit/include/eunit.hrl").
from_path_simple_test() ->
{ok, Parts, Query} = hb_singleton:from_path(<<"/a/b/c">>),
?assertEqual([<<"a">>, <<"b">>, <<"c">>], Parts),
?assertEqual(#{}, Query).
from_path_with_query_test() ->
{ok, Parts, Query} = hb_singleton:from_path(<<"/path?key1=val1&key2=val2">>),
?assertEqual([<<"path">>], Parts),
?assertEqual(<<"val1">>, maps:get(<<"key1">>, Query)),
?assertEqual(<<"val2">>, maps:get(<<"key2">>, Query)).
from_path_encoded_test() ->
{ok, Parts, Query} = hb_singleton:from_path(<<"/path?key=hello%20world">>),
?assertEqual([<<"path">>], Parts),
?assertEqual(<<"hello world">>, maps:get(<<"key">>, Query)).3. to/1
-spec to([AOMessage]) -> TABMMessage
when
AOMessage :: map() | binary(),
TABMMessage :: map().Description: Convert a list of AO-Core messages into a TABM singleton message. Inverse of from/2. Handles scoped keys and typed values.
-module(hb_singleton_to_test).
-include_lib("eunit/include/eunit.hrl").
to_simple_test() ->
Messages = [
#{},
#{<<"path">> => <<"a">>},
#{<<"path">> => <<"b">>, <<"key">> => <<"value">>}
],
TABM = hb_singleton:to(Messages),
?assertEqual(<<"/a/b">>, maps:get(<<"path">>, TABM)),
?assertEqual(<<"value">>, maps:get(<<"2.key">>, TABM)).
to_with_ids_test() ->
ID = <<"IYkkrqlZNW_J-4T-5eFApZOMRl5P4VjvrcOXWvIqB1Q">>,
Messages = [
ID,
#{<<"path">> => <<"action">>}
],
TABM = hb_singleton:to(Messages),
?assertEqual(<<"/", ID/binary, "/action">>, maps:get(<<"path">>, TABM)).
to_with_resolve_test() ->
Messages = [
#{},
#{<<"path">> => <<"a">>, <<"key">> => {resolve, [#{}, #{<<"path">> => <<"x">>}]}}
],
TABM = hb_singleton:to(Messages),
?assertMatch(#{<<"1.key+resolve">> := <<"/x">>}, TABM).TABM Syntax Overview
Path Syntax
% Simple path: /Part1/Part2/.../PartN
"/a/b/c" → [#{}, #{path => <<"a">>}, #{path => <<"b">>}, #{path => <<"c">>}]
% ID-based path: /ID/Part2/.../PartN
"/IYkkrq.../msg2" → [<<"IYkkrq...">>, #{path => <<"msg2">>}]
% Root path
"/" → [#{}]Inline Keys
% Key-value pair: Part&Key=Value
"/a/b&k1=v1" → Msg2 has #{k1 => <<"v1">>}
% Multiple keys: Part&K1=V1&K2=V2
"/a/b&k1=v1&k2=v2" → Msg2 has #{k1 => <<"v1">>, k2 => <<"v2">>}
% Boolean flag: Part&Key
"/a/b&flag" → Msg2 has #{flag => true}
% Assumed key: Part=Value
"/a/b=4" → Msg2 has #{b => <<"4">>}Device Specification
% Device routing: Part~Device
"/a/b~process@1.0" → {as, <<"process@1.0">>, #{path => <<"b">>}}
% Device with keys: Part~D&K1=V1
"/a/b~device@1.0&key=val" → {as, <<"device@1.0">>, #{path => <<"b">>, key => <<"val">>}}Typed Keys
% Integer type: key+int=value
"2.key+integer=123" → Msg2 has #{key => 123}
% Resolve type: key+res=(/path)
"2.key+resolve=(/a/b)" → Msg2 has #{key => {resolve, [...]}}Nested Resolution
% Subpath in path: (/nested/path)
"/a/(x/y/z)/c" → Msg2 is {resolve, [...]} for /x/y/z
% Subpath in key: key+res=(/a/b)
"&key+resolve=(/a/b)" → {key => {resolve, [...]}}Scoped Keys
% Global key: applies to all messages
"key=value" → All messages have #{key => <<"value">>}
% Scoped key: N.key=value (applies to Nth message)
"2.test-key=value" → Only Msg2 has #{test-key => <<"value">>}Syntax Examples
Basic Paths
% Simple path
"/a/b/c"
→ [#{}, #{path => <<"a">>}, #{path => <<"b">>}, #{path => <<"c">>}]
% ID as base
"/IYkkrqlZNW_J-4T-5eFApZOMRl5P4VjvrcOXWvIqB1Q/data"
→ [<<"IYkkrq...">>, #{path => <<"data">>}]
% Single part
"/test"
→ [#{}, #{path => <<"test">>}]
% Root only
"/"
→ [#{}]Inline Parameters
% Single key-value
"/path&key=value"
→ [#{}, #{path => <<"path">>, key => <<"value">>}]
% Multiple keys
"/path&k1=v1&k2=v2"
→ [#{}, #{path => <<"path">>, k1 => <<"v1">>, k2 => <<"v2">>}]
% Boolean flag
"/path&enabled"
→ [#{}, #{path => <<"path">>, enabled => true}]
% Assumed key name
"/increment=5"
→ [#{}, #{path => <<"increment">>, increment => <<"5">>}]
% Mixed
"/action&enabled&count=10"
→ [#{}, #{path => <<"action">>, enabled => true, count => <<"10">>}]Device Routing
% Simple device
"/~process@1.0/state"
→ [#{}, {as, <<"process@1.0">>, #{path => <<"process@1.0">>}}, #{path => <<"state">>}]
% Device with parameters
"/~process@1.0/execute&action=run"
→ [#{}, {as, <<"process@1.0">>, ...}, #{path => <<"execute">>, action => <<"run">>}]Typed Values
% Integer
"2.count+integer=42"
→ Msg2 has #{count => 42}
% Resolve
"2.data+resolve=(/other/path)"
→ Msg2 has #{data => {resolve, [#{}, #{path => <<"other">>}, #{path => <<"path">>}]}}Nested Resolutions
% Nested in path
"/a/(x/y)/b"
→ [#{}, #{path => <<"a">>}, {resolve, [#{}, #{path => <<"x">>}, #{path => <<"y">>}]}, #{path => <<"b">>}]
% Nested in key
"/path&key=(/sub/path)"
→ [#{}, #{path => <<"path">>, key => {resolve, [#{}, #{path => <<"sub">>}, #{path => <<"path">>}]}}]
% Deep nesting
"/a/(b/(c/d))/e"
→ Nested resolve structuresComplex Examples
% ID base with typed scoped keys
"/IYkkrq.../execute&2.count+integer=5&2.enabled"
→ [<<"IYkkrq...">>, #{path => <<"execute">>}, #{count => 5, enabled => true}]
% Multiple devices
"/~dev1@1.0/a/~dev2@1.0/b"
→ Device transitions
% Query parameters
"/path?global_key=value"
→ Global key added to all messages
% Everything combined
"/~process@1.0/execute&action=run&2.data+resolve=(/state/current)?method=POST"
→ Complex multi-feature requestParsing Process
1. Path Extraction
Input: #{<<"path">> => <<"/a/b">>, <<"key">> => <<"val">>}
↓
Split path from query: Path = <<"/a/b">>, Query = #{<<"key">> => <<"val">>}2. Path Segmentation
Path: <<"/a/b&k=v/c">>
↓
Segments: [<<"a">>, <<"b&k=v">>, <<"c">>]3. Part Parsing
Segment: <<"b&k=v">>
↓
Parse: #{path => <<"b">>, k => <<"v">>}4. Base Normalization
Messages: [Msg1, Msg2, Msg3]
↓
If Msg1 is ID or {as, ...}: Keep as is
Else: Prepend empty base: [#{}, Msg1, Msg2, Msg3]5. Key Scoping
Keys: #{<<"2.key">> => <<"val">>, <<"global">> => <<"g">>}
↓
Apply: Only Msg2 gets key => <<"val">>, all get global => <<"g">>6. Type Application
"2.count+integer" => <<"42">>
↓
Msg2: #{count => 42} % Converted to integerQuery String Parameters
Format
"/path?key1=value1&key2=value2"Parsing
% URL-encoded
"/path?name=John%20Doe"
→ #{name => <<"John Doe">>}
% Multiple values (last wins)
"/path?key=val1&key=val2"
→ #{key => <<"val2">>}
% Boolean flags
"/path?flag"
→ #{flag => true}Global Application
Query parameters become global keys applied to all messages:
Input: #{<<"path">> => <<"/a/b?method=POST">>}
↓
All messages get: #{method => <<"POST">>, ...}Key Scoping
Global Keys
% Keys without N. prefix apply to all messages
#{
<<"path">> => <<"/a/b">>,
<<"global-key">> => <<"value">>
}
→ All messages have global-key => <<"value">>Scoped Keys
% N.key applies only to Nth message (0-indexed)
#{
<<"path">> => <<"/a/b/c">>,
<<"2.scoped-key">> => <<"value">>
}
→ Only Msg2 (third message after base) has scoped-key => <<"value">>Mixed Scoping
#{
<<"path">> => <<"/a/b">>,
<<"global">> => <<"g">>,
<<"1.first">> => <<"f">>,
<<"2.second">> => <<"s">>
}
→
Base: #{global => <<"g">>}
Msg1: #{path => <<"a">>, global => <<"g">>, first => <<"f">>}
Msg2: #{path => <<"b">>, global => <<"g">>, second => <<"s">>}Type Conversion
Supported Types
% Binary (default)
"key" => "value" → #{key => <<"value">>}
% Integer
"key+integer" => "42" → #{key => 42}
% Resolve
"key+resolve" => "/path" → #{key => {resolve, [...]}}Type Syntax
% In query/global keys
"key+integer=123"
% In scoped keys
"2.key+integer=456"
% In inline keys
"/path&key+integer=789"
% In assumed keys
"/path+integer=999"Type Inference
% Explicit type always wins
"key+integer=123" → 123 (integer)
% No type specified
"key=123" → <<"123">> (binary)Device Resolution
Device Syntax
% Tilde prefix for device
"~device@1.0"
% With path
"/~device@1.0/action"
% With parameters
"/~device@1.0/action&key=val"Resolution Pattern
"/~process@1.0/state"
→ [
#{},
{as, <<"process@1.0">>, #{path => <<"process@1.0">>}},
#{path => <<"state">>}
]
% Nested devices
"/~dev1@1.0/a/~dev2@1.0/b"
→ Multiple {as, ...} transitionsNested Resolution
Parentheses Syntax
% In path
"(/nested/path)"
% In key value
"key=(/nested/path)"
% With parameters
"(/path&k=v)"Resolution Structure
"(/a/b)"
→ {resolve, [#{}, #{path => <<"a">>}, #{path => <<"b">>}]}
% Used in message
"/main/(sub/path)/end"
→ [
#{},
#{path => <<"main">>},
{resolve, [#{}, #{path => <<"sub">>}, #{path => <<"path">>}]},
#{path => <<"end">>}
]Deep Nesting
"(/a/(b/c))"
→ {resolve, [
#{},
#{path => <<"a">>},
{resolve, [#{}, #{path => <<"b">>}, #{path => <<"c">>}]}
]}Common Patterns
%% Simple API call
Path = <<"/process/state">>,
Msgs = hb_singleton:from(#{<<"path">> => Path}, #{}),
% Execute: Base → process → state
%% With method
Path = <<"/process/execute?method=POST">>,
Msgs = hb_singleton:from(#{<<"path">> => Path}, #{}),
% All messages have method => <<"POST">>
%% ID-based resolution
ID = <<"IYkkrqlZNW_J-4T-5eFApZOMRl5P4VjvrcOXWvIqB1Q">>,
Path = <<"/", ID/binary, "/data/current">>,
Msgs = hb_singleton:from(#{<<"path">> => Path}, #{}),
% Start from specific ID
%% Inline parameters
Path = <<"/execute&action=run&count+integer=5">>,
Msgs = hb_singleton:from(#{<<"path">> => Path}, #{}),
% Execute with typed parameters
%% Device routing
Path = <<"/~process@1.0/state">>,
Msgs = hb_singleton:from(#{<<"path">> => Path}, #{}),
% Execute as process@1.0 device
%% Nested resolution
Path = <<"/main/(sub/path)/end">>,
Msgs = hb_singleton:from(#{<<"path">> => Path}, #{}),
% Resolve sub-path inline
%% Scoped keys
Req = #{
<<"path">> => <<"/a/b/c">>,
<<"1.param">> => <<"only-for-a">>,
<<"2.param">> => <<"only-for-b">>
},
Msgs = hb_singleton:from(Req, #{}),
% Different params for different steps
%% Round-trip conversion
AOMessages = [...],
TABM = hb_singleton:to(AOMessages),
Recovered = hb_singleton:from(TABM, #{}),
% TABM encoding/decodingHTTP API Integration
Request Structure
% HTTP GET request
GET /process/state HTTP/1.1
% Parsed to TABM
#{
<<"path">> => <<"/process/state">>,
<<"method">> => <<"GET">>
}
% Converted to messages
[#{}, #{path => <<"process">>}, #{path => <<"state">>, method => <<"GET">>}]POST with Body
% HTTP POST
POST /process/execute HTTP/1.1
Content-Type: application/json
{"action": "run", "count": 5}
% TABM
#{
<<"path">> => <<"/process/execute">>,
<<"method">> => <<"POST">>,
<<"action">> => <<"run">>,
<<"count">> => 5
}Performance Considerations
Parsing Complexity
- Time: O(n) where n is path length
- Space: O(m) where m is number of messages
- Optimizations: Single-pass parsing with minimal allocations
Path Segment Limits
-define(MAX_SEGMENT_LENGTH, 512).Segments longer than 512 bytes may be rejected for security.
References
- AO-Core Protocol -
hb_ao.erl - Path Utilities -
hb_path.erl - HTTP Server -
hb_http.erl - Message System -
hb_message.erl - URL Encoding -
hb_escape.erl
Notes
- TABM Format: Transform-Apply-Bind-Map message encoding
- URL-Like Syntax: Familiar format for HTTP APIs
- Inline Parameters: Avoid separate body for simple requests
- Device Routing: Explicit device specification with
~ - Nested Resolution: Parentheses for inline sub-resolutions
- Type Safety: Explicit type annotations prevent errors
- Scoped Keys: Fine-grained control over parameter application
- Bidirectional: Both
fromandtoconversions supported - Query Strings: Standard URL query parameter support
- ID Recognition: Automatic detection of Arweave IDs as base