Browse Source

Merge pull request #67 from sile/encode-ip-address

Add `jsone:ip_address_to_json_string/1` function
Takeru Ohta 3 years ago
parent
commit
ed90938834
4 changed files with 202 additions and 1 deletions
  1. 29 1
      src/jsone.erl
  2. 123 0
      src/jsone_inet.erl
  3. 9 0
      test/jsone_encode_tests.erl
  4. 41 0
      test/jsone_inet_tests.erl

+ 29 - 1
src/jsone.erl

@@ -34,7 +34,8 @@
          try_decode/1, try_decode/2,
          encode/1, encode/2,
          try_encode/1, try_encode/2,
-         term_to_json_string/1
+         term_to_json_string/1,
+         ip_address_to_json_string/1
         ]).
 
 -export_type([
@@ -413,3 +414,30 @@ try_encode(JsonValue, Options) ->
 -spec term_to_json_string(term()) -> {ok, json_string()} | error.
 term_to_json_string(X) ->
     {ok, list_to_binary(io_lib:format("~p", [X]))}.
+
+%% @doc Convert an IP address into a text representation.
+%%
+%% This function can be specified as the value of the `map_unknown_value' encoding option.
+%%
+%% This function formats IPv6 addresses by following the recommendation defined in RFC 5952.
+%% Note that the trailing 32 bytes of special IPv6 addresses such as IPv4-Compatible (::X.X.X.X),
+%% IPv4-Mapped (::ffff:X.X.X.X), IPv4-Translated (::ffff:0:X.X.X.X) and IPv4/IPv6 translation
+%% (64:ff9b::X.X.X.X and 64:ff9b:1::X.X.X.X ~ 64:ff9b:1:ffff:ffff:ffff:X.X.X.X) are formatted
+%% using the IPv4 format.
+%%
+%% ```
+%% > EncodeOpt = [{map_unknown_value, fun jsone:ip_address_to_json_string/1}].
+%%
+%% > jsone:encode(#{ip => {127, 0, 0, 1}}, EncodeOpt).
+%% <<"{\"ip\":\"127.0.0.1\"}">>
+%%
+%% > {ok, Addr} = inet:parse_address("2001:DB8:0000:0000:0001:0000:0000:0001").
+%% > jsone:encode(Addr, EncodeOpt).
+%% <<"\"2001:db8::1:0:0:1\"">>
+%%
+%% > jsone:encode([foo, {0, 0, 0, 0, 0, 16#FFFF, 16#7F00, 16#0001}], EncodeOpt).
+%% <<"[\"foo\",\"::ffff:127.0.0.1\"]">>
+%% '''
+-spec ip_address_to_json_string(inet:ip_address()|any()) -> {ok, json_string()} | error.
+ip_address_to_json_string(X) ->
+    jsone_inet:ip_address_to_json_string(X).

+ 123 - 0
src/jsone_inet.erl

@@ -0,0 +1,123 @@
+%%% @doc Utility functions for `inet' module
+%%% @private
+%%% @end
+%%%
+%%% Copyright (c) 2013-2021, Takeru Ohta <phjgt308@gmail.com>
+%%%
+%%% The MIT License
+%%%
+%%% Permission is hereby granted, free of charge, to any person obtaining a copy
+%%% of this software and associated documentation files (the "Software"), to deal
+%%% in the Software without restriction, including without limitation the rights
+%%% to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+%%% copies of the Software, and to permit persons to whom the Software is
+%%% furnished to do so, subject to the following conditions:
+%%%
+%%% The above copyright notice and this permission notice shall be included in
+%%% all copies or substantial portions of the Software.
+%%%
+%%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+%%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+%%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+%%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+%%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+%%% OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+%%% THE SOFTWARE.
+%%%
+%%%---------------------------------------------------------------------------------------
+-module(jsone_inet).
+
+%%--------------------------------------------------------------------------------
+%% Exported API
+%%--------------------------------------------------------------------------------
+-export([ip_address_to_json_string/1]).
+
+%%--------------------------------------------------------------------------------
+%% Macros
+%%--------------------------------------------------------------------------------
+-define(IS_IPV4_RANGE(X), is_integer(X) andalso 0 =< X andalso X =< 255).
+-define(IS_IPV4(A, B, C, D),
+        ?IS_IPV4_RANGE(A) andalso ?IS_IPV4_RANGE(B) andalso ?IS_IPV4_RANGE(C) andalso ?IS_IPV4_RANGE(D)).
+
+-define(IS_IPV6_RANGE(X), is_integer(X) andalso 0 =< X andalso X =< 65535).
+-define(IS_IPV6(A, B, C, D, E, F, G, H),
+        ?IS_IPV6_RANGE(A) andalso ?IS_IPV6_RANGE(B) andalso ?IS_IPV6_RANGE(C) andalso ?IS_IPV6_RANGE(D) andalso
+        ?IS_IPV6_RANGE(E) andalso ?IS_IPV6_RANGE(F) andalso ?IS_IPV6_RANGE(G) andalso ?IS_IPV6_RANGE(H)).
+
+%%--------------------------------------------------------------------------------
+%% Exported Functions
+%%--------------------------------------------------------------------------------
+
+%% @doc Convert an IP address into a text representation.
+%%
+%% Please refer to the doc of `jsone:ip_address_to_json_string/1' for the detail.
+-spec ip_address_to_json_string(inet:ip_address()|any()) -> {ok, jsone:json_string()} | error.
+ip_address_to_json_string({A, B, C, D}) when ?IS_IPV4(A, B, C, D) ->
+    {ok, iolist_to_binary(io_lib:format("~p.~p.~p.~p", [A, B, C, D]))};
+ip_address_to_json_string({A, B, C, D, E, F, G, H}) when ?IS_IPV6(A, B, C, D, E, F, G, H) ->
+    Text =
+        case {A, B, C, D, E, F} of
+            {0, 0, 0, 0, 0, 0} ->
+                %% IPv4-compatible address
+                io_lib:format("::~p.~p.~p.~p", [G bsr 8, G band 16#ff, H bsr 8, H band 16#ff]);
+            {0, 0, 0, 0, 0, 16#ffff} ->
+                %% IPv4-mapped address
+                io_lib:format("::ffff:~p.~p.~p.~p", [G bsr 8, G band 16#ff, H bsr 8, H band 16#ff]);
+            {0, 0, 0, 0, 16#ffff, 0} ->
+                %% IPv4-translated address
+                io_lib:format("::ffff:0:~p.~p.~p.~p", [G bsr 8, G band 16#ff, H bsr 8, H band 16#ff]);
+            {16#64, 16#ff9b, 0, 0, 0, 0} ->
+                %% IPv4/IPv6 translation
+                io_lib:format("64:ff9b::~p.~p.~p.~p", [G bsr 8, G band 16#ff, H bsr 8, H band 16#ff]);
+            {16#64, 16#ff9b, 1, _, _, _} ->
+                %% IPv4/IPv6 translation
+                {Prefix, _} = format_ipv6([A, B, C, D, E, F], 0, 0),
+                Last = lists:flatten(io_lib:format("~p.~p.~p.~p", [G bsr 8, G band 16#ff, H bsr 8, H band 16#ff])),
+                string:join(Prefix ++ [Last], ":");
+            _ ->
+                format_ipv6([A, B, C, D, E, F, G, H])
+        end,
+    {ok, iolist_to_binary(Text)};
+ip_address_to_json_string(_) ->
+    error.
+
+%%--------------------------------------------------------------------------------
+%% Internal Functions
+%%--------------------------------------------------------------------------------
+-spec format_ipv6([0..65535]) -> string().
+format_ipv6(Xs) ->
+    case format_ipv6(Xs, 0, 0) of
+        {Ys, shortening} -> [$: | string:join(Ys, ":")];
+        {Ys, _} ->
+            Text = string:join(Ys, ":"),
+            case lists:last(Text) of
+                $: -> [Text | ":"];
+                _  -> Text
+            end
+    end.
+
+-spec format_ipv6([0..65535], non_neg_integer(), non_neg_integer()) -> {[string()], not_shortened | shortening | shortened}.
+format_ipv6([], _Zeros, _MaxZeros) ->
+    {[], not_shortened};
+format_ipv6([X | Xs], Zeros0, MaxZeros) ->
+    Zeros1 =
+        case X of
+            0 -> Zeros0 + 1;
+            _ -> 0
+        end,
+    Shorten = Zeros1 > MaxZeros andalso Zeros1 > 1,
+    case format_ipv6(Xs, Zeros1, max(Zeros1, MaxZeros)) of
+        {Ys, not_shortened} ->
+            case Shorten of
+                true  -> {["" | Ys], shortening};
+                false -> {[to_hex(X) | Ys], not_shortened}
+            end;
+        {Ys, shortening} when X =:= 0 ->
+            {Ys, shortening};
+        {Ys, _} ->
+            {[to_hex(X) | Ys], shortened}
+    end.
+
+-spec to_hex(0..65535) -> string().
+to_hex(N) ->
+    string:lowercase(integer_to_list(N, 16)).

+ 9 - 0
test/jsone_encode_tests.erl

@@ -316,6 +316,15 @@ encode_test_() ->
               Expected = <<"[\"{foo}\\n\"]">>,
               ?assertEqual(Expected, jsone:encode(Input, [{map_unknown_value, MapFun}]))
       end},
+     {"IP address",
+      fun () ->
+              Input = #{ip => {127, 0, 0, 1}},
+              Expected = <<"{\"ip\":\"127.0.0.1\"}">>,
+              ?assertEqual(Expected, jsone:encode(Input, [{map_unknown_value, fun jsone:ip_address_to_json_string/1}])),
+
+              %% Without `map_unknown_value' option.
+              ?assertMatch({error, _}, jsone:try_encode(Input, [{map_unknown_value, undefined}]))
+      end},
 
      %% Others
      {"compound data",

+ 41 - 0
test/jsone_inet_tests.erl

@@ -0,0 +1,41 @@
+%% Copyright (c) 2013-2021, Takeru Ohta <phjgt308@gmail.com>
+-module(jsone_inet_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+
+format_ipv4_test() ->
+    Expected = <<"127.0.0.1">>,
+    {ok, Addr} = inet:parse_ipv4_address(binary_to_list(Expected)),
+    {ok, Actual} = jsone_inet:ip_address_to_json_string(Addr),
+    ?assertEqual(Actual, Expected).
+
+format_ipv6_test() ->
+    Addresses =
+        [
+         <<"::127.0.0.1">>,
+         <<"::ffff:192.0.2.1">>,
+         <<"::ffff:0:255.255.255.255">>,
+         <<"64:ff9b::0.0.0.0">>,
+         <<"64:ff9b:1::192.168.1.1">>,
+         <<"64:ff9b:1::1:192.168.1.1">>,
+         <<"::1:2:3:2001:db8">>,
+         <<"2001:db8::">>,
+         <<"2001:db8::1">>,
+         <<"2001:db8::1:0:0:1">>,
+         <<"2001:db8:0:1:1:1:1:1">>,
+         <<"2001:0:0:1::1">>,
+         <<"2001:db8:85a3::8a2e:370:7334">>
+        ],
+    lists:foreach(
+      fun (Expected) ->
+              {ok, Addr} = inet:parse_ipv6_address(binary_to_list(Expected)),
+              {ok, Bin} = jsone_inet:ip_address_to_json_string(Addr),
+              ?assertEqual(Expected, Bin)
+      end,
+      Addresses).
+
+invalid_ip_addr_test() ->
+    ?assertEqual(jsone_inet:ip_address_to_json_string(foo), error),
+    ?assertEqual(jsone_inet:ip_address_to_json_string({1, 2, 3}), error),
+    ?assertEqual(jsone_inet:ip_address_to_json_string({0, 10000, 0, 0}), error),
+    ?assertEqual(jsone_inet:ip_address_to_json_string({-1, 0, 0, 0, 0, 0, 0, 0}), error).