feat: Add struct conversion code

This commit is contained in:
Robert Prehn 2024-02-29 19:00:07 -06:00
parent bef0d41f0e
commit 68cc47ba2e
No known key found for this signature in database
11 changed files with 293 additions and 83 deletions

View file

@ -0,0 +1,86 @@
defmodule Kindling.Converter.DateTime do
defstruct year: "",
month: "",
day: "",
hour: "",
minute: "",
second: "",
zone: ""
def parse(value) do
value
|> String.codepoints()
|> do_year(%__MODULE__{})
|> set_defaults()
|> to_iso_string()
|> DateTime.from_iso8601()
end
def to_iso_string(%{
year: year,
month: month,
day: day,
hour: hour,
minute: minute,
second: second,
zone: zone
}) do
"#{year}-#{month}-#{day}T#{hour}:#{minute}:#{second}#{zone}"
end
def set_defaults(%{
year: year,
month: month,
day: day,
hour: hour,
minute: minute,
second: second,
zone: zone
}) do
%__MODULE__{
year: format(year, "0000"),
month: format(month, "01"),
day: format(day, "01"),
hour: format(hour, "00"),
minute: format(minute, "00"),
second: format(second, "00"),
zone: if(zone == "", do: "Z", else: zone)
}
end
defp format("", default), do: default
defp format(string, default), do: String.pad_leading(string, String.length(default), "0")
def do_year([], data), do: data
def do_year(["-" | tail], data), do: do_month(tail, data)
def do_year([hd | tail], %{year: year} = data), do: do_year(tail, %{data | year: year <> hd})
def do_month([], data), do: data
def do_month(["-" | tail], data), do: do_day(tail, data)
def do_month([hd | tail], %{month: month} = data),
do: do_month(tail, %{data | month: month <> hd})
def do_day([], data), do: data
def do_day(["T" | tail], data), do: do_hour(tail, data)
def do_day([hd | tail], %{day: day} = data), do: do_day(tail, %{data | day: day <> hd})
def do_hour([], data), do: data
def do_hour([":" | tail], data), do: do_minute(tail, data)
def do_hour([hd | tail], %{hour: hour} = data), do: do_hour(tail, %{data | hour: hour <> hd})
def do_minute([], data), do: data
def do_minute([":" | tail], data), do: do_second(tail, data)
def do_minute([hd | tail], %{minute: minute} = data),
do: do_minute(tail, %{data | minute: minute <> hd})
def do_second([], data), do: data
def do_second([c | tail], data) when c in ["+", "-", "Z"], do: do_zone(tail, data)
def do_second([hd | tail], %{second: second} = data),
do: do_second(tail, %{data | second: second <> hd})
def do_zone([], data), do: data
def do_zone([hd | tail], %{zone: zone} = data), do: do_zone(tail, %{data | zone: zone <> hd})
end

43
lib/kindling/client.ex Normal file
View file

@ -0,0 +1,43 @@
defmodule Kindling.Client do
alias Kindling.Converter
defstruct [:base_url, :access_token, auth_mode: :bearer]
def read(client, resource_module, id, opts \\ []) do
base_uri = URI.parse(client.base_url)
uri = base_uri |> URI.append_path(resource_module.path()) |> URI.append_path("/#{id}")
more_headers = Keyword.get(opts, :headers, [])
headers = headers(client, more_headers)
uri
|> Req.get(headers: headers)
|> case do
{:ok, %{status: status} = response} when status >= 200 and status < 300 ->
Converter.convert(resource_module.version(), response.body)
other ->
other
end
end
def headers(client, more_headers) do
case client.auth_mode do
:open ->
[format_header() | more_headers]
_other ->
[auth_header(client) | [format_header() | more_headers]]
end
end
def auth_header(%{auth_mode: :bearer, access_token: token}),
do: {"Authorization", "Bearer #{token}"}
def auth_header(%{auth_mode: :basic, access_token: token}),
do: {"Authorization", "Basic #{token}"}
def format_header do
{"Accept", "application/json"}
end
end

View file

@ -1,19 +1,3 @@
defmodule Kindling.Config do
@default_embedded_resources [
"Coding",
"Duration",
"Extension",
"Identifier",
"Meta",
"Element",
"CodeableConcept",
"Narrative",
"Period",
"Reference",
"ResourceList"
]
def embedded_resources() do
Application.get_env(:kindling, :embedded_resources, @default_embedded_resources)
end
def root_resources, do: Application.get_env(:kindling, :root_resources, ["Encounter"])
end

89
lib/kindling/converter.ex Normal file
View file

