Skip to main content

src/aws/internal/codec/json_timestamp.gleam

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

import gleam/dynamic/decode
import gleam/json

@external(erlang, "aws_ffi", "parse_iso8601")
fn parse_iso8601_ffi(t: String) -> Result(Int, Nil)

@external(erlang, "aws_ffi", "parse_http_date")
fn parse_http_date_ffi(t: String) -> Result(Int, Nil)

/// Parse an ISO 8601 timestamp string ("2024-01-02T03:04:05Z") into a
/// `Timestamp` at second precision. `nanoseconds` is always 0 until
/// the FFI gains fractional-second support. Used by the header
/// extractor for members carrying `@timestampFormat("date-time")`.
pub fn parse_iso8601(s: String) -> Result(Timestamp, Nil) {
  case parse_iso8601_ffi(s) {
    Ok(n) -> Ok(Timestamp(seconds: n, nanoseconds: 0))
    Error(_) -> Error(Nil)
  }
}

/// Parse an HTTP-date timestamp string ("Tue, 29 Apr 2014 18:30:38 GMT")
/// into a `Timestamp`. The default `@timestampFormat` for
/// `@httpHeader` bindings per Smithy core — used by `Last-Modified`,
/// `Expires`, `Date`, etc.
pub fn parse_http_date(s: String) -> Result(Timestamp, Nil) {
  case parse_http_date_ffi(s) {
    Ok(n) -> Ok(Timestamp(seconds: n, nanoseconds: 0))
    Error(_) -> Error(Nil)
  }
}

/// `2024-01-02T03:04:05Z`. Inverse of `parse_iso8601_ffi`.
@external(erlang, "aws_ffi", "format_iso8601")
pub fn format_iso8601(seconds: Int) -> String

/// `Tue, 29 Apr 2014 18:30:38 GMT`. Used by
/// `@timestampFormat("http-date")` body fields and headers.
@external(erlang, "aws_ffi", "format_http_date")
pub fn format_http_date(seconds: Int) -> String

@external(erlang, "erlang", "trunc")
fn float_to_int(f: Float) -> Int

/// Decode `Int | Float | String` into epoch seconds. Falls back to 0
/// when none of the forms match, which matches `gleam/dynamic`'s
/// default `decode.failure` payload style and lets the caller surface
/// the decode failure via the standard `Decoder` machinery rather than
/// crashing on bad data.
pub fn decoder() -> decode.Decoder(Int) {
  decode.one_of(decode.int, [
    decode.then(decode.float, fn(f) { decode.success(float_to_int(f)) }),
    decode.then(decode.string, fn(s) {
      case parse_iso8601_ffi(s) {
        Ok(n) -> decode.success(n)
        Error(_) ->
          case parse_http_date_ffi(s) {
            Ok(n) -> decode.success(n)
            Error(_) -> decode.failure(0, "timestamp: unrecognised wire form")
          }
      }
    }),
  ])
}

/// Higher-precision timestamp value with nanosecond resolution.
/// AWS services like CloudWatch, EventBridge, and metric APIs ship
/// `Float` epoch-seconds wire values that carry sub-second
/// precision; the existing `Int` decoder truncates them. Callers
/// who need the precision use `Timestamp` end-to-end:
///
/// ```gleam
/// import aws/internal/codec/json_timestamp.{type Timestamp, Timestamp}
///
/// let t = Timestamp(seconds: 1700000000, nanoseconds: 123_000_000)
/// // ⇒ 2023-11-14T22:13:20.123 UTC
/// ```
///
/// `nanoseconds` is bounded to `[0, 999_999_999]` by convention;
/// callers normalising from a Float wire value get this for free
/// (see `decoder_precise`).
pub type Timestamp {
  Timestamp(seconds: Int, nanoseconds: Int)
}

/// Decode an AWS timestamp wire value into a `Timestamp` that
/// preserves sub-second precision when present.
///
/// * `Int` → `Timestamp(seconds: n, nanoseconds: 0)`
/// * `Float` → fractional seconds extracted via floor + scaling
///   to nanoseconds. Negative timestamps (pre-1970) handled by
///   normalising the fractional remainder so `nanoseconds` is
///   always in `[0, 999_999_999]`.
/// * `String` → ISO 8601 / HTTP-date, parsed with second-level
///   precision today (fractional ISO timestamps would need an
///   FFI extension; tracked separately).
pub fn decoder_precise() -> decode.Decoder(Timestamp) {
  decode.one_of(
    decode.then(decode.int, fn(n) {
      decode.success(Timestamp(seconds: n, nanoseconds: 0))
    }),
    [
      decode.then(decode.float, fn(f) { decode.success(float_to_timestamp(f)) }),
      decode.then(decode.string, fn(s) {
        case parse_iso8601_ffi(s) {
          Ok(n) -> decode.success(Timestamp(seconds: n, nanoseconds: 0))
          Error(_) ->
            case parse_http_date_ffi(s) {
              Ok(n) -> decode.success(Timestamp(seconds: n, nanoseconds: 0))
              Error(_) ->
                decode.failure(
                  Timestamp(seconds: 0, nanoseconds: 0),
                  "timestamp: unrecognised wire form",
                )
            }
        }
      }),
    ],
  )
}

