Skip to main content

lib/layr8/attachment.ex

defmodule Layr8.Attachment do
  @moduledoc """
  A DIDComm v2 attachment.

  Attachments carry payloads that travel alongside a message but are not part
  of the message body — signed credentials, large binaries, file references, etc.

  ## Fields

  - `id` — unique attachment identifier (within the containing message)
  - `description` — human-readable description
  - `filename` — suggested filename for downloaded content
  - `media_type` — IANA media type of the content (e.g. `application/jwt`)
  - `format` — protocol-specific format identifier (e.g. `application/vc+jwt`)
  - `lastmod_time` — last modification time as a Unix timestamp (integer)
  - `byte_count` — size of the attached content in bytes
  - `data` — map carrying the payload; see DIDComm v2 spec §5

  ## `data` map

  The `data` map may contain one or more of:

  - `"base64"` — base64url-encoded bytes (no padding)
  - `"json"` — inline JSON value
  - `"jws"` — a JSON-serialized JWS (object, not a compact string)
  - `"hash"` — base64url-encoded SHA-256 of the referenced content
  - `"links"` — list of URLs pointing to the content

  For signed credentials sent as compact JWT strings, use `data.base64`:

      Base.url_encode64(compact_jwt, padding: false)

  placed under the `"base64"` key.

  ## Wire format

      %{
        "id" => "...",
        "format" => "...",
        "media_type" => "...",
        "data" => %{"base64" => "..."}
      }

  Optional fields are omitted when empty/nil.
  """

  defstruct id: "",
            description: "",
            filename: "",
            media_type: "",
            format: "",
            lastmod_time: nil,
            byte_count: nil,
            data: %{}

  @type data_map :: %{optional(String.t()) => term()}

  @type t :: %__MODULE__{
          id: String.t(),
          description: String.t(),
          filename: String.t(),
          media_type: String.t(),
          format: String.t(),
          lastmod_time: non_neg_integer() | nil,
          byte_count: non_neg_integer() | nil,
          data: data_map()
        }

  @doc """
  Serializes an `Attachment` into a DIDComm v2 JSON envelope map.

  Optional string fields are omitted when empty; optional numeric fields are
  omitted when `nil`. `data` is always included (defaults to `%{}`).
  """
  @spec marshal(t()) :: map()
  def marshal(%__MODULE__{} = att) do
    %{"data" => att.data || %{}}
    |> maybe_put("id", att.id)
    |> maybe_put("description", att.description)
    |> maybe_put("filename", att.filename)
    |> maybe_put("media_type", att.media_type)
    |> maybe_put("format", att.format)
    |> maybe_put("lastmod_time", att.lastmod_time)
    |> maybe_put("byte_count", att.byte_count)
  end

  @doc """
  Parses a DIDComm v2 attachment map into an `Attachment` struct.

  Missing fields default to empty strings / `nil` / `%{}`.
  """
  @spec parse(map()) :: t()
  def parse(map) when is_map(map) do
    %__MODULE__{
      id: Map.get(map, "id", ""),
      description: Map.get(map, "description", ""),
      filename: Map.get(map, "filename", ""),
      media_type: Map.get(map, "media_type", ""),
      format: Map.get(map, "format", ""),
      lastmod_time: Map.get(map, "lastmod_time"),
      byte_count: Map.get(map, "byte_count"),
      data: Map.get(map, "data", %{})
    }
  end

  defp maybe_put(map, _key, ""), do: map
  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
end