Skip to content

Instantly share code, notes, and snippets.

@jeregrine
Created January 18, 2024 18:37
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jeregrine/0d842c16de4ac4204c3d4b7bc62c1467 to your computer and use it in GitHub Desktop.
Save jeregrine/0d842c16de4ac4204c3d4b7bc62c1467 to your computer and use it in GitHub Desktop.
deps:
{:aws_signature, "~> 0.3.0"},
{:aws_credentials, "~> 0.2.0"}
defmodule Req.S3 do
def attach(request) do
request
|> Req.Request.register_options([:aws_endpoint])
|> Req.Request.append_request_steps(sign_s3: &s3_sign/1)
end
defp s3_sign(request) do
if request.url.scheme == "s3" do
credentials =
case :aws_credentials.get_credentials() do
:undefined ->
raise ~s|aws_credentials could not find configured AWS Credentials. See https://hexdocs.pm/aws_credentials/readme.html for more. |
credentials ->
credentials
end
aws_endpoint = Map.get(request.options, :aws_endpoint, "s3.amazonaws.com")
host = "#{request.url.host}.#{aws_endpoint}"
url = %{request.url | scheme: "https", host: host, authority: host, port: 443}
now = NaiveDateTime.utc_now() |> NaiveDateTime.to_erl()
headers =
:aws_signature.sign_v4(
credentials.access_key_id,
credentials.secret_access_key,
credentials.region,
"s3",
now,
Atom.to_string(request.method),
URI.to_string(url),
Map.put(request.headers, "Host", host) |> headers(),
request.body || "",
uri_encode_path: false,
session_token: Map.get(credentials, :token, nil)
)
request
|> Map.replace!(:url, url)
|> Req.Request.put_headers(headers)
|> Req.Request.append_response_steps(req_s3_decode_body: &decode_body/1)
else
request
end
end
defp headers(headers) do
headers
|> Enum.map(fn
{k, v} when is_list(v) -> {k, Enum.join(v, ",")}
{k, v} -> {k, v}
end)
end
defp decode_body({request, response}) do
if request.url.path in [nil, "/"] && xml?(response.headers, response.body) do
body = response.body |> Req.S3.XML.decode!()
{request, %{response | body: body}}
else
{request, response}
end
end
defp xml?(headers, body) do
guess_xml? = String.starts_with?(body, "<?xml")
case Map.get(headers, "content-type") do
content_type when content_type in ["text/xml", "application/xml"] -> true
# Apparently some requests return XML without content-type
_ when guess_xml? -> true
_otherwise -> false
end
end
end
defmodule Req.S3.XML do
# Adapted from https://github.com/livebook-dev/livebook/blob/main/lib/livebook/file_system/s3/xml.ex
# Adapted from https://github.com/aws-beam/aws-elixir/blob/v0.8.0/lib/aws/xml.ex
import Record
@text "__text"
defrecord(:xmlElement, extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
defrecord(:xmlText, extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl"))
@doc """
Encodes a map into XML iodata.
Raises in case of errors.
"""
@spec encode_to_iodata!(map()) :: iodata()
def encode_to_iodata!(map) do
map
|> Map.to_list()
|> Enum.map(&encode_xml_key_value/1)
|> :erlang.iolist_to_binary()
end
@doc """
Decodes a XML into a map.
Raises in case of errors.
"""
@spec decode!(iodata()) :: map()
def decode!(xml) do
xml_str = :unicode.characters_to_list(xml)
opts = [{:hook_fun, &hook_fun/2}]
{element, []} = :xmerl_scan.string(xml_str, opts)
element
end
defp encode_xml_key_value({k, v}) when is_binary(k) and is_binary(v) do
["<", k, ">", escape_xml_string(v), "</", k, ">"]
end
defp encode_xml_key_value({k, values}) when is_binary(k) and is_list(values) do
for v <- values do
encode_xml_key_value({k, v})
end
end
defp encode_xml_key_value({k, v}) when is_binary(k) and is_integer(v) do
["<", k, ">", Integer.to_charlist(v), "</", k, ">"]
end
defp encode_xml_key_value({k, v}) when is_binary(k) and is_float(v) do
["<", k, ">", Float.to_charlist(v), "</", k, ">"]
end
defp encode_xml_key_value({k, v}) when is_binary(k) and is_map(v) do
inner = v |> Map.to_list() |> Enum.map(&encode_xml_key_value/1)
["<", k, ">", inner, "</", k, ">"]
end
# Callback hook_fun for xmerl parser
defp hook_fun(element, global_state) when Record.is_record(element, :xmlElement) do
tag = xmlElement(element, :name)
content = xmlElement(element, :content)
value =
case List.foldr(content, :none, &content_to_map/2) do
v = %{@text => text} ->
case String.trim(text) do
"" -> Map.delete(v, @text)
trimmed -> Map.put(v, @text, trimmed)
end
v ->
v
end
{%{Atom.to_string(tag) => value}, global_state}
end
defp hook_fun(text, global_state) when Record.is_record(text, :xmlText) do
text = xmlText(text, :value)
{:unicode.characters_to_binary(text), global_state}
end
# Convert the content of an Xml node into a map.
# When there is more than one element with the same tag name, their
# values get merged into a list.
# If the content is only text then that is what gets returned.
# If the content is a mix between text and child elements, then the
# elements are processed as described above and all the text parts
# are merged under the `__text' key.
defp content_to_map(x, :none) do
x
end
defp content_to_map(x, acc) when is_map(x) and is_map(acc) do
[{tag, value}] = Map.to_list(x)
case Map.has_key?(acc, tag) do
true ->
update_fun = fn
l when is_list(l) -> [value | l]
v -> [value, v]
end
Map.update!(acc, tag, update_fun)
false ->
Map.put(acc, tag, value)
end
end
defp content_to_map(x, %{@text => text} = acc) when is_binary(x) and is_map(acc) do
%{acc | @text => <<x::binary, text::binary>>}
end
defp content_to_map(x, acc) when is_binary(x) and is_map(acc) do
Map.put(acc, @text, x)
end
defp content_to_map(x, acc) when is_binary(x) and is_binary(acc) do
<<x::binary, acc::binary>>
end
defp content_to_map(x, acc) when is_map(x) and is_binary(acc) do
Map.put(x, @text, acc)
end
# See https://github.com/ex-aws/ex_aws_s3/blob/6973f72b0b78d928c86252767e7dcebab5be0ba8/lib/ex_aws/s3.ex#L1268
defp escape_xml_string(value) do
String.replace(value, ["'", "\"", "&", "<", ">", "\r", "\n"], fn
"'" -> "&apos;"
"\"" -> "&quot;"
"&" -> "&amp;"
"<" -> "&lt;"
">" -> "&gt;"
"\r" -> "&#13;"
"\n" -> "&#10;"
end)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment