//// Shared runtime helpers for the rest-protocol emitters //// (restJson1, restXml). Holds the URI / query / header glue that //// generated `build_*_request` functions call into for each //// `@httpLabel`, `@httpQuery`, `@httpHeader` member. import aws/internal/codec/json_timestamp import aws/internal/crypto import aws/internal/uri import gleam/bit_array import gleam/dict.{type Dict} import gleam/float import gleam/int import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string /// Substitute a single `@httpLabel` member into the URI template. /// Templates use `{Name}` or `{Name+}` (the `+` marks a greedy label /// that may contain `/`). Values are percent-encoded; greedy labels /// preserve `/` in the value. pub fn substitute_label( template: String, name: String, value: String, greedy: Bool, ) -> String { let placeholder = "{" <> name <> "}" let greedy_placeholder = "{" <> name <> "+}" let encoded = case greedy { True -> encode_path_preserve_slash(value) False -> uri.encode_segment(value) } template |> string.replace(greedy_placeholder, encoded) |> string.replace(placeholder, encoded) } /// Encode each path segment but keep the `/` separators intact. fn encode_path_preserve_slash(path: String) -> String { string.split(path, "/") |> list.map(uri.encode_segment) |> string.join("/") } /// Append a query parameter pair. Returns the resulting query string /// (without the leading `?`); call sites prepend it themselves. pub fn add_query(existing: String, name: String, value: String) -> String { let pair = uri.encode_component(name) <> "=" <> uri.encode_component(value) case existing { "" -> pair _ -> existing <> "&" <> pair } } /// Bool → query value: "true" / "false". pub fn bool_to_query(b: Bool) -> String { case b { True -> "true" False -> "false" } } /// Int → query / header value as decimal. pub fn int_to_query(n: Int) -> String { int.to_string(n) } /// Float → query / header / URI-label value. Uses Erlang's `short` /// formatter so `1.1` round-trips as the literal `"1.1"` — the AWS /// SimpleScalarProperties protocol-test corpus rejects scientific /// notation in these positions. pub fn float_to_query(f: Float) -> String { float_to_string(f) } @external(erlang, "aws_ffi", "float_short") fn float_to_string(f: Float) -> String /// `@httpHeader` on a list member emits the values comma-joined, per /// HTTP/1.1 header-folding rules. `Some(["a", "b"])` becomes /// `Name: a, b`. Empty lists drop the header entirely. pub fn maybe_set_list_header( headers: Dict(String, String), name: String, values: List(String), ) -> Dict(String, String) { // Each entry arrives already rendered as a wire string. Strings are // RFC 7230-quoted by `quote_list_string_entry` at the codegen layer // (only that type needs quoting; numeric / boolean / http-date // values are unambiguous by shape). dict.insert(headers, name, string.join(values, ", ")) } /// Append a `@requestCompression` encoding to the `Content-Encoding` /// header. Existing encodings (e.g. caller-set `Content-Encoding: /// custom`) are preserved with the new value appended after a comma: /// `custom` + `gzip` ⇒ `custom, gzip`. pub fn append_content_encoding( headers: Dict(String, String), encoding: String, ) -> Dict(String, String) { case dict.get(headers, "Content-Encoding") { Ok(existing) -> dict.insert(headers, "Content-Encoding", existing <> ", " <> encoding) Error(_) -> dict.insert(headers, "Content-Encoding", encoding) } } /// Generated `@idempotencyToken` value, used when a request member /// with that trait is left as `Option.None`. Backed by the runtime /// FFI so tests can pin a deterministic UUID via `application:set_env`. @external(erlang, "aws_ffi", "idempotency_token") pub fn idempotency_token() -> String /// RFC 7230 list-header quoting for string elements. Smithy applies /// this only to string-typed entries — timestamp values use the raw /// `Mon, 16 Dec ... GMT` form even though it contains a comma. pub fn quote_list_string_entry(v: String) -> String { case string.contains(v, ",") || string.contains(v, "\"") { True -> "\"" <> string.replace(v, "\"", "\\\"") <> "\"" False -> v } } /// Build the full path: substitute labels, then append query (with `?`) /// if non-empty. /// Merge a path that may already carry a static query string (from the /// `@http` URI template, e.g. `/Foo?bar=baz`) with the dynamically /// built query string from `@httpQuery` members. Either or both can be /// empty. The static-query side wins on key order; dynamic params are /// appended. pub fn build_path(uri_path: String, query: String) -> String { let #(path_only, static_query) = case string.split_once(uri_path, "?") { Ok(#(p, q)) -> #(p, q) Error(_) -> #(uri_path, "") } let combined = case static_query, query { "", "" -> "" "", q -> q sq, "" -> sq sq, q -> sq <> "&" <> q } case combined { "" -> path_only _ -> path_only <> "?" <> combined } } /// Set a header on the headers dict if the value is non-empty (Smithy /// `@httpHeader` typically omits the header when the value is None). pub fn maybe_set_header( headers: Dict(String, String), name: String, value: String, ) -> Dict(String, String) { // Always set, including empty-string values — Smithy's // `@httpHeader` semantics treat `""` as a present (empty) header. // Absent is expressed at the codegen layer by skipping the call. dict.insert(headers, name, value) } /// Insert a service-level default header only when the caller hasn't /// already provided one with the same name. Mirrors `set_default_header` /// in the Rust SDK and the `contains_key` guard inside Glacier's /// `add_checksum_treehash` so caller `@httpHeader`-bound values win on /// collision. pub fn set_default_header( headers: Dict(String, String), name: String, value: String, ) -> Dict(String, String) { case dict.has_key(headers, name) { True -> headers False -> dict.insert(headers, name, value) } } /// Iterate `@httpPrefixHeaders` map members: for each entry, /// emit a header `: `. pub fn add_prefix_headers( headers: Dict(String, String), prefix: String, entries: Dict(String, String), ) -> Dict(String, String) { dict.fold(entries, headers, fn(acc, k, v) { dict.insert(acc, prefix <> k, v) }) } /// Iterate `@httpQueryParams` map members (Map). pub fn add_query_params( query: String, entries: Dict(String, String), ) -> String { dict.fold(entries, query, fn(acc, k, v) { add_query(acc, k, v) }) } /// Iterate `@httpQueryParams` map members (Map>). /// Each list value emits one query param per element. pub fn add_query_params_list( query: String, entries: Dict(String, List(String)), ) -> String { dict.fold(entries, query, fn(acc, k, vs) { list.fold(vs, acc, fn(q, v) { add_query(q, k, v) }) }) } /// Format a timestamp for use in URI labels / query strings / headers. /// Smithy's default `@timestampFormat` for `date-time` (the /// restJson1 + restXml default) is RFC 3339, e.g. `2019-12-16T23:48:18Z`. /// We always emit Z-suffixed UTC; the wire form remains stable even if /// the input came from a system clock in a different zone. pub fn timestamp_to_header(epoch_seconds: Int) -> String { iso8601_format(epoch_seconds) } @external(erlang, "aws_ffi", "format_iso8601") fn iso8601_format(epoch_seconds: Int) -> String /// Extract the raw wire string from a JSON-encoded enum value. The /// generated `encode__enum(v)` returns a `json.Json` like /// `json.string("VALUE")`; URI / query / header position wants just /// `VALUE`. We render to JSON text and strip the surrounding quotes. pub fn enum_wire_value(j: json.Json) -> String { let s = json.to_string(j) let len = string.length(s) case len > 2 { True -> string.slice(s, 1, len - 2) False -> s } } // ---------- response-side header extraction ---------- // // The runtime gives `parse__response` a `dict.Dict(String, String)` // with lowercased keys. The helpers below normalise the caller-supplied // header name to lowercase for the lookup so generated code can use the // header's wire spelling verbatim — e.g. `string_header(headers, "ETag")` // works the same whether the server replied with `ETag:` or `etag:`. pub fn string_header( headers: Dict(String, String), name: String, ) -> Option(String) { dict.get(headers, string.lowercase(name)) |> option.from_result } pub fn int_header(headers: Dict(String, String), name: String) -> Option(Int) { use raw <- option.then(string_header(headers, name)) raw |> string.trim |> int.parse |> option.from_result } pub fn bool_header( headers: Dict(String, String), name: String, ) -> Option(Bool) { use raw <- option.then(string_header(headers, name)) case string.lowercase(string.trim(raw)) { "true" -> Some(True) "false" -> Some(False) _ -> None } } /// Float header — used for shapes that bind a `Float` member to a /// response header. Falls through to `None` if the value can't be /// parsed; same forgiving contract as `int_header` / `bool_header`. pub fn float_header( headers: Dict(String, String), name: String, ) -> Option(Float) { use raw <- option.then(string_header(headers, name)) parse_float(string.trim(raw)) } /// Enum header — looks up a string header, then runs the supplied /// `_from_wire` decoder. Falls through to `None` if the /// header is missing or the wire value doesn't match a known /// variant (forgiving contract — unknown variants don't crash /// the response parse). The codegen passes the generated /// `_from_wire` function directly so this stays /// enum-agnostic. pub fn enum_header( headers: Dict(String, String), name: String, from_wire: fn(String) -> Result(t, String), ) -> Option(t) { case string_header(headers, name) { Some(s) -> case from_wire(s) { Ok(v) -> Some(v) Error(_) -> None } None -> None } } /// HTTP-date header — RFC 7231 §7.1.1.1 form /// (`Tue, 29 Apr 2014 18:30:38 GMT`). The default `@timestampFormat` /// for header bindings per Smithy core — covers `Last-Modified`, /// `Expires`, `Date`, etc. Forgiving contract: missing header or /// unparseable string → `None`. pub fn http_date_header( headers: Dict(String, String), name: String, ) -> Option(json_timestamp.Timestamp) { case string_header(headers, name) { Some(raw) -> case json_timestamp.parse_http_date(string.trim(raw)) { Ok(t) -> Some(t) Error(_) -> None } None -> None } } /// ISO 8601 timestamp header (`@timestampFormat("date-time")`, /// `2024-01-02T03:04:05Z`). pub fn iso8601_header( headers: Dict(String, String), name: String, ) -> Option(json_timestamp.Timestamp) { case string_header(headers, name) { Some(raw) -> case json_timestamp.parse_iso8601(string.trim(raw)) { Ok(t) -> Some(t) Error(_) -> None } None -> None } } /// Epoch-seconds timestamp header /// (`@timestampFormat("epoch-seconds")`, integer seconds since 1970 /// in the header value). pub fn epoch_seconds_header( headers: Dict(String, String), name: String, ) -> Option(json_timestamp.Timestamp) { case int_header(headers, name) { Some(n) -> Some(json_timestamp.Timestamp(seconds: n, nanoseconds: 0)) None -> None } } // ---------- @httpChecksumRequired ---------- /// Set the `Content-MD5` header to `base64(md5(body))`. Used by the /// Smithy `smithy.api#httpChecksumRequired` trait — the codegen /// emits a call to this helper at the tail of `build__request` /// for any operation that carries the trait. /// /// Always overwrites a previous `Content-MD5` entry: the SDK is /// responsible for the canonical value, and a stale caller-supplied /// one would surface as a 400 from the service. Other headers pass /// through unchanged. /// /// MD5 is not a security primitive here. The wire contract requires /// it (S3-control + restJson1 protocol tests fix the exact bytes); /// SigV4 covers the actual auth on the request. pub fn with_content_md5_header( headers: Dict(String, String), body: BitArray, ) -> Dict(String, String) { let digest = bit_array.base64_encode(crypto.md5(body), True) dict.insert(headers, "Content-MD5", digest) } /// Glacier's tree-hash + content-sha256 headers. Both end up as the /// Computed against the recursive 1 MiB chunk algorithm at /// https://docs.aws.amazon.com/amazonglacier/latest/dev/checksum- /// calculations.html: split the body into 1 MiB chunks, SHA-256 each, /// then pair-hash adjacent digests until one remains. Single-chunk /// bodies degenerate to plain SHA-256 (tree-hash == content-sha256), /// which is what every Glacier protocol-test fixture happens to use, /// but the recursive form is required for the > 1 MiB upload archive /// path used by real callers. /// /// `X-Amz-Sha256-Tree-Hash` carries the tree-hash digest; /// `X-Amz-Content-Sha256` carries the full-body SHA-256. The Rust SDK /// uses the same pair of headers in `glacier_interceptors:: /// add_checksum_treehash`. Both are skipped when already present so a /// caller-supplied value wins. pub fn with_glacier_tree_hash_headers( headers: Dict(String, String), body: BitArray, ) -> Dict(String, String) { let content_sha256 = crypto.hex_encode(crypto.sha256(body)) let tree_hash = crypto.hex_encode(glacier_tree_hash(body)) headers |> set_default_header("X-Amz-Sha256-Tree-Hash", tree_hash) |> set_default_header("X-Amz-Content-Sha256", content_sha256) } const glacier_chunk_size = 1_048_576 /// Compute the Glacier tree hash of `body` as raw bytes (caller /// hex-encodes for the wire). Empty body degenerates to /// `SHA-256("")` so the function is total. Matches /// `glacier_interceptors::compute_hash_tree` in the Rust SDK. pub fn glacier_tree_hash(body: BitArray) -> BitArray { case bit_array.byte_size(body) { 0 -> crypto.sha256(<<>>) _ -> { let chunks = chunk_hashes(body, glacier_chunk_size, []) combine_tree_hashes(chunks) } } } fn chunk_hashes( body: BitArray, chunk_size: Int, acc: List(BitArray), ) -> List(BitArray) { case bit_array.byte_size(body) { 0 -> list.reverse(acc) size -> { let take = case size > chunk_size { True -> chunk_size False -> size } // `bit_array.slice` is the only call here that can fail (out-of- // range), and we've just guarded `take` against `size`, so the // assertions below are total. let assert Ok(chunk) = bit_array.slice(body, 0, take) let rest_size = size - take let assert Ok(rest) = bit_array.slice(body, take, rest_size) chunk_hashes(rest, chunk_size, [crypto.sha256(chunk), ..acc]) } } } fn combine_tree_hashes(hashes: List(BitArray)) -> BitArray { case hashes { [single] -> single _ -> combine_tree_hashes(pair_hash(hashes)) } } fn pair_hash(hashes: List(BitArray)) -> List(BitArray) { case hashes { [] -> [] [single] -> [single] [left, right, ..rest] -> [ crypto.sha256(bit_array.append(to: left, suffix: right)), ..pair_hash(rest) ] } } /// Checksum algorithm picked by the `aws.protocols#httpChecksum` /// trait. Each variant maps to one of the AWS `x-amz-checksum-*` /// request headers; the codegen / runtime middleware writes the /// base64-encoded digest of the request body into that header /// before signing. pub type ChecksumAlgorithm { ChecksumSha256 ChecksumSha1 ChecksumCrc32 ChecksumCrc32C } /// `(header_name, base64_digest)` pair for a body checksum. The /// header name follows the AWS convention `x-amz-checksum-` /// (lowercase). The digest is base64 of the raw bytes — same /// padding rules as `Content-MD5`. Pure function; the /// `aws.protocols#httpChecksum` middleware in the codegen layer /// calls this and inserts the result into the request headers. pub fn checksum_header( algorithm: ChecksumAlgorithm, body: BitArray, ) -> #(String, String) { case algorithm { ChecksumSha256 -> #( "x-amz-checksum-sha256", bit_array.base64_encode(crypto.sha256(body), True), ) ChecksumSha1 -> #( "x-amz-checksum-sha1", bit_array.base64_encode(crypto.sha1(body), True), ) ChecksumCrc32 -> #( "x-amz-checksum-crc32", bit_array.base64_encode(crypto.crc32_be_bytes(crypto.crc32(body)), True), ) ChecksumCrc32C -> #( "x-amz-checksum-crc32c", bit_array.base64_encode(crypto.crc32_be_bytes(crypto.crc32c(body)), True), ) } } /// Add an `x-amz-checksum-` header to the request. Convenience /// wrapper around `checksum_header` that lets call sites stay /// pipeline-style with the existing `Dict(String, String)` header /// shape — same ergonomics as `with_content_md5_header`. pub fn with_checksum_header( headers: Dict(String, String), algorithm: ChecksumAlgorithm, body: BitArray, ) -> Dict(String, String) { let #(name, value) = checksum_header(algorithm, body) dict.insert(headers, name, value) } /// Translate a Smithy `ChecksumAlgorithm` enum's wire value /// (e.g. `"SHA256"`, `"CRC32C"`) to the runtime `ChecksumAlgorithm` /// variant, falling back to `ChecksumSha256` when the wire value /// doesn't match a supported algorithm. Used by the codegen's /// algorithm-member dispatch for `aws.protocols#httpChecksum` so /// generated request builders can read the caller's typed enum /// choice without needing a per-service jump table. pub fn checksum_algorithm_from_wire(wire: String) -> ChecksumAlgorithm { case wire { "SHA256" -> ChecksumSha256 "SHA1" -> ChecksumSha1 "CRC32" -> ChecksumCrc32 "CRC32C" -> ChecksumCrc32C // Unknown algorithms (CRC64NVME etc.) fall back to SHA-256 — // the safe default per the `aws.protocols#httpChecksum` // spec when no `requestChecksumRequired` is set or when the // declared algorithm isn't one we can compute. _ -> ChecksumSha256 } } /// Add the `x-amz-checksum-` header using a wire-form /// algorithm name. Equivalent to /// `with_checksum_header(headers, checksum_algorithm_from_wire(wire), body)`. /// Exists so the codegen can emit a single call that takes the /// generated enum's wire-encoder output directly, without /// needing per-service algorithm-mapping helpers. pub fn with_checksum_header_for_wire( headers: Dict(String, String), wire: String, body: BitArray, ) -> Dict(String, String) { with_checksum_header(headers, checksum_algorithm_from_wire(wire), body) } fn parse_float(s: String) -> Option(Float) { // Integer literals are valid Float wire values per the Smithy spec — // `1` decodes to `1.0`. The stdlib's float parser rejects them, so // fall back to int + cast on the `Error` side via `lazy_or`. float.parse(s) |> result.lazy_or(fn() { int.parse(s) |> result.map(int.to_float) }) |> option.from_result }