/// Convert `Timestamp` back to integer epoch seconds, dropping the
/// nanosecond component. Symmetric with `int_to_timestamp` and
/// useful when callers want to bridge to the existing `Int` API.
pub fn timestamp_to_int(t: Timestamp) -> Int {
  t.seconds
}

/// Promote an `Int` epoch seconds value to a `Timestamp` with
/// zero nanoseconds. Symmetric with `timestamp_to_int`.
pub fn int_to_timestamp(seconds: Int) -> Timestamp {
  Timestamp(seconds: seconds, nanoseconds: 0)
}

/// Format a `Timestamp` as ISO 8601 (`2024-01-02T03:04:05Z`).
/// Wire-equivalent to `format_iso8601(t.seconds)` — sub-second
/// precision is dropped because the underlying FFI doesn't
/// emit fractional seconds yet. Promoted to a distinct entry
/// point so the codegen can call this from `Timestamp`-typed
/// code paths without a redundant `timestamp_to_int` step.
pub fn format_iso8601_precise(t: Timestamp) -> String {
  format_iso8601(t.seconds)
}

/// Format a `Timestamp` as HTTP-date
/// (`Tue, 29 Apr 2014 18:30:38 GMT`). Same nanosecond caveat as
/// `format_iso8601_precise` — HTTP-date is whole-second precision
/// by definition.
pub fn format_http_date_precise(t: Timestamp) -> String {
  format_http_date(t.seconds)
}

/// Render a `Timestamp` as a plain epoch-seconds integer string
/// (`"1700000000"`). Used by URI / query / header / XML emitters
/// when `@timestampFormat("epoch-seconds")` is in force — the
/// wire form is the integer-as-decimal-digits, no fractional
/// component.
pub fn epoch_seconds_text(t: Timestamp) -> String {
  int_to_decimal(t.seconds)
}

@external(erlang, "erlang", "integer_to_binary")
fn int_to_decimal(n: Int) -> String

/// Encode a `Timestamp` as a JSON epoch-seconds number. When
/// `nanoseconds == 0` we emit a JSON Int (`1700000000`) so the
/// wire bytes match the existing `json.int` path the codegen
/// uses for the `Int` API — flipping a member to precise must
/// not perturb the wire form for callers who never set
/// nanoseconds. When `nanoseconds > 0` we emit a JSON Float
/// (`1700000000.5`) so the fractional component reaches the
/// server intact.
pub fn encode_epoch_seconds(t: Timestamp) -> json.Json {
  case t.nanoseconds {
    0 -> json.int(t.seconds)
    _ ->
      json.float(
        int_to_float(t.seconds)
        +. int_to_float(t.nanoseconds)
        /. 1_000_000_000.0,
      )
  }
}

fn float_to_timestamp(f: Float) -> Timestamp {
  // Floor-divide the float into integer seconds + fractional
  // remainder. `truncate` would round-toward-zero, breaking
  // negative timestamps where the fractional part should
  // negative-extend; we use floor instead so `nanoseconds` stays
  // in [0, 999_999_999] without sign games.
  let seconds = float_floor(f)
  let fractional = f -. int_to_float(seconds)
  let nanos = float_to_int(fractional *. 1_000_000_000.0)
  // Clamp to the valid range — floating-point error can push
  // 0.999999999 up to 1.0e9 + epsilon in pathological cases.
  case nanos < 0 {
    True -> Timestamp(seconds: seconds, nanoseconds: 0)
    False ->
      case nanos > 999_999_999 {
        True ->
          Timestamp(seconds: seconds + 1, nanoseconds: nanos - 1_000_000_000)
        False -> Timestamp(seconds: seconds, nanoseconds: nanos)
      }
  }
}

@external(erlang, "math", "floor")
fn float_floor_native(f: Float) -> Float

fn float_floor(f: Float) -> Int {
  float_to_int(float_floor_native(f))
}

@external(erlang, "erlang", "float")
fn int_to_float(n: Int) -> Float