From 9e9c45aee19b7f8da321e711e06d9415b92590ef Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Fri, 12 Apr 2024 10:16:58 -0500 Subject: [PATCH] chore: Add documentation --- LICENSE | 9 + README.md | 209 ++++++++++++++++++++- lib/kindling.ex | 18 +- lib/kindling/client.ex | 68 ++++++- lib/kindling/client/req.ex | 5 + lib/kindling/config.ex | 8 + lib/kindling/converter.ex | 15 ++ lib/{ => kindling}/converter/date_time.ex | 27 +++ lib/kindling/embed_template.eex | 5 +- lib/kindling/schema.ex | 34 +++- lib/kindling/schema/resource.ex | 113 +++++++++-- lib/kindling/schema_downloader.ex | 39 +++- lib/kindling/template.eex | 5 +- lib/kindling/templates.ex | 4 + lib/kindling/templates/functions.ex | 22 ++- lib/kindling/version.ex | 4 + lib/mix/tasks/kindling/generate_schemas.ex | 4 +- mix.exs | 27 ++- mix.lock | 9 + 19 files changed, 563 insertions(+), 62 deletions(-) create mode 100644 LICENSE create mode 100644 lib/kindling/client/req.ex rename lib/{ => kindling}/converter/date_time.ex (78%) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6348e47 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 Robert Prehn, Mythic Insight Cooperative Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 666154e..2df815b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ -# Kindling - -**TODO: Add description** +Kindling is a library for working with [HL7 FHIR](https://hl7.org/fhir/) APIs. It can generate +each FHIR resource as an `Ecto.Schema`. It also contains a client for working with the FHIR REST +API. ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `kindling` to your list of dependencies in `mix.exs`: +Kindling can be installed by adding `kindling` to your list of dependencies in `mix.exs`: ```elixir def deps do @@ -15,7 +14,201 @@ def deps do end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . +You should then configure your root resources, which are the FHIR resources that your application +uses: +```elixir +config :kindling, root_resources: ["Bundle", "Patient", "Encounter"] +``` + +When you generate resource schemas, Kindling will generate these schemas, plus any that they reference (recursively). + +## Generating Resource Schemas + +[mix kindling.generate_schemas](`Mix.Tasks.Kindling.GenerateSchemas`) will generate Elixir source files for the resource schemas under a namespace module under the `lib/` directory. It takes two arguments: the name of the namespace module, and a FHIR version. + +Example: + +```sh +mix kindling.generate_schemas FHIR R4 +``` + +## Example Schema Module + +The generated schema modules are normal `Ecto.Schema`s. Here's an example of a Patient resource schema: + +```elixir +defmodule FHIR.R4.Patient do + use Ecto.Schema + import Ecto.Changeset + + @fields [ + :active, + :multiple_birth_boolean, + :language, + :implicit_rules, + :birth_date, + :multiple_birth_integer, + :id, + :deceased_boolean, + :gender, + :deceased_date_time + ] + @required_fields [] + + @primary_key {:id, :binary_id, autogenerate: true} + schema "patient" do + # Constants + field(:resource_type, :string, virtual: true, default: "Patient") + + # Fields + field(:active, :boolean) + field(:multiple_birth_boolean, :boolean) + field(:language, :string) + field(:implicit_rules, :string) + field(:birth_date, :date) + field(:multiple_birth_integer, :integer) + field(:deceased_boolean, :boolean) + field(:deceased_date_time, :string) + + # Enum + field(:gender, Ecto.Enum, values: [:male, :female, :other, :unknown]) + + # Embed One + embeds_one(:marital_status, FHIR.R4.CodeableConcept) + embeds_one(:managing_organization, FHIR.R4.Reference) + embeds_one(:text, FHIR.R4.Narrative) + embeds_one(:meta, FHIR.R4.Meta) + + # Embed Many + embeds_many(:photo, FHIR.R4.Attachment) + embeds_many(:communication, FHIR.R4.Patient.Communication) + embeds_many(:name, FHIR.R4.HumanName) + embeds_many(:extension, FHIR.R4.Extension) + embeds_many(:telecom, FHIR.R4.ContactPoint) + embeds_many(:contained, FHIR.R4.ResourceList) + embeds_many(:link, FHIR.R4.Patient.Link) + embeds_many(:contact, FHIR.R4.Patient.Contact) + embeds_many(:modifier_extension, FHIR.R4.Extension) + embeds_many(:identifier, FHIR.R4.Identifier) + embeds_many(:general_practitioner, FHIR.R4.Reference) + embeds_many(:address, FHIR.R4.Address) + end + + def version, do: FHIR.R4 + def path, do: "/Patient" + + def base_changeset(data \\ %__MODULE__{}, attrs) do + data + |> cast(attrs, @fields) + |> validate_required(@required_fields) + end +end +``` + +## API Client + +Kindling also includes a [FHIR REST API client](`Kindling.Client`) that can be used to request resources +from a FHIR server. The client will automatically convert the results to resource schema structs: + +```elixir +# Use the public FHIR test server +client = %{ + base_url: "http://hapi.fhir.org/baseR4", + auth_mode: :open +} + +patient_id = "593166" + +Kindling.Client.read(client, FHIR.R4.Patient, patient_id) +``` + +Results in a struct something like this: + +```elixir +%FHIR.R4.Patient{ + __meta__: #Ecto.Schema.Metadata<:built, "patient">, + id: "593166", + resource_type: "Patient", + active: nil, + multiple_birth_boolean: nil, + language: nil, + implicit_rules: nil, + birth_date: ~D[2000-10-31], + multiple_birth_integer: nil, + deceased_boolean: nil, + deceased_date_time: nil, + gender: :female, + marital_status: nil, + managing_organization: nil, + text: %FHIR.R4.Narrative{ + id: nil, + div: "
Sabrina SPELLMAN
", + status: :generated, + extension: [] + }, + meta: %FHIR.R4.Meta{ + id: nil, + last_updated: {:error, :invalid_format}, + source: "#NPQrzINFNuDwuDgM", + version_id: "1", + profile: nil, + extension: [], + security: [], + tag: [] + }, + photo: [], + communication: [], + name: [ + %FHIR.R4.HumanName{ + id: nil, + family: "Spellman", + text: nil, + given: ["Sabrina"], + prefix: nil, + suffix: nil, + use: nil, + period: nil, + extension: [] + } + ], + extension: [], + telecom: [ + %FHIR.R4.ContactPoint{ + id: nil, + rank: nil, + value: "1(845)443-7666", + system: :phone, + use: :home, + period: nil, + extension: [] + } + ], + contained: [], + link: [], + contact: [], + modifier_extension: [], + identifier: [], + general_practitioner: [], + address: [ + %FHIR.R4.Address{ + id: nil, + city: "Greendale", + country: "United States", + district: nil, + postal_code: "11199", + state: "New York", + text: nil, + line: ["1138 Decario Lane"], + type: nil, + use: nil, + period: nil, + extension: [] + } + ] +} +``` + +## Additional Resources + +The docs can be found at . diff --git a/lib/kindling.ex b/lib/kindling.ex index 47395d5..4987942 100644 --- a/lib/kindling.ex +++ b/lib/kindling.ex @@ -1,18 +1,4 @@ defmodule Kindling do - @moduledoc """ - Documentation for `Kindling`. - """ - - @doc """ - Hello world. - - ## Examples - - iex> Kindling.hello() - :world - - """ - def hello do - :world - end + @external_resource "README.md" + @moduledoc File.read!("README.md") end diff --git a/lib/kindling/client.ex b/lib/kindling/client.ex index af974e1..d590bad 100644 --- a/lib/kindling/client.ex +++ b/lib/kindling/client.ex @@ -1,9 +1,44 @@ defmodule Kindling.Client do + @moduledoc """ + FHIR API client. + """ alias Kindling.Converter defstruct [:base_url, :access_token, auth_mode: :bearer] - def read(client, resource_module, id, opts \\ []) do + @typedoc """ + Configuration of a FHIR client. + """ + @type t :: %{ + base_url: fhir_server_base_url(), + access_token: access_token(), + auth_mode: :bearer | :basic | :open + } + @typedoc """ + The base URL of the FHIR server. Often (but not always), includes the FHIR version in the URL path. + """ + @type fhir_server_base_url :: String.t() + @typedoc """ + The FHIR access token. Used when :auth_mode is :bearer or :basic. Ignored when :auth_mode is :open. + """ + @type access_token :: String.t() + + @typedoc """ + A string that is the ID of a FHIR resource. + """ + @type resource_id :: String.t() + + @doc """ + Make a read REST request against a FHIR API server, for a resource of type `resource_module` + and the id `id`. Returns a schema struct of the results, or an error. + + `opts`: + - `headers`: additional HTTP request headers to send with the request, as a list of {key, value} + pairs. + """ + @spec read(t(), Kindling.Schema.Resource.t(), resource_id()) :: + {:ok, Kindling.Schema.Resource.schema(), Keyword.t()} | term() + def read(client, resource_module, id, opts \\ [], req_fn \\ &Kindling.Client.Req.get/2) 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, []) @@ -11,17 +46,35 @@ defmodule Kindling.Client do headers = headers(client, more_headers) uri - |> Req.get(headers: headers) + |> req_fn.(headers: headers) |> case do {:ok, %{status: status} = response} when status >= 200 and status < 300 -> - Converter.convert(resource_module.version(), response.body) + Converter.convert(resource_module.version_namespace(), response.body) other -> other end end - def search(client, resource_module, params \\ [], opts \\ []) do + @doc """ + Make a search REST request against a FHIR API server, for a resource of type `resource_module` + using the search params `params`. + + Returns a schema struct of the results (usually a FHIR bundle), or an error. + + `opts`: + - `headers`: additional HTTP request headers to send with the request, as a list of {key, value} + pairs. + """ + @spec search(t(), Kindling.Schema.Resource.t(), Keyword.t(), Keyword.t()) :: + {:ok, Kindling.Schema.Resource.schema()} | term() + def search( + client, + resource_module, + params \\ [], + opts \\ [], + req_fn \\ &Kindling.Client.Req.get/2 + ) do base_uri = URI.parse(client.base_url) query = URI.encode_query(params) uri = base_uri |> URI.append_path(resource_module.path()) |> URI.append_query(query) @@ -30,16 +83,17 @@ defmodule Kindling.Client do headers = headers(client, more_headers) uri - |> Req.get(headers: headers) + |> req_fn.(headers: headers) |> case do {:ok, %{status: status} = response} when status >= 200 and status < 300 -> - Converter.convert(resource_module.version(), response.body) + Converter.convert(resource_module.version_namespace(), response.body) other -> other end end + @doc false def headers(client, more_headers) do case client.auth_mode do :open -> @@ -50,12 +104,14 @@ defmodule Kindling.Client do end end + @doc false 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}"} + @doc false def format_header do {"Accept", "application/json"} end diff --git a/lib/kindling/client/req.ex b/lib/kindling/client/req.ex new file mode 100644 index 0000000..b8f3a26 --- /dev/null +++ b/lib/kindling/client/req.ex @@ -0,0 +1,5 @@ +defmodule Kindling.Client.Req do + def get(request, opts \\ []) do + Req.get(request, opts) + end +end diff --git a/lib/kindling/config.ex b/lib/kindling/config.ex index f069a26..e3b3ab7 100644 --- a/lib/kindling/config.ex +++ b/lib/kindling/config.ex @@ -1,4 +1,12 @@ defmodule Kindling.Config do + @moduledoc """ + Configuration for Kindling. + """ + + @doc """ + Returns the list of configured root resources. + """ + @spec root_resources() :: [String.t()] def root_resources, do: Application.get_env(:kindling, :root_resources, ["Bundle", "Patient", "Encounter"]) end diff --git a/lib/kindling/converter.ex b/lib/kindling/converter.ex index 611154d..c60fcc8 100644 --- a/lib/kindling/converter.ex +++ b/lib/kindling/converter.ex @@ -1,6 +1,16 @@ defmodule Kindling.Converter do + @moduledoc """ + Convert between JSON-style maps and Elixir structs, using the generated resource Ecto schemas as + a guide. + """ alias Kindling.Schema.Resource + @doc """ + Convert a JSON-style map with string keys to a FHIR resource schema. `version_namespace` is + the module namespace where your resource schema module have been generated (e.g. `FHIR.R4`) + and `resource_json` is the map of data. + """ + @spec convert(atom() | binary(), map()) :: Kindling.Schema.Resource.schema() def convert(version_namespace, %{"resourceType" => resource_type} = resource_json) do resource_module = Module.concat(version_namespace, Resource.class_name(resource_type)) resource_list_module = Module.concat(version_namespace, "ResourceList") @@ -8,6 +18,7 @@ defmodule Kindling.Converter do structify(resource_module, resource_list_module, resource_json) end + @doc false def structify( resource_module, resource_list_module, @@ -40,6 +51,7 @@ defmodule Kindling.Converter do struct!(resource_module, atom_map) end + @doc false def convert_field(resource_module, resource_list_module, field, value) do cond do field in resource_module.__schema__(:associations) -> @@ -56,6 +68,7 @@ defmodule Kindling.Converter do end end + @doc false def cast_field(resource_module, field, value) do type = resource_module.__schema__(:type, field) @@ -82,6 +95,7 @@ defmodule Kindling.Converter do end end + @doc false def convert_association(resource_module, resource_list_module, field, value) do %{cardinality: cardinality, related: type} = resource_module.__schema__(:association, field) @@ -94,6 +108,7 @@ defmodule Kindling.Converter do end end + @doc false def convert_embed(resource_module, resource_list_module, field, value) do %{cardinality: cardinality, related: type} = resource_module.__schema__(:embed, field) diff --git a/lib/converter/date_time.ex b/lib/kindling/converter/date_time.ex similarity index 78% rename from lib/converter/date_time.ex rename to lib/kindling/converter/date_time.ex index 8e2ad22..b63be3a 100644 --- a/lib/converter/date_time.ex +++ b/lib/kindling/converter/date_time.ex @@ -1,4 +1,10 @@ defmodule Kindling.Converter.DateTime do + @moduledoc """ + Handles dates and datetimes in FHIR's format. Dates may be in ISO8601 format, or may be in a truncated + form that indicates only certain parts of a date (e.g. year, or year+month, etc). DateTimes work + similarly. + """ + defstruct year: "", month: "", day: "", @@ -7,6 +13,18 @@ defmodule Kindling.Converter.DateTime do second: "", zone: "" + @doc """ + Parse a string containing a FHIR-style datetime. Return `{:ok, DateTime.t(), integer()}` or a parse + error. + """ + @spec parse(binary()) :: + {:error, + :incompatible_calendars + | :invalid_date + | :invalid_format + | :invalid_time + | :missing_offset} + | {:ok, DateTime.t(), integer()} def parse(value) do value |> String.codepoints() @@ -16,6 +34,7 @@ defmodule Kindling.Converter.DateTime do |> DateTime.from_iso8601() end + @doc false def to_iso_string(%{ year: year, month: month, @@ -28,6 +47,7 @@ defmodule Kindling.Converter.DateTime do "#{year}-#{month}-#{day}T#{hour}:#{minute}:#{second}#{zone}" end + @doc false def set_defaults(%{ year: year, month: month, @@ -51,36 +71,43 @@ defmodule Kindling.Converter.DateTime do defp format("", default), do: default defp format(string, default), do: String.pad_leading(string, String.length(default), "0") + @doc false 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}) + @doc false 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}) + @doc false 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}) + @doc false 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}) + @doc false 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}) + @doc false 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}) + @doc false 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/embed_template.eex b/lib/kindling/embed_template.eex index 9d7567d..fec282c 100644 --- a/lib/kindling/embed_template.eex +++ b/lib/kindling/embed_template.eex @@ -19,7 +19,7 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do <%= 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)) %> + <%= for {name, df, _} <- @properties.enum do %>field :<%= Recase.to_snake(name) %>, Ecto.Enum, values: <%= enum_value_string(df) %> <% end %> <%= 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"]) %> @@ -35,6 +35,9 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do <% end %> end + def version_namespace, do: <%= @namespace %>.<%= @version %> + def version, do: "<%= @version %>" + def base_changeset(data \\ %__MODULE__{}, attrs) do data |> cast(attrs, @fields) diff --git a/lib/kindling/schema.ex b/lib/kindling/schema.ex index 06955c2..c7363a0 100644 --- a/lib/kindling/schema.ex +++ b/lib/kindling/schema.ex @@ -1,8 +1,22 @@ defmodule Kindling.Schema do - alias Kindling.Version - alias Kindling.Schema.Resource + @moduledoc """ + Tools for working with the JSON schema for FHIR. + """ - def schema_object(version) do + alias Kindling.Schema.Resource + alias Kindling.Version + + @typedoc """ + A string representing a particular FHIR version ("R5" | "R4B" | "R4" | "R3" ). + """ + @type version_string :: String.t() + + @doc """ + Load and process a JSON schema file for the given `version`, return the map defining the spec + for that version. + """ + @spec schema_map(version_string()) :: map() + def schema_map(version) do filename = Path.join(Version.version_dir(version), "fhir.schema.json") filename @@ -11,6 +25,13 @@ defmodule Kindling.Schema do |> build_backlinks() end + def all_resources(schema) do + schema + |> get_in(["discriminator", "mapping"]) + |> Map.keys() + end + + @doc false def build_backlinks(schema) do new_defs = schema["definitions"] @@ -36,6 +57,13 @@ defmodule Kindling.Schema do Map.put(schema, "definitions", defs) end + @doc """ + Given a schema map in the style returned by `&schema_map/1`, and a root resource name (e.g. + `"Encounter"`), return a `MapSet` of resources that are referenced (recursively) from the + resource. This can be used to determine the set of resource schemas that need to be generated + to full support that resource as a fully-defined set or schemas. + """ + @spec refs_recursive(map(), String.t()) :: MapSet.t() def refs_recursive(schema, root_name) when is_binary(root_name) do do_recurse(schema, MapSet.new([root_name]), [root_name]) end diff --git a/lib/kindling/schema/resource.ex b/lib/kindling/schema/resource.ex index f6a994c..8a361a4 100644 --- a/lib/kindling/schema/resource.ex +++ b/lib/kindling/schema/resource.ex @@ -1,32 +1,68 @@ defmodule Kindling.Schema.Resource do - @empty_properties %{ - array: [], - const: [], - embeds_one: [], - embeds_many: [], - has_one: [], - has_many: [], - enum: [], - value: [] - } + @moduledoc """ + Functions for working on entries of the `definitions` section of the FHIR schema, which define + the Resources available in FHIR. + """ + @typedoc """ + A module representing a given FHIR resource, generated by + [mix kindling.generate_schemas](`Mix.Tasks.Kindling.GenerateSchemas`). + """ + @type t() :: atom() + + @typedoc """ + An `Ecto.Schema` struct representing a FHIR Resource. + """ + @type schema() :: Ecto.Schema.t() + + @typedoc """ + A string-keyed map that represents one resource in the `definitions` section of the FHIR schema. + """ + @type definition() :: map() + + defstruct array: [], + const: [], + embeds_one: [], + embeds_many: [], + has_one: [], + has_many: [], + enum: [], + value: [] + + @type grouped_properties_struct :: %__MODULE__{} + + @doc """ + Return the property fields of a given definition map. + """ + @spec properties(definition()) :: map() def properties(df) do df["properties"] end + @doc """ + Given the definition `df` and a list of root resource types, return a struct which contains + all the properties of `df`, grouped by the type of field that should be used to represent them ( + :array, :const, :embeds_one, :embeds_many, :has_one, :has_many, :enum, :value). + """ + @spec grouped_properties(definition(), [String.t()]) :: grouped_properties_struct() def grouped_properties(df, roots) do properties = (df["properties"] || %{}) |> Map.delete("id") - |> Enum.reject(&is_element/1) + |> Enum.reject(&element?/1) |> Enum.map(fn {key, value} -> {key, value, property_type(value, roots)} end) |> Enum.group_by(fn {_key, _value, type} -> type end) - Map.merge(@empty_properties, properties) + Map.merge(%__MODULE__{}, properties) end + @doc """ + Given the definition `df` return a list of all the properties which are "simple values", i.e. + arrays, enum values, or base value types like integer or string. + """ + @spec all_fields(definition()) :: [String.t()] def all_fields(df) do (df["properties"] || %{}) |> Enum.filter(fn {_name, definition} -> @@ -35,6 +71,12 @@ defmodule Kindling.Schema.Resource do |> Enum.map(fn {name, _} -> name end) end + @doc """ + Given the definition `df` return a list of all the properties which are "simple values", i.e. + arrays, enum values, or base value types like integer or string AND are required by the FHIR + spec. + """ + @spec required_fields(definition()) :: [String.t()] def required_fields(df) do df |> required() @@ -44,18 +86,27 @@ defmodule Kindling.Schema.Resource do |> Enum.map(fn {name, _} -> name end) end + @doc """ + Return a map of all properties (simple or reference) that are required under the FHIR spec. + """ + @spec required(definition()) :: map() def required(df) do Map.take(df["properties"] || %{}, df["required"] || []) end + @doc """ + Return true if `key` is required in the definition `df`, else return false. + """ + @spec required?(definition(), any()) :: boolean() def required?(df, key) do key in df["required"] end + @doc false def property_type(%{"const" => _}, _roots), do: :const def property_type(%{"$ref" => "#/definitions/" <> name}, roots) do - if is_class_name(name) do + if class_name?(name) do if name in roots do :has_one else @@ -67,7 +118,7 @@ defmodule Kindling.Schema.Resource do end def property_type(%{"items" => %{"$ref" => "#/definitions/" <> name}}, roots) do - if is_class_name(name) do + if class_name?(name) do if name in roots do :has_many else @@ -82,6 +133,11 @@ defmodule Kindling.Schema.Resource do def property_type(%{"enum" => _}, _roots), do: :enum def property_type(_, _roots), do: :value + @doc """ + Return all properties of `df` that are references to other resource (i.e. are not simple value + types). + """ + @spec refs(definition()) :: MapSet.t() def refs(df) do (df["properties"] || %{}) |> Enum.map(fn @@ -95,21 +151,38 @@ defmodule Kindling.Schema.Resource do nil end) |> Enum.reject(&is_nil/1) - |> Enum.filter(&is_class_name/1) + |> Enum.filter(&class_name?/1) |> MapSet.new() end - def is_class_name(name) do + @doc """ + Return true if the resource type name matches the convention for resource type names (i.e. they + start with a capital letter). + """ + @spec class_name?(binary()) :: boolean() + def class_name?(name) do 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 + @doc false + def element?({_name, %{"$ref" => "#/definitions/Element"}}), do: true + def element?({name, %{"items" => items}}), do: element?({name, items}) + def element?(_), do: false + @doc """ + Convert from a definition reference in the FHIR spec (i.e. a string like + `"#/definitions/ResourceName"`) to the corresponding Elixir-style module name (i.e. + `ResourceName`) as a string. + """ + @spec ref_to_class_name(String.t()) :: String.t() def ref_to_class_name("#/definitions/" <> name), do: class_name(name) + @doc """ + Convert from a FHIR spec resource type name ("Encounter_patient") to the corresponding + Elixir-style module name as a string ("Encounter.Patient"). + """ + @spec class_name(String.t()) :: String.t() def class_name(name), - do: name |> String.split("_") |> Enum.map(&Recase.to_pascal/1) |> Enum.join(".") + do: name |> String.split("_") |> Enum.map_join(".", &Recase.to_pascal/1) end diff --git a/lib/kindling/schema_downloader.ex b/lib/kindling/schema_downloader.ex index 03725af..dd3bbb6 100644 --- a/lib/kindling/schema_downloader.ex +++ b/lib/kindling/schema_downloader.ex @@ -1,10 +1,26 @@ defmodule Kindling.SchemaDownloader do + @moduledoc """ + Handles downloading and unzipping FHIR JSON schemas from the hl7 server. + """ + + @version_aliases %{ + "R3" => "STU3" + } + + @doc """ + Download and unzip the JSON schema file for the given `version` into the _build directory for + the library to reference. + """ + @spec download_version(Kindling.Schema.version_string()) :: :ok | {:error, any()} def download_version(version) do + {:ok, _} = Application.ensure_all_started(:req) + version_alias = Map.get(@version_aliases, version, version) + version_dir = "#{Mix.Project.build_path(Mix.Project.config())}/fhir/#{version}" filename = "#{version_dir}/fhir.schema.json.zip" File.mkdir_p!(version_dir) - "http://hl7.org/fhir/#{version}/fhir.schema.json.zip" + "http://hl7.org/fhir/#{version_alias}/fhir.schema.json.zip" |> Req.get!() |> then(&File.write!(filename, &1.body)) @@ -13,7 +29,28 @@ defmodule Kindling.SchemaDownloader do :ok other -> + dbg(other) other end end + + @doc """ + Check if the `version` JSON schema is downloaded and unzipped for use. If not, download and unzip + it. If that download & unzip fails, raise an error. + """ + def ensure_version!(version) do + version_dir = "#{Mix.Project.build_path(Mix.Project.config())}/fhir/#{version}" + filename = "#{version_dir}/fhir.schema.json" + + cond do + File.exists?(filename) -> + :ok + + download_version(version) == :ok -> + :ok + + true -> + raise "Could not download and unzip the FHIR schema for version #{version}." + end + end end diff --git a/lib/kindling/template.eex b/lib/kindling/template.eex index 034e114..6255953 100644 --- a/lib/kindling/template.eex +++ b/lib/kindling/template.eex @@ -20,7 +20,7 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do <%= 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)) %> + <%= for {name, df, _} <- @properties.enum do %>field :<%= Recase.to_snake(name) %>, Ecto.Enum, values: <%= enum_value_string(df) %> <% end %> <%= 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"]) %> @@ -39,7 +39,8 @@ defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do <% end %> end - def version, do: <%= @namespace %>.<%= @version %> + def version_namespace, do: <%= @namespace %>.<%= @version %> + def version, do: "<%= @version %>" def path, do: "/<%= @resource_name %>" def base_changeset(data \\ %__MODULE__{}, attrs) do diff --git a/lib/kindling/templates.ex b/lib/kindling/templates.ex index 84deffc..34ce06a 100644 --- a/lib/kindling/templates.ex +++ b/lib/kindling/templates.ex @@ -1,4 +1,8 @@ defmodule Kindling.Templates do + @moduledoc """ + Template functions for generating FHIR Resource modules. + """ + require EEx import Kindling.Templates.Functions diff --git a/lib/kindling/templates/functions.ex b/lib/kindling/templates/functions.ex index 5169f72..908c54f 100644 --- a/lib/kindling/templates/functions.ex +++ b/lib/kindling/templates/functions.ex @@ -1,11 +1,28 @@ defmodule Kindling.Templates.Functions do + @moduledoc """ + Helper functions used the in generator templates. + """ + @int_patterns [ "^[1-9][0-9]*$", "^[0]|([1-9][0-9]*)$", - "^-?([0]|([1-9][0-9]*))$" + "^-?([0]|([1-9][0-9]*))$", + "^[0]|[-+]?[1-9][0-9]*$" ] - @decimal_patterns ["^-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?$"] + @decimal_patterns [ + "^-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?$", + "^-?(0|[1-9][0-9]{0,17})(\\.[0-9]{1,17})?([eE][+-]?[0-9]{1,9}})?$" + ] + + def enum_value_string(df) do + df["enum"] + |> Enum.map(&Recase.to_snake(&1)) + |> Enum.map(&String.to_atom/1) + |> Enum.uniq() + |> Enum.map_join(",\n", &inspect/1) + |> then(&"[\n#{&1}\n]") + end def fhir_type_to_ecto(%{"type" => "string"}), do: :string def fhir_type_to_ecto(%{"type" => "boolean"}), do: :boolean @@ -30,6 +47,7 @@ defmodule Kindling.Templates.Functions do def fhir_type_to_ecto(%{"$ref" => "#/definitions/xhtml"}), do: :string def fhir_type_to_ecto(%{"$ref" => "#/definitions/markdown"}), do: :string def fhir_type_to_ecto(%{"$ref" => "#/definitions/base64Binary"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/integer64"}), do: :string def fhir_type_to_ecto(%{"$ref" => "#/definitions/date"}), do: :date def fhir_type_to_ecto(%{"$ref" => "#/definitions/dateTime"}), do: :utc_datetime_usec def fhir_type_to_ecto(%{"$ref" => "#/definitions/instant"}), do: :utc_datetime_usec diff --git a/lib/kindling/version.ex b/lib/kindling/version.ex index 3fcc8f9..af3c421 100644 --- a/lib/kindling/version.ex +++ b/lib/kindling/version.ex @@ -1,4 +1,8 @@ defmodule Kindling.Version do + @moduledoc """ + Functions for working with FHIR versions e.g. R3, R4, R5, STU3, etc. + """ + def version_dir(version), do: Path.join([Mix.Project.build_path(Mix.Project.config()), "fhir", version]) end diff --git a/lib/mix/tasks/kindling/generate_schemas.ex b/lib/mix/tasks/kindling/generate_schemas.ex index 346ca55..da75f57 100644 --- a/lib/mix/tasks/kindling/generate_schemas.ex +++ b/lib/mix/tasks/kindling/generate_schemas.ex @@ -14,7 +14,9 @@ defmodule Mix.Tasks.Kindling.GenerateSchemas do [namespace, version] = args - schema = Kindling.Schema.schema_object(version) + Kindling.SchemaDownloader.ensure_version!(version) + + schema = Kindling.Schema.schema_map(version) roots = Kindling.Config.root_resources() to_generate = diff --git a/mix.exs b/mix.exs index e2c1452..2ff3f69 100644 --- a/mix.exs +++ b/mix.exs @@ -4,13 +4,34 @@ defmodule Kindling.MixProject do def project do [ app: :kindling, - version: "0.1.0", + version: version(), elixir: "~> 1.15", start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + + # Docs + source_url: "https://gitlab.com/mythic-insight/kindling", + docs: [ + main: "Kindling" + ], + + # Hex + licenses: ["MIT"], + links: %{ + "source" => "https://gitlab.com/mythic-insight/kindling" + } ] end + def version do + [_, version] = + "README.md" + |> File.read!() + |> then(&Regex.run(~r/{:kindling, "~> ([^"]+)"}/, &1)) + + version + end + # Run "mix help compile.app" to learn about applications. def application do [ @@ -21,7 +42,9 @@ defmodule Kindling.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:credo, "~> 1.7", only: [:dev]}, {:ecto, "~> 3.11", only: [:dev, :test]}, + {:ex_doc, "~> 0.31.2", only: [:dev]}, {:jason, "~> 1.4"}, {:recase, "~> 0.7.0"}, {:req, "~> 0.4.11"} diff --git a/mix.lock b/mix.lock index ac91e31..399365f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,14 +1,23 @@ %{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, + "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, + "ex_doc": {:hex, :ex_doc, "0.31.2", "8b06d0a5ac69e1a54df35519c951f1f44a7b7ca9a5bb7a260cd8a174d6322ece", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "317346c14febaba9ca40fd97b5b5919f7751fb85d399cc8e7e8872049f37e0af"}, + "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, "nimble_ownership": {:hex, :nimble_ownership, "0.2.1", "3e44c72ebe8dd213db4e13aff4090aaa331d158e72ce1891d02e0ffb05a1eb2d", [:mix], [], "hexpm", "bf38d2ef4fb990521a4ecf112843063c1f58a5c602484af4c7977324042badee"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, "req": {:hex, :req, "0.4.11", "cb19f87d5251e7de30cfc67d1899696b290711092207c6b2e8fc2294f237fcdc", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bbf4f2393c649fa4146a3b8470e2a7e8c9b23e4100a16c75f5e7d1d3d33144f3"},