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