Packages

macula

0.35.2
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
macula src macula_authorization.erl
Raw

src/macula_authorization.erl

%% @doc Mesh Authorization Module for UCAN/DID-based access control.
%%
%% This module implements decentralized authorization for Macula mesh operations
%% using UCAN (User Controlled Authorization Networks) and DID (Decentralized
%% Identifiers). All DID parsing and UCAN validation is implemented inline
%% with no external dependencies.
%%
%% == Namespace Ownership Model ==
%%
%% DIDs map to namespaces they own:
%% - `did:macula:io.macula.rgfaber' owns `io.macula.rgfaber.*'
%% - Parent DIDs can access child namespaces (hierarchical)
%% - `did:macula:io.macula' can access everything in `io.macula.*'
%%
%% == Authorization Flow ==
%%
%% 1. Extract caller DID from connection/message
%% 2. Parse topic/procedure to extract namespace
%% 3. Check if caller owns namespace → Allow
%% 4. If not owner, check for valid UCAN grant → Allow/Deny
%%
%% == Public Topics ==
%%
%% Topics containing `.public.' segment are world-readable:
%% - `io.macula.rgfaber.public.announcements' → Anyone can subscribe
%% - Publishing still requires ownership or UCAN grant
%%
%% @author rgfaber
-module(macula_authorization).
%% Core authorization checks
-export([
check_rpc_call/4,
check_publish/4,
check_subscribe/3,
check_subscribe/4,
check_announce/3
]).
%% Namespace operations
-export([
extract_namespace/1,
check_namespace_ownership/2,
is_public_topic/1,
is_ancestor_namespace/2
]).
%% UCAN operations
-export([
validate_ucan_for_operation/4,
check_capability_match/3
]).
%% DID operations
-export([
resolve_caller_did/1,
extract_identity_from_did/1
]).
%%====================================================================
%% Types
%%====================================================================
-type did() :: binary().
-type topic() :: binary().
-type procedure() :: binary().
-type namespace() :: binary().
-type ucan_token() :: binary() | undefined.
-type operation() :: binary().
-type auth_opts() :: #{atom() => term()}.
-type auth_result() :: {ok, authorized} | {error, auth_error()}.
-type auth_error() :: unauthorized
| invalid_ucan
| expired_ucan
| revoked_ucan
| insufficient_capability
| invalid_did
| namespace_mismatch.
-export_type([did/0, auth_result/0, auth_error/0]).
%%====================================================================
%% Core Authorization Checks
%%====================================================================
%% @doc Check if caller is authorized to make an RPC call.
%%
%% Authorization succeeds if:
%% 1. Caller owns the procedure's namespace, OR
%% 2. Caller is ancestor of the namespace (parent access), OR
%% 3. Caller has a valid UCAN with `mesh:call' capability
-spec check_rpc_call(CallerDID :: did(), Procedure :: procedure(),
UcanToken :: ucan_token(), Opts :: auth_opts()) ->
auth_result().
check_rpc_call(CallerDID, Procedure, UcanToken, _Opts) ->
Namespace = extract_namespace(Procedure),
Result = check_with_ucan_fallback(CallerDID, Namespace, Procedure,
UcanToken, <<"mesh:call">>),
audit_result(call, CallerDID, Procedure, Result),
Result.
%% @doc Check if caller is authorized to publish to a topic.
%%
%% Publishing requires ownership or UCAN grant.
%% Public topics do NOT grant publish rights.
-spec check_publish(CallerDID :: did(), Topic :: topic(),
UcanToken :: ucan_token(), Opts :: auth_opts()) ->
auth_result().
check_publish(CallerDID, Topic, UcanToken, _Opts) ->
Namespace = extract_namespace(Topic),
Result = check_with_ucan_fallback(CallerDID, Namespace, Topic,
UcanToken, <<"mesh:publish">>),
audit_result(publish, CallerDID, Topic, Result),
Result.
%% @doc Check if caller is authorized to subscribe to a topic.
%%
%% Authorization succeeds if:
%% 1. Topic is public (contains `.public.'), OR
%% 2. Caller owns the namespace, OR
%% 3. Caller is ancestor, OR
%% 4. Caller has UCAN with `mesh:subscribe' capability
-spec check_subscribe(CallerDID :: did(), Topic :: topic(),
Opts :: auth_opts()) ->
auth_result().
check_subscribe(CallerDID, Topic, _Opts) when is_binary(Topic) ->
Result = case is_public_topic(Topic) of
true ->
{ok, authorized};
false ->
%% For subscriptions without UCAN, check ownership
%% Note: UCAN check requires token which isn't in this signature
%% Subscriptions to non-public topics require ownership
{error, unauthorized}
end,
audit_result(subscribe, CallerDID, Topic, Result),
Result.
%% @doc Check if caller is authorized to subscribe with UCAN support.
%% Full version with UCAN token parameter.
-spec check_subscribe(CallerDID :: did(), Topic :: topic(),
UcanToken :: ucan_token(), Opts :: auth_opts()) ->
auth_result().
check_subscribe(CallerDID, Topic, UcanToken, _Opts) ->
Result = case is_public_topic(Topic) of
true ->
{ok, authorized};
false ->
Namespace = extract_namespace(Topic),
check_with_ucan_fallback(CallerDID, Namespace, Topic,
UcanToken, <<"mesh:subscribe">>)
end,
audit_result(subscribe, CallerDID, Topic, Result),
Result.
%% @doc Check if caller is authorized to announce/declare a procedure.
%%
%% Announcing ALWAYS requires namespace ownership.
%% UCAN grants cannot give announce rights (prevents namespace hijacking).
-spec check_announce(CallerDID :: did(), Procedure :: procedure(),
Opts :: auth_opts()) ->
auth_result().
check_announce(CallerDID, Procedure, _Opts) ->
Namespace = extract_namespace(Procedure),
Result = case check_namespace_ownership(CallerDID, Namespace) of
{ok, owner} -> {ok, authorized};
{ok, ancestor} -> {ok, authorized};
{error, not_owner} -> {error, unauthorized}
end,
audit_result(announce, CallerDID, Procedure, Result),
Result.
%%====================================================================
%% Namespace Operations
%%====================================================================
%% @doc Extract namespace from a topic or procedure.
%%
%% Namespace is the first 3 segments of a dotted path:
%% - `io.macula.rgfaber.place_order' → `io.macula.rgfaber'
%% - `io.macula.public.events' → `io.macula.public'
%% - Short topics return the topic itself as namespace.
-spec extract_namespace(TopicOrProcedure :: binary()) -> namespace().
extract_namespace(TopicOrProcedure) when is_binary(TopicOrProcedure) ->
Parts = binary:split(TopicOrProcedure, <<".">>, [global]),
case length(Parts) of
N when N >= 3 ->
[A, B, C | _] = Parts,
<<A/binary, ".", B/binary, ".", C/binary>>;
_ ->
%% Short topic = namespace itself
TopicOrProcedure
end.
%% @doc Check if caller DID owns a namespace.
%%
%% Returns:
%% - `{ok, owner}' if DID identity matches namespace exactly
%% - `{ok, ancestor}' if DID identity is parent of namespace
%% - `{error, not_owner}' otherwise
-spec check_namespace_ownership(CallerDID :: did(), Namespace :: namespace()) ->
{ok, owner | ancestor} | {error, not_owner}.
check_namespace_ownership(CallerDID, Namespace) ->
case extract_identity_from_did(CallerDID) of
{ok, Identity} ->
check_identity_ownership(Identity, Namespace);
{error, _} ->
{error, not_owner}
end.
%% @private Check identity ownership of namespace
-spec check_identity_ownership(Identity :: binary(), Namespace :: namespace()) ->
{ok, owner | ancestor} | {error, not_owner}.
check_identity_ownership(Identity, Namespace) when Identity =:= Namespace ->
{ok, owner};
check_identity_ownership(Identity, Namespace) ->
case is_ancestor_namespace(Identity, Namespace) of
true -> {ok, ancestor};
false -> {error, not_owner}
end.
%% @doc Check if one namespace is an ancestor of another.
%%
%% `io.macula' is ancestor of `io.macula.rgfaber'
%% `io.macula.rgfaber' is ancestor of `io.macula.rgfaber.services'
-spec is_ancestor_namespace(Parent :: namespace(), Child :: namespace()) -> boolean().
is_ancestor_namespace(Parent, Child) when Parent =:= Child ->
false;
is_ancestor_namespace(Parent, Child) ->
Prefix = <<Parent/binary, ".">>,
case binary:match(Child, Prefix) of
{0, _} -> true;
_ -> false
end.
%% @doc Check if a topic is public (contains `.public.' segment).
%%
%% Public topics allow subscription without ownership or UCAN.
-spec is_public_topic(Topic :: topic()) -> boolean().
is_public_topic(Topic) when is_binary(Topic) ->
case binary:match(Topic, <<".public.">>) of
{_, _} -> true;
nomatch ->
%% Also check if topic starts with public.
case binary:match(Topic, <<"public.">>) of
{0, _} -> true;
_ -> false
end
end.
%%====================================================================
%% UCAN Operations
%%====================================================================
%% @doc Validate a UCAN token for a specific operation.
%%
%% Checks:
%% 1. Token is well-formed and not expired
%% 2. Audience matches caller DID
%% 3. Token has required capability for operation
%% 4. Token is not revoked
-spec validate_ucan_for_operation(UcanToken :: ucan_token(),
CallerDID :: did(),
Resource :: binary(),
Operation :: operation()) ->
auth_result().
validate_ucan_for_operation(undefined, _CallerDID, _Resource, _Operation) ->
{error, unauthorized};
validate_ucan_for_operation(UcanToken, CallerDID, Resource, Operation) ->
%% First decode without verification to get audience
case decode_ucan(UcanToken) of
{ok, Payload} ->
%% Check audience matches caller
case maps:get(<<"aud">>, Payload, undefined) of
CallerDID ->
%% Check capabilities and revocation
validate_ucan_capabilities(Payload, UcanToken, Resource, Operation);
_ ->
{error, unauthorized}
end;
{error, _} ->
{error, invalid_ucan}
end.
%% @private Validate UCAN capabilities and check revocation.
-spec validate_ucan_capabilities(Payload :: map(), UcanToken :: binary(),
Resource :: binary(), Operation :: operation()) ->
auth_result().
validate_ucan_capabilities(Payload, UcanToken, Resource, Operation) ->
%% Check expiration
case is_ucan_expired(Payload) of
true ->
{error, expired_ucan};
false ->
%% Check capabilities
Capabilities = maps:get(<<"cap">>, Payload, []),
case check_capability_list(Capabilities, Resource, Operation) of
true ->
%% Check if UCAN is revoked
check_ucan_not_revoked(Payload, UcanToken);
false ->
{error, insufficient_capability}
end
end.
%% @private Check if a UCAN has been revoked.
-spec check_ucan_not_revoked(Payload :: map(), UcanToken :: binary()) ->
auth_result().
check_ucan_not_revoked(Payload, UcanToken) ->
IssuerDID = maps:get(<<"iss">>, Payload, undefined),
case IssuerDID of
undefined ->
%% No issuer - can't check revocation, allow
{ok, authorized};
_ ->
%% Compute CID and check revocation
CID = macula_ucan_revocation:compute_ucan_cid(UcanToken),
case macula_ucan_revocation:is_revoked(IssuerDID, CID) of
true ->
{error, revoked_ucan};
false ->
{ok, authorized}
end
end.
%% @private Check if any capability in the list grants access
-spec check_capability_list(Capabilities :: [map()], Resource :: binary(),
Operation :: operation()) -> boolean().
check_capability_list([], _Resource, _Operation) ->
false;
check_capability_list([Cap | Rest], Resource, Operation) ->
case check_capability_match(Cap, Resource, Operation) of
true -> true;
false -> check_capability_list(Rest, Resource, Operation)
end.
%% @doc Check if a capability grants access to a resource for an operation.
%%
%% Capability format: `#{<<"with">> => Resource, <<"can">> => Operation}'
%%
%% Wildcards supported:
%% - `io.macula.rgfaber.*' matches any resource in namespace
%% - `mesh:*' matches any mesh operation
-spec check_capability_match(Capability :: map(), Resource :: binary(),
Operation :: operation()) -> boolean().
check_capability_match(Capability, Resource, Operation) when is_map(Capability) ->
CapWith = maps:get(<<"with">>, Capability, <<>>),
CapCan = maps:get(<<"can">>, Capability, <<>>),
match_resource(CapWith, Resource) andalso match_operation(CapCan, Operation);
check_capability_match(_, _, _) ->
false.
%% @private Match resource pattern against actual resource
-spec match_resource(Pattern :: binary(), Resource :: binary()) -> boolean().
match_resource(Pattern, Resource) when Pattern =:= Resource ->
true;
match_resource(Pattern, Resource) ->
%% Check wildcard pattern
case binary:match(Pattern, <<"*">>) of
nomatch ->
false;
{Pos, 1} ->
%% Pattern like "io.macula.rgfaber.*"
Prefix = binary:part(Pattern, 0, Pos),
case binary:match(Resource, Prefix) of
{0, _} -> true;
_ -> false
end
end.
%% @private Match operation pattern against actual operation
-spec match_operation(Pattern :: binary(), Operation :: binary()) -> boolean().
match_operation(Pattern, Operation) when Pattern =:= Operation ->
true;
match_operation(<<"mesh:*">>, <<"mesh:", _/binary>>) ->
true;
match_operation(<<"*">>, _Operation) ->
true;
match_operation(_, _) ->
false.
%%====================================================================
%% DID Operations
%%====================================================================
%% @doc Resolve and validate a caller DID.
%%
%% Parses the DID and returns the parsed components if valid.
%% Uses `macula_did_cache' for performance.
-spec resolve_caller_did(CallerDID :: did()) ->
{ok, Components :: map()} | {error, invalid_did}.
resolve_caller_did(CallerDID) when is_binary(CallerDID) ->
macula_did_cache:get_or_parse(CallerDID);
resolve_caller_did(_) ->
{error, invalid_did}.
%% @doc Extract the identity portion from a DID.
%%
%% `did:macula:io.macula.rgfaber' → `io.macula.rgfaber'
%%
%% Uses `macula_did_cache' for performance - repeated lookups for the same
%% DID return cached results without re-parsing.
-spec extract_identity_from_did(DID :: did()) ->
{ok, Identity :: binary()} | {error, invalid_did}.
extract_identity_from_did(DID) when is_binary(DID) ->
case macula_did_cache:get_or_parse(DID) of
{ok, #{<<"identity">> := Identity}} ->
{ok, Identity};
_ ->
{error, invalid_did}
end;
extract_identity_from_did(_) ->
{error, invalid_did}.
%%====================================================================
%% Internal Helpers
%%====================================================================
%% @private Check ownership first, fall back to UCAN validation
-spec check_with_ucan_fallback(CallerDID :: did(), Namespace :: namespace(),
Resource :: binary(), UcanToken :: ucan_token(),
Operation :: operation()) ->
auth_result().
check_with_ucan_fallback(CallerDID, Namespace, Resource, UcanToken, Operation) ->
case check_namespace_ownership(CallerDID, Namespace) of
{ok, owner} ->
{ok, authorized};
{ok, ancestor} ->
{ok, authorized};
{error, not_owner} ->
%% Try UCAN validation
validate_ucan_for_operation(UcanToken, CallerDID, Resource, Operation)
end.
%% @private Log authorization result to audit system.
%% This is a fire-and-forget operation that should not affect authorization.
-spec audit_result(Operation :: atom(), CallerDID :: did(),
Resource :: binary(), Result :: auth_result()) -> ok.
audit_result(Operation, CallerDID, Resource, {ok, authorized}) ->
maybe_log_authorized(Operation, CallerDID, Resource);
audit_result(Operation, CallerDID, Resource, {error, Reason}) ->
maybe_log_denied(Operation, CallerDID, Resource, Reason).
%% @private Log authorized if audit server is running
maybe_log_authorized(Operation, CallerDID, Resource) ->
case whereis(macula_authorization_audit) of
undefined -> ok;
_Pid -> macula_authorization_audit:log_authorized(Operation, CallerDID, Resource)
end.
%% @private Log denied if audit server is running
maybe_log_denied(Operation, CallerDID, Resource, Reason) ->
case whereis(macula_authorization_audit) of
undefined -> ok;
_Pid -> macula_authorization_audit:log_denied(Operation, CallerDID, Resource, Reason)
end.
%%====================================================================
%% DID Parsing (delegated to macula_did_cache)
%%====================================================================
%% DID parsing is now handled by macula_did_cache for performance.
%% See macula_did_cache:get_or_parse/1 for cached DID parsing.
%%====================================================================
%% Inline UCAN Decoding (no external dependency)
%%====================================================================
%% @private Decode a UCAN token (JWT format) without signature verification.
%% WARNING: This does NOT verify the signature - for authorization checks
%% the token signature should be verified separately.
-spec decode_ucan(Token :: binary()) -> {ok, Payload :: map()} | {error, term()}.
decode_ucan(Token) when is_binary(Token) ->
case binary:split(Token, <<".">>, [global]) of
[_HeaderB64, PayloadB64, _SignatureB64] ->
case base64_url_decode(PayloadB64) of
{ok, PayloadJson} ->
case json_decode(PayloadJson) of
{ok, Payload} when is_map(Payload) -> {ok, Payload};
_ -> {error, invalid_token}
end;
_ ->
{error, invalid_token}
end;
_ ->
{error, invalid_token}
end;
decode_ucan(_) ->
{error, invalid_token}.
%% @private Check if UCAN is expired based on payload exp field
-spec is_ucan_expired(Payload :: map()) -> boolean().
is_ucan_expired(Payload) when is_map(Payload) ->
case maps:get(<<"exp">>, Payload, null) of
null -> false;
Exp when is_integer(Exp) ->
Now = erlang:system_time(second),
Now > Exp;
_ -> false
end.
%% @private URL-safe base64 decode
-spec base64_url_decode(Encoded :: binary()) -> {ok, binary()} | {error, term()}.
base64_url_decode(Encoded) ->
try
%% Convert URL-safe to standard base64
B64Std = binary:replace(
binary:replace(Encoded, <<"-">>, <<"+">>, [global]),
<<"_">>, <<"/">>, [global]),
%% Add padding if needed
Padded = case byte_size(B64Std) rem 4 of
0 -> B64Std;
2 -> <<B64Std/binary, "==">>;
3 -> <<B64Std/binary, "=">>
end,
{ok, base64:decode(Padded)}
catch
_:_ -> {error, invalid_base64}
end.
%% @private Simple JSON decoding for UCAN payloads
-spec json_decode(Binary :: binary()) -> {ok, term()} | {error, term()}.
json_decode(Binary) when is_binary(Binary) ->
try
%% Use OTP 27+ built-in json module if available, otherwise simple parser
case erlang:function_exported(json, decode, 1) of
true ->
{ok, json:decode(Binary)};
false ->
{ok, simple_json_decode(Binary)}
end
catch
_:_ -> {error, invalid_json}
end.
%% @private Simple JSON object decoder (for environments without json module)
-spec simple_json_decode(Binary :: binary()) -> map().
simple_json_decode(Binary) ->
{Value, _Rest} = decode_value(skip_ws(Binary)),
Value.
%% @private Decode JSON value
decode_value(<<"null", Rest/binary>>) -> {null, Rest};
decode_value(<<"true", Rest/binary>>) -> {true, Rest};
decode_value(<<"false", Rest/binary>>) -> {false, Rest};
decode_value(<<"\"", Rest/binary>>) -> decode_string(Rest, <<>>);
decode_value(<<"[", Rest/binary>>) -> decode_array(skip_ws(Rest), []);
decode_value(<<"{", Rest/binary>>) -> decode_object(skip_ws(Rest), #{});
decode_value(<<C, _/binary>> = Bin) when C =:= $- orelse (C >= $0 andalso C =< $9) ->
decode_number(Bin).
%% @private Decode JSON string
decode_string(<<"\\\"", Rest/binary>>, Acc) -> decode_string(Rest, <<Acc/binary, "\"">>);
decode_string(<<"\\\\", Rest/binary>>, Acc) -> decode_string(Rest, <<Acc/binary, "\\">>);
decode_string(<<"\\n", Rest/binary>>, Acc) -> decode_string(Rest, <<Acc/binary, "\n">>);
decode_string(<<"\\t", Rest/binary>>, Acc) -> decode_string(Rest, <<Acc/binary, "\t">>);
decode_string(<<"\"", Rest/binary>>, Acc) -> {Acc, Rest};
decode_string(<<C, Rest/binary>>, Acc) -> decode_string(Rest, <<Acc/binary, C>>).
%% @private Decode JSON array
decode_array(<<"]", Rest/binary>>, Acc) -> {lists:reverse(Acc), Rest};
decode_array(Bin, Acc) ->
{Value, Rest} = decode_value(Bin),
Rest2 = skip_ws(Rest),
case Rest2 of
<<",", Rest3/binary>> -> decode_array(skip_ws(Rest3), [Value | Acc]);
<<"]", Rest3/binary>> -> {lists:reverse([Value | Acc]), Rest3}
end.
%% @private Decode JSON object
decode_object(<<"}", Rest/binary>>, Acc) -> {Acc, Rest};
decode_object(<<"\"", Rest/binary>>, Acc) ->
{Key, Rest2} = decode_string(Rest, <<>>),
<<":", Rest3/binary>> = skip_ws(Rest2),
{Value, Rest4} = decode_value(skip_ws(Rest3)),
Rest5 = skip_ws(Rest4),
case Rest5 of
<<",", Rest6/binary>> -> decode_object(skip_ws(Rest6), maps:put(Key, Value, Acc));
<<"}", Rest6/binary>> -> {maps:put(Key, Value, Acc), Rest6}
end.
%% @private Decode JSON number
decode_number(Bin) ->
{NumStr, Rest} = take_number(Bin, <<>>),
Num = case binary:match(NumStr, [<<".">>, <<"e">>, <<"E">>]) of
nomatch -> binary_to_integer(NumStr);
_ -> binary_to_float(NumStr)
end,
{Num, Rest}.
%% @private Take number characters
take_number(<<C, Rest/binary>>, Acc) when C =:= $- orelse C =:= $+ orelse C =:= $.
orelse C =:= $e orelse C =:= $E orelse (C >= $0 andalso C =< $9) ->
take_number(Rest, <<Acc/binary, C>>);
take_number(Rest, Acc) -> {Acc, Rest}.
%% @private Skip whitespace
skip_ws(<<C, Rest/binary>>) when C =:= $\s orelse C =:= $\t orelse C =:= $\n orelse C =:= $\r ->
skip_ws(Rest);
skip_ws(Bin) -> Bin.