From 68cc47ba2e1ecf3636aa6dd00997cea8b058b6db Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Thu, 29 Feb 2024 19:00:07 -0600 Subject: [PATCH] feat: Add struct conversion code --- lib/converter/date_time.ex | 86 +++++++++++++++++++++ lib/kindling/client.ex | 43 +++++++++++ lib/kindling/config.ex | 18 +---- lib/kindling/converter.ex | 89 ++++++++++++++++++++++ lib/kindling/embed_template.eex | 15 ++-- lib/kindling/schema.ex | 8 +- lib/kindling/schema/resource.ex | 63 ++++++++------- lib/kindling/template.eex | 15 ++-- lib/kindling/templates.ex | 18 ++--- lib/kindling/templates/functions.ex | 6 -- lib/mix/tasks/kindling/generate_schemas.ex | 15 ++-- 11 files changed, 293 insertions(+), 83 deletions(-) create mode 100644 lib/converter/date_time.ex create mode 100644 lib/kindling/client.ex create mode 100644 lib/kindling/converter.ex diff --git a/lib/converter/date_time.ex b/lib/converter/date_time.ex new file mode 100644 index 0000000..8e2ad22 --- /dev/null +++ b/lib/converter/date_time.ex @@ -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 diff --git a/lib/kindling/client.ex b/lib/kindling/client.ex new file mode 100644 index 0000000..5b69377 --- /dev/null +++ b/lib/kindling/client.ex @@ -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 diff --git a/lib/kindling/config.ex b/lib/kindling/config.ex index 53cbe1e..fef6e23 100644 --- a/lib/kindling/config.ex +++ b/lib/kindling/config.ex @@ -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 diff --git a/lib/kindling/converter.ex b/lib/kindling/converter.ex new file mode 100644 index 0000000..8e140ef --- /dev/null +++ b/lib/kindling/converter.ex @@ -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 diff --git a/lib/kindling/embed_template.eex b/lib/kindling/embed_template.eex index 015cb03..9d7567d 100644 --- a/lib/kindling/embed_template.eex +++ b/lib/kindling/embed_template.eex @@ -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 diff --git a/lib/kindling/schema.ex b/lib/kindling/schema.ex index 05f0a64..06955c2 100644 --- a/lib/kindling/schema.ex +++ b/lib/kindling/schema.ex @@ -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 diff --git a/lib/kindling/schema/resource.ex b/lib/kindling/schema/resource.ex index 74d921c..f6a994c 100644 --- a/lib/kindling/schema/resource.ex +++ b/lib/kindling/schema/resource.ex @@ -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 diff --git a/lib/kindling/template.eex b/lib/kindling/template.eex index 7898f29..034e114 100644 --- a/lib/kindling/template.eex +++ b/lib/kindling/template.eex @@ -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) diff --git a/lib/kindling/templates.ex b/lib/kindling/templates.ex index 856ff4c..84deffc 100644 --- a/lib/kindling/templates.ex +++ b/lib/kindling/templates.ex @@ -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 diff --git a/lib/kindling/templates/functions.ex b/lib/kindling/templates/functions.ex index 27b2928..7f34da8 100644 --- a/lib/kindling/templates/functions.ex +++ b/lib/kindling/templates/functions.ex @@ -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 diff --git a/lib/mix/tasks/kindling/generate_schemas.ex b/lib/mix/tasks/kindling/generate_schemas.ex index b22c7e7..346ca55 100644 --- a/lib/mix/tasks/kindling/generate_schemas.ex +++ b/lib/mix/tasks/kindling/generate_schemas.ex @@ -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 " - ) + if Enum.count(args) != 2 do + Mix.shell().error("usage: mix mix kindling.generate_schemas ") + + 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