Skip to content

Commit

Permalink
Adapt the Livebook XML parsing to add a mini-parser to allow us to in…
Browse files Browse the repository at this point in the history
…terface with the AWS API
  • Loading branch information
probably-not committed Jan 6, 2025
1 parent f8e200d commit db656c8
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 2 deletions.
21 changes: 19 additions & 2 deletions lib/flame_ec2/ec2_api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule FlameEC2.EC2Api do

alias FlameEC2.BackendState
alias FlameEC2.Config
alias FlameEC2.EC2Api.XML
alias FlameEC2.Templates

require Logger
Expand All @@ -28,12 +29,12 @@ defmodule FlameEC2.EC2Api do
url: uri,
method: :post,
form: params,
headers: [{:accept, "application/json"}],
aws_sigv4: Map.put_new(credentials, :service, "ec2")
]
|> Req.new()
|> Req.request()
|> raise_or_response!()
|> Map.fetch!(:body)
|> Map.fetch!("RunInstancesResponse")
|> Map.fetch!("instancesSet")
|> Map.fetch!("item")
Expand Down Expand Up @@ -132,11 +133,27 @@ defmodule FlameEC2.EC2Api do
end

defp raise_or_response!({:ok, %Req.Response{} = resp}) do
resp.body
if xml?(resp) do
update_in(resp.body, &XML.decode!/1)
else
resp
end
end

defp raise_or_response!({:error, exception}) do
Logger.error("Failed to create instance with exception: #{inspect(exception)}")
raise exception
end

# Adapted from https://github.com/livebook-dev/livebook/blob/v0.14.5/lib/livebook/file_system/s3/client.ex
defp xml?(response) do
guess_xml? = String.starts_with?(response.body, "<?xml")

case FlameEC2.EC2Api.Utils.fetch_content_type(response) do
{:ok, content_type} when content_type in ["text/xml", "application/xml"] -> true
# Apparently some requests return XML without content-type
:error when guess_xml? -> true
_otherwise -> false
end
end
end
18 changes: 18 additions & 0 deletions lib/flame_ec2/ec2_api/utils.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule FlameEC2.EC2Api.Utils do
@moduledoc false
# Adapted from https://github.com/livebook-dev/livebook/blob/v0.14.5/lib/livebook/utils.ex

@doc """
Retrieves content type from response headers.
"""
@spec fetch_content_type(Req.Response.t()) :: {:ok, String.t()} | :error
def fetch_content_type(%Req.Response{} = res) do
case res.headers["content-type"] do
[value] ->
{:ok, value |> String.split(";") |> hd()}

_other ->
:error
end
end
end
90 changes: 90 additions & 0 deletions lib/flame_ec2/ec2_api/xml.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
defmodule FlameEC2.EC2Api.XML do
# Adapted from https://github.com/livebook-dev/livebook/blob/v0.14.5/lib/livebook/file_system/s3/xml.ex
@moduledoc false
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 """
Decodes a XML into a map.
Raises in case of errors.
"""
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

# 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
%{@text => text} = v ->
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)

if Map.has_key?(acc, tag) do
update_fun = fn
l when is_list(l) -> [value | l]
v -> [value, v]
end

Map.update!(acc, tag, update_fun)
else
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
end

0 comments on commit db656c8

Please sign in to comment.