Skip to main content

src/aws@internal@codec@json_timestamp.erl

-module(aws@internal@codec@json_timestamp).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/aws/internal/codec/json_timestamp.gleam").
-export([parse_iso8601/1, parse_http_date/1, format_iso8601/1, format_http_date/1, decoder/0, decoder_precise/0, timestamp_to_int/1, int_to_timestamp/1, format_iso8601_precise/1, format_http_date_precise/1, epoch_seconds_text/1, encode_epoch_seconds/1]).
-export_type([timestamp/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " JSON-side timestamp decoder. AWS protocol families disagree on\n"
    " the wire shape of `@timestamp` fields:\n"
    "\n"
    "   * awsJson1_0 / awsJson1_1 default: epoch-seconds number\n"
    "     (Int OR Float — services like KitchenSinkOperation send doubles)\n"
    "   * restJson1 / restXml default: ISO 8601 string\n"
    "   * Any protocol with `@timestampFormat(\"http-date\")`: HTTP-date string\n"
    "\n"
    " We never know which the server will send for a given field —\n"
    " fractional-second tests in particular surface Floats where the\n"
    " schema declares an Int member. Returning `option.None` on decode\n"
    " failure would mask data; instead we accept all three forms and\n"
    " coerce to `Int` (epoch seconds).\n"
).

-type timestamp() :: {timestamp, integer(), integer()}.

-file("src/aws/internal/codec/json_timestamp.gleam", 28).
?DOC(
    " Parse an ISO 8601 timestamp string (\"2024-01-02T03:04:05Z\") into a\n"
    " `Timestamp` at second precision. `nanoseconds` is always 0 until\n"
    " the FFI gains fractional-second support. Used by the header\n"
    " extractor for members carrying `@timestampFormat(\"date-time\")`.\n"
).
-spec parse_iso8601(binary()) -> {ok, timestamp()} | {error, nil}.
parse_iso8601(S) ->
    case aws_ffi:parse_iso8601(S) of
        {ok, N} ->
            {ok, {timestamp, N, 0}};

        {error, _} ->
            {error, nil}
    end.

-file("src/aws/internal/codec/json_timestamp.gleam", 39).
?DOC(
    " Parse an HTTP-date timestamp string (\"Tue, 29 Apr 2014 18:30:38 GMT\")\n"
    " into a `Timestamp`. The default `@timestampFormat` for\n"
    " `@httpHeader` bindings per Smithy core — used by `Last-Modified`,\n"
    " `Expires`, `Date`, etc.\n"
).
-spec parse_http_date(binary()) -> {ok, timestamp()} | {error, nil}.
parse_http_date(S) ->
    case aws_ffi:parse_http_date(S) of
        {ok, N} ->
            {ok, {timestamp, N, 0}};

        {error, _} ->
            {error, nil}
    end.

-file("src/aws/internal/codec/json_timestamp.gleam", 48).
?DOC(" `2024-01-02T03:04:05Z`. Inverse of `parse_iso8601_ffi`.\n").
-spec format_iso8601(integer()) -> binary().
format_iso8601(Seconds) ->
    aws_ffi:format_iso8601(Seconds).

-file("src/aws/internal/codec/json_timestamp.gleam", 53).
?DOC(
    " `Tue, 29 Apr 2014 18:30:38 GMT`. Used by\n"
    " `@timestampFormat(\"http-date\")` body fields and headers.\n"
).
-spec format_http_date(integer()) -> binary().
format_http_date(Seconds) ->
    aws_ffi:format_http_date(Seconds).

-file("src/aws/internal/codec/json_timestamp.gleam", 63).
?DOC(
    " Decode `Int | Float | String` into epoch seconds. Falls back to 0\n"
    " when none of the forms match, which matches `gleam/dynamic`'s\n"
    " default `decode.failure` payload style and lets the caller surface\n"
    " the decode failure via the standard `Decoder` machinery rather than\n"
    " crashing on bad data.\n"
).
-spec decoder() -> gleam@dynamic@decode:decoder(integer()).
decoder() ->
    gleam@dynamic@decode:one_of(
        {decoder, fun gleam@dynamic@decode:decode_int/1},
        [gleam@dynamic@decode:then(
                {decoder, fun gleam@dynamic@decode:decode_float/1},
                fun(F) -> gleam@dynamic@decode:success(erlang:trunc(F)) end
            ),
            gleam@dynamic@decode:then(
                {decoder, fun gleam@dynamic@decode:decode_string/1},
                fun(S) -> case aws_ffi:parse_iso8601(S) of
                        {ok, N} ->
                            gleam@dynamic@decode:success(N);

                        {error, _} ->
                            case aws_ffi:parse_http_date(S) of
                                {ok, N@1} ->
                                    gleam@dynamic@decode:success(N@1);

                                {error, _} ->
                                    gleam@dynamic@decode:failure(
                                        0,
                                        <<"timestamp: unrecognised wire form"/utf8>>
                                    )
                            end
                    end end
            )]
    ).

-file("src/aws/internal/codec/json_timestamp.gleam", 223).
-spec float_floor(float()) -> integer().
float_floor(F) ->
    erlang:trunc(math:floor(F)).

-file("src/aws/internal/codec/json_timestamp.gleam", 198).
-spec float_to_timestamp(float()) -> timestamp().
float_to_timestamp(F) ->
    Seconds = float_floor(F),
    Fractional = F - erlang:float(Seconds),
    Nanos = erlang:trunc(Fractional * 1000000000.0),
    case Nanos < 0 of
        true ->
            {timestamp, Seconds, 0};

        false ->
            case Nanos > 999999999 of
                true ->
                    {timestamp, Seconds + 1, Nanos - 1000000000};

                false ->
                    {timestamp, Seconds, Nanos}
            end
    end.

-file("src/aws/internal/codec/json_timestamp.gleam", 110).
?DOC(
    " Decode an AWS timestamp wire value into a `Timestamp` that\n"
    " preserves sub-second precision when present.\n"
    "\n"
    " * `Int` → `Timestamp(seconds: n, nanoseconds: 0)`\n"
    " * `Float` → fractional seconds extracted via floor + scaling\n"
    "   to nanoseconds. Negative timestamps (pre-1970) handled by\n"
    "   normalising the fractional remainder so `nanoseconds` is\n"
    "   always in `[0, 999_999_999]`.\n"
    " * `String` → ISO 8601 / HTTP-date, parsed with second-level\n"
    "   precision today (fractional ISO timestamps would need an\n"
    "   FFI extension; tracked separately).\n"
).
-spec decoder_precise() -> gleam@dynamic@decode:decoder(timestamp()).
decoder_precise() ->
    gleam@dynamic@decode:one_of(
        gleam@dynamic@decode:then(
            {decoder, fun gleam@dynamic@decode:decode_int/1},
            fun(N) -> gleam@dynamic@decode:success({timestamp, N, 0}) end
        ),
        [gleam@dynamic@decode:then(
                {decoder, fun gleam@dynamic@decode:decode_float/1},
                fun(F) ->
                    gleam@dynamic@decode:success(float_to_timestamp(F))
                end
            ),
            gleam@dynamic@decode:then(
                {decoder, fun gleam@dynamic@decode:decode_string/1},
                fun(S) -> case aws_ffi:parse_iso8601(S) of
                        {ok, N@1} ->
                            gleam@dynamic@decode:success({timestamp, N@1, 0});

                        {error, _} ->
                            case aws_ffi:parse_http_date(S) of
                                {ok, N@2} ->
                                    gleam@dynamic@decode:success(
                                        {timestamp, N@2, 0}
                                    );

                                {error, _} ->
                                    gleam@dynamic@decode:failure(
                                        {timestamp, 0, 0},
                                        <<"timestamp: unrecognised wire form"/utf8>>
                                    )
                            end
                    end end
            )]
    ).

-file("src/aws/internal/codec/json_timestamp.gleam", 138).
?DOC(
    " Convert `Timestamp` back to integer epoch seconds, dropping the\n"
    " nanosecond component. Symmetric with `int_to_timestamp` and\n"
    " useful when callers want to bridge to the existing `Int` API.\n"
).
-spec timestamp_to_int(timestamp()) -> integer().
timestamp_to_int(T) ->
    erlang:element(2, T).

-file("src/aws/internal/codec/json_timestamp.gleam", 144).
?DOC(
    " Promote an `Int` epoch seconds value to a `Timestamp` with\n"
    " zero nanoseconds. Symmetric with `timestamp_to_int`.\n"
).
-spec int_to_timestamp(integer()) -> timestamp().
int_to_timestamp(Seconds) ->
    {timestamp, Seconds, 0}.

-file("src/aws/internal/codec/json_timestamp.gleam", 154).
?DOC(
    " Format a `Timestamp` as ISO 8601 (`2024-01-02T03:04:05Z`).\n"
    " Wire-equivalent to `format_iso8601(t.seconds)` — sub-second\n"
    " precision is dropped because the underlying FFI doesn't\n"
    " emit fractional seconds yet. Promoted to a distinct entry\n"
    " point so the codegen can call this from `Timestamp`-typed\n"
    " code paths without a redundant `timestamp_to_int` step.\n"
).
-spec format_iso8601_precise(timestamp()) -> binary().
format_iso8601_precise(T) ->
    aws_ffi:format_iso8601(erlang:element(2, T)).

-file("src/aws/internal/codec/json_timestamp.gleam", 162).
?DOC(
    " Format a `Timestamp` as HTTP-date\n"
    " (`Tue, 29 Apr 2014 18:30:38 GMT`). Same nanosecond caveat as\n"
    " `format_iso8601_precise` — HTTP-date is whole-second precision\n"
    " by definition.\n"
).
-spec format_http_date_precise(timestamp()) -> binary().
format_http_date_precise(T) ->
    aws_ffi:format_http_date(erlang:element(2, T)).

-file("src/aws/internal/codec/json_timestamp.gleam", 171).
?DOC(
    " Render a `Timestamp` as a plain epoch-seconds integer string\n"
    " (`\"1700000000\"`). Used by URI / query / header / XML emitters\n"
    " when `@timestampFormat(\"epoch-seconds\")` is in force — the\n"
    " wire form is the integer-as-decimal-digits, no fractional\n"
    " component.\n"
).
-spec epoch_seconds_text(timestamp()) -> binary().
epoch_seconds_text(T) ->
    erlang:integer_to_binary(erlang:element(2, T)).

-file("src/aws/internal/codec/json_timestamp.gleam", 186).
?DOC(
    " Encode a `Timestamp` as a JSON epoch-seconds number. When\n"
    " `nanoseconds == 0` we emit a JSON Int (`1700000000`) so the\n"
    " wire bytes match the existing `json.int` path the codegen\n"
    " uses for the `Int` API — flipping a member to precise must\n"
    " not perturb the wire form for callers who never set\n"
    " nanoseconds. When `nanoseconds > 0` we emit a JSON Float\n"
    " (`1700000000.5`) so the fractional component reaches the\n"
    " server intact.\n"
).
-spec encode_epoch_seconds(timestamp()) -> gleam@json:json().
encode_epoch_seconds(T) ->
    case erlang:element(3, T) of
        0 ->
            gleam@json:int(erlang:element(2, T));

        _ ->
            gleam@json:float(
                erlang:float(erlang:element(2, T)) + (erlang:float(
                    erlang:element(3, T)
                )
                / 1000000000.0)
            )
    end.