From a6016e31fdd142d4d2b3b612d7fd305e2c7e6308 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Fri, 23 Feb 2024 12:19:58 -0600 Subject: [PATCH] feat: Recursively generate resource code --- lib/kindling/schema.ex | 14 ++++++++ lib/kindling/schema/resource.ex | 42 +++++++++++++++++++--- lib/kindling/template.eex | 20 +++++++++++ lib/kindling/templates.ex | 19 +++++++--- lib/kindling/templates/functions.ex | 3 ++ lib/mix/tasks/kindling/generate_schemas.ex | 25 +++++++++++++ 6 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 lib/mix/tasks/kindling/generate_schemas.ex diff --git a/lib/kindling/schema.ex b/lib/kindling/schema.ex index 7e20ffa..f786e0b 100644 --- a/lib/kindling/schema.ex +++ b/lib/kindling/schema.ex @@ -1,5 +1,6 @@ defmodule Kindling.Schema do alias Kindling.Version + alias Kindling.Schema.Resource def schema_object(version) do filename = Path.join(Version.version_dir(version), "fhir.schema.json") @@ -8,4 +9,17 @@ defmodule Kindling.Schema do |> File.read!() |> Jason.decode!() end + + def refs_recursive(schema, root_name) do + do_recurse(schema, MapSet.new([root_name]), [root_name]) + end + + defp do_recurse(_schema, visited, []), do: visited + + defp do_recurse(schema, visited, [hd | tail]) do + refs = Resource.refs(schema["definitions"][hd]) + new_refs = MapSet.difference(refs, visited) + new_visited = MapSet.union(visited, refs) + do_recurse(schema, new_visited, MapSet.to_list(new_refs) ++ tail) + end end diff --git a/lib/kindling/schema/resource.ex b/lib/kindling/schema/resource.ex index 156f8b8..623a696 100644 --- a/lib/kindling/schema/resource.ex +++ b/lib/kindling/schema/resource.ex @@ -1,5 +1,5 @@ defmodule Kindling.Schema.Resource do - @empty_properties %{const: [], has_one: [], has_many: [], enum: [], value: []} + @empty_properties %{array: [], const: [], has_one: [], has_many: [], enum: [], value: []} def properties(df) do df["properties"] @@ -7,7 +7,7 @@ defmodule Kindling.Schema.Resource do def grouped_properties(df) do properties = - (df["properties"] || []) + (df["properties"] || %{}) |> Enum.map(fn {key, value} -> {key, value, property_type(value)} end) |> Enum.group_by(fn {_key, _value, type} -> type @@ -16,8 +16,25 @@ defmodule Kindling.Schema.Resource do Map.merge(@empty_properties, properties) end + def all_fields(df) do + (df["properties"] || %{}) + |> Enum.filter(fn {_name, definition} -> + property_type(definition) in [:array, :enum, :value] + end) + |> Enum.map(fn {name, _} -> name end) + end + + def required_fields(df) do + df + |> required() + |> Enum.filter(fn {_name, definition} -> + property_type(definition) in [:array, :enum, :value] + end) + |> Enum.map(fn {name, _} -> name end) + end + def required(df) do - Map.take(df["properties"], df["required"]) + Map.take(df["properties"] || %{}, df["required"] || []) end def required?(df, key) do @@ -26,7 +43,24 @@ defmodule Kindling.Schema.Resource do def property_type(%{"const" => _}), do: :const def property_type(%{"$ref" => _}), do: :has_one - def property_type(%{"items" => _}), do: :has_many + def property_type(%{"items" => %{"$ref" => _}}), do: :has_many + def property_type(%{"items" => %{"enum" => _}}), do: :array def property_type(%{"enum" => _}), do: :enum def property_type(_), do: :value + + def refs(df) do + (df["properties"] || %{}) + |> Enum.map(fn + {_, %{"$ref" => "#/definitions/" <> name}} -> + name + + {_, %{"items" => %{"$ref" => "#/definitions/" <> name}}} -> + name + + _other -> + nil + end) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + end end diff --git a/lib/kindling/template.eex b/lib/kindling/template.eex index 84efc8f..8544436 100644 --- a/lib/kindling/template.eex +++ b/lib/kindling/template.eex @@ -1,5 +1,13 @@ defmodule <%= @namespace %>.<%= @version %>.<%= @resource_name %> do use Ecto.Schema + import Ecto.Changeset + + @fields [ + <%= @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") %> + ] schema "<%= Recase.to_snake(@resource_name) %>" do <%= if @properties.const != [] do %># Constants<% end %> @@ -8,6 +16,8 @@ defmodule <%= @namespace %>.<%= @version %>.<%= @resource_name %> do <%= if @properties.value != [] do %># Fields<% end %> <%= for {name, df, _} <- @properties.value do %>field :<%= Recase.to_snake(name) %>, :<%= fhir_type_to_ecto(df) %> <% end %> + <%= for {name, df, _} <- @properties.array do %>field :<%= Recase.to_snake(name) %>, <%= inspect(fhir_type_to_ecto(df)) %> + <% end %> <%= 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 %> @@ -18,4 +28,14 @@ defmodule <%= @namespace %>.<%= @version %>.<%= @resource_name %> do <%= for {name, df, _} <- @properties.has_many do %>has_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %> <% end %> end + + def base_changeset(data \\ %__MODULE__{}, attrs) do + data + |> cast(attrs, @fields) + <%= for {name, df, _} <- @properties.has_one do %>|> cast_assoc(:<%= Recase.to_snake(name) %>, with: &<%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>.base_changeset/1) + <% end %> + <%= for {name, df, _} <- @properties.has_many do %>|> cast_assoc(:<%= Recase.to_snake(name) %>, with: &<%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %>.base_changeset/1) + <% end %> + |> validate_required(@required_fields) + end end diff --git a/lib/kindling/templates.ex b/lib/kindling/templates.ex index 4e1752e..314253d 100644 --- a/lib/kindling/templates.ex +++ b/lib/kindling/templates.ex @@ -12,17 +12,28 @@ defmodule Kindling.Templates do namespace: namespace, version: version, resource_name: resource_name, - properties: Resource.grouped_properties(resource) + properties: Resource.grouped_properties(resource), + all_fields: Resource.all_fields(resource), + required_fields: Resource.required_fields(resource) } assigns |> render() |> Code.format_string!(file: "#{resource_name}.ex") end def write_code(namespace, version, resource_name, resource) do - dir = Path.join(String.downcase(namespace), String.downcase(version)) - file = Path.join(dir, "#{Recase.to_snake(resource_name)}.ex") + dir = Path.join(["lib", String.downcase(namespace), String.downcase(version)]) + file = Path.join([dir | to_source_file_name(resource_name)]) - File.mkdir_p!(dir) + File.mkdir_p!(Path.dirname(file)) File.write!(file, resource_code(namespace, version, resource_name, resource)) end + + def to_source_file_name(resource_name) do + resource_name + |> String.split("_") + |> Enum.reverse() + |> Enum.map(&Recase.to_snake/1) + |> then(fn [hd | tail] -> ["#{hd}.ex" | tail] end) + |> Enum.reverse() + end end diff --git a/lib/kindling/templates/functions.ex b/lib/kindling/templates/functions.ex index e8559a3..e286365 100644 --- a/lib/kindling/templates/functions.ex +++ b/lib/kindling/templates/functions.ex @@ -8,6 +8,7 @@ defmodule Kindling.Templates.Functions do @decimal_patterns ["^-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?$"] def fhir_type_to_ecto(%{"type" => "string"}), do: :string + def fhir_type_to_ecto(%{"type" => "boolean"}), do: :boolean def fhir_type_to_ecto(%{"type" => "number", "pattern" => pattern}) when pattern in @int_patterns, @@ -17,6 +18,8 @@ defmodule Kindling.Templates.Functions do when pattern in @decimal_patterns, do: :decimal + def fhir_type_to_ecto(%{"type" => "array", "items" => %{"enum" => _}}), do: {:array, :string} + def ref_to_class_name("#/definitions/" <> name), do: name |> String.replace("_", ".") |> Recase.to_pascal() end diff --git a/lib/mix/tasks/kindling/generate_schemas.ex b/lib/mix/tasks/kindling/generate_schemas.ex new file mode 100644 index 0000000..ada570b --- /dev/null +++ b/lib/mix/tasks/kindling/generate_schemas.ex @@ -0,0 +1,25 @@ +defmodule Mix.Tasks.Kindling.GenerateSchemas do + @moduledoc "Generates FHIR schemas" + @shortdoc "Generates FHIR schemas" + + use Mix.Task + + @impl Mix.Task + def run(args) do + if Enum.count(args) != 3 do + Mix.shell().error( + "usage: mix mix kindling.generate_schemas " + ) + end + + [namespace, version, root] = args + + schema = Kindling.Schema.schema_object(version) + to_generate = Kindling.Schema.refs_recursive(schema, root) + + Enum.each( + to_generate, + &Kindling.Templates.write_code(namespace, version, &1, schema["definitions"][&1]) + ) + end +end