@ -0,0 +1,89 @@
defmodule Kindling.Converter do
alias Kindling.Schema.Resource
def convert(version_namespace, %{"resourceType" => resource_type} = resource_json) do
resource_module = Module.concat(version_namespace, Resource.class_name(resource_type))
structify(resource_module, resource_json)
end
def structify(resource_module, resource_json) do
Code.ensure_loaded!(resource_module)
atom_map =
resource_json
|> Enum.map(fn {key_string, value} ->
key_atom = key_string |> Recase.to_snake() |> String.to_existing_atom()
{key_atom, convert_field(resource_module, key_atom, value)}
end)
|> Map.new()
struct!(resource_module, atom_map)
end
def convert_field(resource_module, field, value) do
cond do
field in resource_module.__schema__(:associations) ->
convert_association(resource_module, field, value)
field in resource_module.__schema__(:embeds) ->
convert_embed(resource_module, field, value)
field in resource_module.__schema__(:fields) ->
cast_field(resource_module, field, value)
true ->
value
end
end
def cast_field(resource_module, field, value) do
type = resource_module.__schema__(:type, field)
do_cast_field(type, value)
end
defp do_cast_field(:utc_datetime_usec, value) do
case Kindling.Converter.DateTime.parse(value) do
{:ok, v, _} ->
v
other ->
other
end
end
defp do_cast_field(type, value) do
case Ecto.Type.cast(type, value) do
{:ok, v} ->
v
other ->
other
end
end
def convert_association(resource_module, field, value) do
%{cardinality: cardinality, related: type} = resource_module.__schema__(:association, field)
case cardinality do
:many ->
Enum.map(value, &structify(type, &1))
:one ->
structify(type, value)
end
end
def convert_embed(resource_module, field, value) do
%{cardinality: cardinality, related: type} = resource_module.__schema__(:embed, field)
case cardinality do
:many ->
Enum.map(value, &structify(type, &1))
:one ->
structify(type, value)
end
end
end

View file

