Packages
macula
2.1.1
7.0.0
6.0.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.8.0
4.7.1
4.7.0
4.6.0
4.5.0
4.4.10
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.1
4.3.0
4.2.9
4.2.8
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.1
4.1.0
4.0.0
3.16.0
3.15.3
3.15.2
3.15.1
3.14.0
3.13.0
3.12.1
3.12.0
3.11.1
3.11.0
3.10.3
3.10.2
3.10.1
3.9.0
3.8.0
3.7.0
3.5.0
3.4.0
3.3.0
3.2.0
3.1.0
3.0.0
2.1.1
2.1.0
2.0.0
1.5.2
1.5.1
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.14
1.4.13
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.1
1.3.0
1.2.0
1.1.0
1.0.10
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.48.6
0.48.5
0.48.4
0.48.3
0.48.2
0.48.1
0.48.0
0.47.1
0.47.0
0.46.3
0.46.1
0.46.0
0.45.3
0.45.2
0.45.1
0.45.0
0.44.2
0.44.1
0.44.0
0.43.3
0.43.2
0.43.1
0.43.0
0.42.9
0.42.8
0.42.7
0.42.6
0.42.5
0.42.4
0.42.3
0.42.2
0.42.1
0.42.0
0.41.1
0.41.0
0.40.1
0.40.0
0.39.9
0.39.8
0.39.7
0.39.6
0.39.5
0.39.4
0.39.3
0.39.2
0.39.1
0.39.0
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.6
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.4
0.35.3
0.35.2
0.35.1
0.35.0
0.34.1
0.34.0
0.33.1
0.33.0
0.32.5
0.32.4
0.32.3
0.32.2
0.32.1
0.32.0
0.31.9
0.31.8
0.31.7
0.31.6
0.31.5
0.31.4
0.31.3
0.31.2
0.31.1
0.31.0
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.0
0.28.3
0.28.2
0.28.1
0.28.0
0.27.1
0.27.0
0.26.1
0.26.0
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.6
0.24.5
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.5
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.7
0.21.6
0.21.5
0.21.4
0.21.2
0.21.1
0.21.0
0.20.25
0.20.24
0.20.23
0.20.22
0.20.21
0.20.20
0.20.19
0.20.18
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.3
0.20.2
0.20.1
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.12.6
0.12.5
0.12.3
0.11.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise
Current section
Files
Jump to
Current section
Files
src/macula_topic.erl
%%%-------------------------------------------------------------------
%%% @doc Mesh topic construction and validation.
%%%
%%% Every mesh topic follows a strict 5-segment structure:
%%%
%%% {realm}/{publisher}/{publisher}/{domain}/{name}_v{N}
%%%
%%% The two publisher slots carry different values depending on the
%%% topic's ownership tier. Three tiers exist:
%%%
%%% realm: {realm}/_realm/_realm/{domain}/{name}_v{N}
%%% org: {realm}/{org}/_org/{domain}/{name}_v{N}
%%% app: {realm}/{org}/{app}/{domain}/{name}_v{N}
%%%
%%% Tier answers: who owns the topic's schema and authority?
%%%
%%% realm — realm authority (membership, identity, ban)
%%% org — org-spanning concept (licensing, billing)
%%% app — app-internal (game state, RPCs, app events)
%%%
%%% Use realm_fact / org_fact / app_fact for pub/sub topics
%%% (past tense names — something happened).
%%%
%%% Use realm_hope / org_hope / app_hope for RPC procedure names
%%% (present tense names — we want something to happen).
%%%
%%% System topics (with leading underscore — _mesh.*, _dist.*, _dht.*)
%%% are infrastructure-owned, dot-separated, and exempt from this
%%% 5-segment structure.
%%%
%%% Full guide: docs/guides/TOPIC_NAMING_GUIDE.md
%%% @end
%%%-------------------------------------------------------------------
-module(macula_topic).
-export([
realm_fact/4, realm_hope/4,
org_fact/5, org_hope/5,
app_fact/6, app_hope/6,
build/6,
parse/1,
validate/1,
is_system_topic/1
]).
-type tier() :: realm | org | app.
-type realm() :: binary().
-type org() :: binary().
-type app() :: binary().
-type domain() :: binary().
-type name() :: binary().
-type version() :: pos_integer().
-type topic() :: binary().
-export_type([tier/0, realm/0, org/0, app/0, domain/0, name/0, version/0, topic/0]).
-define(SENTINEL_REALM, <<"_realm">>).
-define(SENTINEL_ORG, <<"_org">>).
%%%===================================================================
%%% Builders — realm tier
%%%===================================================================
%% @doc Build a realm-tier fact (pub/sub) topic.
%% Realm authority owns the schema. Publisher and subscriber slots
%% carry the _realm sentinel.
-spec realm_fact(realm(), domain(), name(), version()) -> topic().
realm_fact(Realm, Domain, Name, Version) ->
build(Realm, ?SENTINEL_REALM, ?SENTINEL_REALM, Domain, Name, Version).
%% @doc Build a realm-tier hope (RPC) procedure name.
-spec realm_hope(realm(), domain(), name(), version()) -> topic().
realm_hope(Realm, Domain, Name, Version) ->
build(Realm, ?SENTINEL_REALM, ?SENTINEL_REALM, Domain, Name, Version).
%%%===================================================================
%%% Builders — org tier
%%%===================================================================
%% @doc Build an org-tier fact topic.
%% An org owns the schema across multiple of its apps.
%% The app slot carries the _org sentinel.
-spec org_fact(realm(), org(), domain(), name(), version()) -> topic().
org_fact(Realm, Org, Domain, Name, Version) ->
build(Realm, Org, ?SENTINEL_ORG, Domain, Name, Version).
%% @doc Build an org-tier hope procedure name.
-spec org_hope(realm(), org(), domain(), name(), version()) -> topic().
org_hope(Realm, Org, Domain, Name, Version) ->
build(Realm, Org, ?SENTINEL_ORG, Domain, Name, Version).
%%%===================================================================
%%% Builders — app tier
%%%===================================================================
%% @doc Build an app-tier fact topic.
%% A specific app owns the schema. All slots carry real values.
-spec app_fact(realm(), org(), app(), domain(), name(), version()) -> topic().
app_fact(Realm, Org, App, Domain, Name, Version) ->
build(Realm, Org, App, Domain, Name, Version).
%% @doc Build an app-tier hope procedure name.
-spec app_hope(realm(), org(), app(), domain(), name(), version()) -> topic().
app_hope(Realm, Org, App, Domain, Name, Version) ->
build(Realm, Org, App, Domain, Name, Version).
%%%===================================================================
%%% Lower-level
%%%===================================================================
%% @doc Build a topic from explicit segments.
%% Validates each segment and the publisher-slot tier combination.
%% Prefer the tier-specific builders (realm_fact, org_fact, app_fact).
-spec build(realm(), org() | binary(), app() | binary(),
domain(), name(), version()) -> topic().
build(Realm, Org, App, Domain, Name, Version)
when is_binary(Realm), is_binary(Org), is_binary(App),
is_binary(Domain), is_binary(Name),
is_integer(Version), Version > 0 ->
validate_segment(realm, Realm),
validate_publisher_slots(Org, App),
validate_segment(domain, Domain),
validate_segment(name, Name),
Vsn = integer_to_binary(Version),
<<Realm/binary, "/", Org/binary, "/", App/binary, "/",
Domain/binary, "/", Name/binary, "_v", Vsn/binary>>.
%% @doc Parse a topic into its constituent parts and tier.
%% Returns an error tuple for any non-canonical topic. System topics
%% (leading underscore prefix) are not canonical and will return an error
%% from parse/1; use validate/1 to accept system topics as well.
-spec parse(topic()) -> {ok, map()} | {error, term()}.
parse(Topic) when is_binary(Topic) ->
parse_segments(binary:split(Topic, <<"/">>, [global]), Topic).
%% @doc Validate a topic string. Accepts canonical 5-segment topics
%% AND system topics (leading underscore prefix infrastructure events).
-spec validate(topic()) -> ok | {error, term()}.
validate(Topic) when is_binary(Topic) ->
validate_dispatch(is_system_topic(Topic), Topic).
%% @doc Check if a topic is a system topic. System topics use the
%% leading-underscore convention (e.g. _mesh.node.up, _dist.tunnel.X,
%% _dht.list_gateways). They are infrastructure-owned, dot-separated,
%% and exempt from the canonical 5-segment structure. Out of scope
%% for this validator — passed through as-is.
-spec is_system_topic(binary()) -> boolean().
is_system_topic(<<"_", _/binary>>) -> true;
is_system_topic(_) -> false.
%%%===================================================================
%%% Internal — parse
%%%===================================================================
parse_segments([Realm, Org, App, Domain, NameVsn], Topic) ->
parse_name_version(Realm, Org, App, Domain, NameVsn, Topic);
parse_segments(_Other, Topic) ->
{error, {invalid_structure, Topic}}.
parse_name_version(Realm, Org, App, Domain, NameVsn, Topic) ->
parse_with_version(parse_version_suffix(NameVsn),
Realm, Org, App, Domain, NameVsn, Topic).
parse_with_version({ok, Name, Version}, Realm, Org, App, Domain, NameVsn, _Topic) ->
build_parsed(infer_tier(Org, App), Realm, Org, App, Domain, Name, NameVsn, Version);
parse_with_version({error, _} = Err, _Realm, _Org, _App, _Domain, _NameVsn, _Topic) ->
Err.
build_parsed({ok, Tier}, Realm, Org, App, Domain, Name, NameVsn, Version) ->
{ok, build_parsed_map(Tier, Realm, Org, App, Domain, Name, NameVsn, Version)};
build_parsed({error, _} = Err, _R, _O, _A, _D, _N, _NV, _V) ->
Err.
build_parsed_map(realm, Realm, _Org, _App, Domain, Name, NameVsn, Version) ->
#{
tier => realm,
realm => Realm,
domain => Domain,
name => Name,
version => Version,
topic => rebuild_topic(Realm, ?SENTINEL_REALM, ?SENTINEL_REALM, Domain, NameVsn)
};
build_parsed_map(org, Realm, Org, _App, Domain, Name, NameVsn, Version) ->
#{
tier => org,
realm => Realm,
org => Org,
domain => Domain,
name => Name,
version => Version,
topic => rebuild_topic(Realm, Org, ?SENTINEL_ORG, Domain, NameVsn)
};
build_parsed_map(app, Realm, Org, App, Domain, Name, NameVsn, Version) ->
#{
tier => app,
realm => Realm,
org => Org,
app => App,
domain => Domain,
name => Name,
version => Version,
topic => rebuild_topic(Realm, Org, App, Domain, NameVsn)
}.
rebuild_topic(Realm, Org, App, Domain, NameVsn) ->
<<Realm/binary, "/", Org/binary, "/", App/binary, "/",
Domain/binary, "/", NameVsn/binary>>.
parse_version_suffix(NameVsn) ->
suffix_match(re:run(NameVsn, <<"^(.+)_v([0-9]+)$">>, [{capture, [1, 2], binary}]),
NameVsn).
suffix_match({match, [Name, VsnBin]}, _NameVsn) ->
{ok, Name, binary_to_integer(VsnBin)};
suffix_match(nomatch, NameVsn) ->
{error, {missing_version_suffix, NameVsn}}.
%% Infer tier from publisher slots. Used by parse/1.
%% Mismatched sentinel combinations are rejected.
infer_tier(?SENTINEL_REALM, ?SENTINEL_REALM) -> {ok, realm};
infer_tier(?SENTINEL_REALM, _App) -> {error, {mismatched_realm_sentinel, app_must_be_realm_too}};
infer_tier(_Org, ?SENTINEL_REALM) -> {error, {mismatched_realm_sentinel, org_must_be_realm_too}};
infer_tier(?SENTINEL_ORG, _App) -> {error, {misplaced_org_sentinel, only_in_app_slot}};
infer_tier(Org, ?SENTINEL_ORG) -> validate_org_for_tier(Org, org);
infer_tier(Org, App) -> validate_org_app_for_tier(Org, App).
validate_org_for_tier(Org, Tier) ->
case is_valid_segment(Org) of
true -> {ok, Tier};
false -> {error, {invalid_segment, org, Org}}
end.
validate_org_app_for_tier(Org, App) ->
case {is_valid_segment(Org), is_valid_segment(App)} of
{true, true} -> {ok, app};
{false, _} -> {error, {invalid_segment, org, Org}};
{true, false} -> {error, {invalid_segment, app, App}}
end.
%%%===================================================================
%%% Internal — validate
%%%===================================================================
validate_dispatch(true, _Topic) -> ok;
validate_dispatch(false, Topic) -> validate_canonical(parse(Topic)).
validate_canonical({ok, _}) -> ok;
validate_canonical({error, _} = E) -> E.
%%%===================================================================
%%% Internal — segment validation (build path)
%%%===================================================================
%% Publisher-slot validation enforces tier sentinel rules at build time.
%% Pattern-match order matters: sentinel combinations first, then real.
validate_publisher_slots(?SENTINEL_REALM, ?SENTINEL_REALM) -> ok;
validate_publisher_slots(?SENTINEL_REALM, _) ->
error({invalid_tier_combination, app_must_be_realm_when_org_realm});
validate_publisher_slots(_, ?SENTINEL_REALM) ->
error({invalid_tier_combination, org_must_be_realm_when_app_realm});
validate_publisher_slots(?SENTINEL_ORG, _) ->
error({invalid_tier_combination, org_sentinel_only_in_app_slot});
validate_publisher_slots(Org, ?SENTINEL_ORG) ->
validate_segment(org, Org);
validate_publisher_slots(Org, App) ->
validate_segment(org, Org),
validate_segment(app, App).
validate_segment(Label, Segment) ->
case is_valid_segment(Segment) of
true -> ok;
false -> error({invalid_segment, Label, Segment})
end.
is_valid_segment(Segment) when is_binary(Segment) ->
case re:run(Segment, <<"^[a-z0-9][a-z0-9._-]*$">>) of
{match, _} -> true;
nomatch -> false
end;
is_valid_segment(_) ->
false.