@ -3,10 +3,10 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do
import Ecto.Changeset
@fields [
<%= @all_fields |> Enum.map(fn name -> ~s("#{Recase.to_snake(name)}") end) |> Enum.join(",\n") %>
<%= @all_fields |> Enum.map(fn name -> ~s(:#{Recase.to_snake(name)}) end) |> Enum.join(",\n") %>
]
@required_fields [
<%= @required_fields |> Enum.map(fn name -> ~s("#{Recase.to_snake(name)}") end) |> Enum.join(",\n") %>
<%= @required_fields |> Enum.map(fn name -> ~s(:#{Recase.to_snake(name)}) end) |> Enum.join(",\n") %>
]
embedded_schema do
@ -21,11 +21,11 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do
<%= if @properties.enum != [] do %># Enum<% end %>
<%= for {name, df, _} <- @properties.enum do %>field :<%= Recase.to_snake(name) %>, Ecto.Enum, values: <%= inspect(df["enum"] |> Enum.map(&Recase.to_snake(&1)) |> Enum.map(&String.to_atom/1)) %>
<% end %>
<%= if @properties.embed_one != [] do %># Embed One<% end %>
<%= for {name, df, _} <- @properties.embeds_one do %>embed_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>
<%= if @properties.embeds_one != [] do %># Embed One<% end %>
<%= for {name, df, _} <- @properties.embeds_one do %>embeds_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>
<% end %>
<%= if @properties.embed_many != [] do %># Embed Many<% end %>
<%= for {name, df, _} <- @properties.embed_many do %>embed_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>
<%= if @properties.embeds_many != [] do %># Embed Many<% end %>
<%= for {name, df, _} <- @properties.embeds_many do %>embeds_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %>
<% end %>
<%= if @properties.has_one != [] do %># Has One<% end %>
<%= for {name, df, _} <- @properties.has_one do %>has_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>
@ -33,9 +33,6 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do
<%= if @properties.has_many != [] do %># Has Many<% end %>
<%= for {name, df, _} <- @properties.has_many do %>has_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %>
<% end %>
<%= if @backlinks != [] do %># belongs_to<% end %>
<%= for name <- @backlinks do %>belongs_to :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= class_name(name) %>
<% end %>
end
def base_changeset(data \\ %__MODULE__{}, attrs) do

View file

@ -1,5 +1,4 @@
defmodule Kindling.Schema do
alias Kindling.Config
alias Kindling.Version
alias Kindling.Schema.Resource
@ -23,7 +22,6 @@ defmodule Kindling.Schema do
defs =
schema["definitions"]
|> Map.drop(Config.embedded_resources())
|> Enum.reduce(schema["definitions"], fn {name, df}, definitions ->
refs = Resource.refs(df)
@ -38,10 +36,14 @@ defmodule Kindling.Schema do
Map.put(schema, "definitions", defs)
end
def refs_recursive(schema, root_name) do
def refs_recursive(schema, root_name) when is_binary(root_name) do
do_recurse(schema, MapSet.new([root_name]), [root_name])
end
def refs_recursive(schema, roots) when is_list(roots) do
do_recurse(schema, MapSet.new(roots), roots)
end
defp do_recurse(_schema, visited, []), do: visited
defp do_recurse(schema, visited, [hd | tail]) do

View file

@ -1,18 +1,25 @@
defmodule Kindling.Schema.Resource do
alias Kindling.Config
@empty_properties %{array: [], const: [], has_one: [], has_many: [], enum: [], value: []}
@empty_properties %{
array: [],
const: [],
embeds_one: [],
embeds_many: [],
has_one: [],
has_many: [],
enum: [],
value: []
}
def properties(df) do
df["properties"]
end
def grouped_properties(df) do
def grouped_properties(df, roots) do
properties =
(df["properties"] || %{})
|> Map.delete("id")
|> Enum.reject(&is_element/1)
|> Enum.map(fn {key, value} -> {key, value, property_type(value)} end)
|> Enum.map(fn {key, value} -> {key, value, property_type(value, roots)} end)
|> Enum.group_by(fn {_key, _value, type} ->
type
end)
@ -23,7 +30,7 @@ defmodule Kindling.Schema.Resource do
def all_fields(df) do
(df["properties"] || %{})
|> Enum.filter(fn {_name, definition} ->
property_type(definition) in [:array, :enum, :value]
property_type(definition, []) in [:array, :enum, :value]
end)
|> Enum.map(fn {name, _} -> name end)
end
@ -32,7 +39,7 @@ defmodule Kindling.Schema.Resource do
df
|> required()
|> Enum.filter(fn {_name, definition} ->
property_type(definition) in [:array, :enum, :value]
property_type(definition, []) in [:array, :enum, :value]
end)
|> Enum.map(fn {name, _} -> name end)
end
@ -45,37 +52,35 @@ defmodule Kindling.Schema.Resource do
key in df["required"]
end
def property_type(%{"const" => _}), do: :const
def property_type(%{"const" => _}, _roots), do: :const
for embed <- Config.embedded_resources() do
dbg(embed)
def property_type(%{"$ref" => "#/definitions/" <> unquote(embed)}), do: :embed_one
def property_type(%{"items" => %{"$ref" => "#/definitions/" <> unquote(embed)}}) do
:embed_many
end
end
def property_type(%{"$ref" => "#/definitions/" <> name}) do
def property_type(%{"$ref" => "#/definitions/" <> name}, roots) do
if is_class_name(name) do
:has_one
if name in roots do
:has_one
else
:embeds_one
end
else
:value
end
end
def property_type(%{"items" => %{"$ref" => "#/definitions/" <> name}}) do
def property_type(%{"items" => %{"$ref" => "#/definitions/" <> name}}, roots) do
if is_class_name(name) do
:has_many
if name in roots do
:has_many
else
:embeds_many
end
else
:array
end
end
def property_type(%{"items" => %{"enum" => _}}), do: :array
def property_type(%{"enum" => _}), do: :enum
def property_type(_), do: :value
def property_type(%{"items" => %{"enum" => _}}, _roots), do: :array
def property_type(%{"enum" => _}, _roots), do: :enum
def property_type(_, _roots), do: :value
def refs(df) do
(df["properties"] || %{})
@ -95,10 +100,16 @@ defmodule Kindling.Schema.Resource do
end
def is_class_name(name) do
!(name in Config.embedded_resources()) && Regex.match?(~r/^[A-Z]/, name)
Regex.match?(~r/^[A-Z]/, name)
end
def is_element({_name, %{"$ref" => "#/definitions/Element"}}), do: true
def is_element({name, %{"items" => items}}), do: is_element({name, items})
def is_element(_), do: false
def ref_to_class_name("#/definitions/" <> name),
do: class_name(name)
def class_name(name),
do: name |> String.split("_") |> Enum.map(&Recase.to_pascal/1) |> Enum.join(".")
end

View file

@ -3,10 +3,10 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do
import Ecto.Changeset
@fields [
<%= @all_fields |> Enum.map(fn name -> ~s("#{Recase.to_snake(name)}") end) |> Enum.join(",\n") %>
<%= @all_fields |> Enum.map(fn name -> ~s(:#{Recase.to_snake(name)}) end) |> Enum.join(",\n") %>
]
@required_fields [
<%= @required_fields |> Enum.map(fn name -> ~s("#{Recase.to_snake(name)}") end) |> Enum.join(",\n") %>
<%= @required_fields |> Enum.map(fn name -> ~s(:#{Recase.to_snake(name)}) end) |> Enum.join(",\n") %>
]
@primary_key {:id, :binary_id, autogenerate: true}
@ -22,11 +22,11 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do
<%= if @properties.enum != [] do %># Enum<% end %>
<%= for {name, df, _} <- @properties.enum do %>field :<%= Recase.to_snake(name) %>, Ecto.Enum, values: <%= inspect(df["enum"] |> Enum.map(&Recase.to_snake(&1)) |> Enum.map(&String.to_atom/1)) %>
<% end %>
<%= if @properties.embed_one != [] do %># Embed One<% end %>
<%= for {name, df, _} <- @properties.embed_one do %>embeds_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>
<%= if @properties.embeds_one != [] do %># Embed One<% end %>
<%= for {name, df, _} <- @properties.embeds_one do %>embeds_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>
<% end %>
<%= if @properties.embed_many != [] do %># Embed Many<% end %>
<%= for {name, df, _} <- @properties.embed_many do %>embed_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %>
<%= if @properties.embeds_many != [] do %># Embed Many<% end %>
<%= for {name, df, _} <- @properties.embeds_many do %>embeds_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %>
<% end %>
<%= if @properties.has_one != [] do %># Has One<% end %>
<%= for {name, df, _} <- @properties.has_one do %>has_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>
@ -39,6 +39,9 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do
<% end %>
end
def version, do: <%= @namespace %>.<%= @version %>
def path, do: "/<%= @resource_name %>"
def base_changeset(data \\ %__MODULE__{}, attrs) do
data
|> cast(attrs, @fields)

View file

@ -2,41 +2,41 @@ defmodule Kindling.Templates do
require EEx
import Kindling.Templates.Functions
import Kindling.Schema.Resource
alias Kindling.Config
alias Kindling.Schema.Resource
EEx.function_from_file(:def, :render, "lib/kindling/template.eex", [:assigns])
EEx.function_from_file(:def, :render_embedded, "lib/kindling/embed_template.eex", [:assigns])
def resource_code(namespace, version, resource_name, resource) do
def resource_code(namespace, version, roots, resource_name, resource) do
assigns = %{
namespace: namespace,
version: version,
resource_name: resource_name,
properties: Resource.grouped_properties(resource),
properties: Resource.grouped_properties(resource, roots),
all_fields: Resource.all_fields(resource),
required_fields: Resource.required_fields(resource),
backlinks: resource["__backlinks"]
backlinks: Enum.filter(resource["__backlinks"], &(&1 in roots))
}
if resource_name in Config.embedded_resources() do
if resource_name in roots do
assigns
|> render_embedded()
|> render()
|> Code.format_string!(file: "#{resource_name}.ex")
else
assigns
|> render()
|> render_embedded()
|> Code.format_string!(file: "#{resource_name}.ex")
end
end
def write_code(namespace, version, resource_name, resource) do
def write_code(namespace, version, roots, resource_name, resource) do
dir = Path.join(["lib", String.downcase(namespace), String.downcase(version)])
file = Path.join([dir | to_source_file_name(resource_name)])
File.mkdir_p!(Path.dirname(file))
File.write!(file, resource_code(namespace, version, resource_name, resource))
File.write!(file, resource_code(namespace, version, roots, resource_name, resource))
end
def to_source_file_name(resource_name) do

View file

@ -40,10 +40,4 @@ defmodule Kindling.Templates.Functions do
def fhir_type_to_ecto(%{"type" => "array", "items" => items}),
do: {:array, fhir_type_to_ecto(items)}
def ref_to_class_name("#/definitions/" <> name),
do: class_name(name)
def class_name(name),
do: name |> String.split("_") |> Enum.map(&Recase.to_pascal/1) |> Enum.join(".")
end

View file

@ -6,22 +6,23 @@ defmodule Mix.Tasks.Kindling.GenerateSchemas do
@impl Mix.Task
def run(args) do
if Enum.count(args) != 3 do
Mix.shell().error(
"usage: mix mix kindling.generate_schemas <namespace> <R5|R4|R3> <root resource name>"
)
if Enum.count(args) != 2 do
Mix.shell().error("usage: mix mix kindling.generate_schemas <namespace> <R5|R4|R3>")
raise "Argument error."
end
[namespace, version, root] = args
[namespace, version] = args
schema = Kindling.Schema.schema_object(version)
roots = Kindling.Config.root_resources()
to_generate =
Kindling.Schema.refs_recursive(schema, root)
Kindling.Schema.refs_recursive(schema, roots)
Enum.each(
to_generate,
&Kindling.Templates.write_code(namespace, version, &1, schema["definitions"][&1])
&Kindling.Templates.write_code(namespace, version, roots, &1, schema["definitions"][&1])
)
end
end