diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..46b035b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Unreleased changes will be displayed here upon implementation. + +## [0.2.0] - 2025-08-13 + +### Added + +- [JSV](https://hexdocs.pm/jsv/JSV.html)-compatible JsonSchema definitions for `format` and `defaults` as + `ValueFormatter.Schema.Format` and `ValueFormatter.Schema.DefaultFormats`, respectively. + +[unreleased]: https://github.com/box-id/value_formatters/compare/0.2.0...HEAD +[0.2.0]: https://github.com/box-id/value_formatters/releases/tag/0.2.0 \ No newline at end of file diff --git a/lib/schemas.ex b/lib/schemas.ex new file mode 100644 index 0000000..b1e5183 --- /dev/null +++ b/lib/schemas.ex @@ -0,0 +1,281 @@ +defmodule ValueFormatters.Schemas do + def number_options do + %{ + precision: %{ + type: :number, + description: "Number of decimal places" + }, + unit: %{ + type: :string, + description: "If set, the formatter appends ' ' + unit to the display value" + } + } + end + + def date_options do + %{ + date_display: %{ + type: :string, + description: """ + How the formatter should display the date portion: + + - `full`: Wednesday, November 29, 2023 + + - `long`: November 29, 2023 + + - `medium`: Nov 29, 2023 + + - `short`: 11/29/23 + + - `none`: Don't display date + """, + enum: ["full", "long", "medium", "short", "none"], + default: "medium" + }, + time_display: %{ + type: :string, + description: """ + How the formatter should display the time portion: + + - `full`: 3:44:28 PM GMT + + - `long`: 3:44:28 PM UTC + + - `medium`: 3:44:28 PM + + - `short`: 3:44 PM + + - `none`: Don't display time + """, + enum: ["full", "long", "medium", "short", "none"], + default: "medium" + } + } + end + + def date_unix_options do + %{ + milliseconds: %{ + type: :boolean, + default: false, + description: + "Whether the formatter should output the values milliseconds (instead of seconds)." + } + } + end + + def coordinates_options do + %{ + radius_display: %{ + type: :boolean, + default: true, + description: + "Whether the formatter should include the radius/accuracy information (if present)." + } + } + end +end + +defmodule ValueFormatters.Schemas.Format do + import ValueFormatters.Schemas + + def json_schema() do + %{ + description: "Formats for value formatting", + oneOf: [ + %{ + const: nil, + description: "Skip formatting and return raw value." + }, + %{ + type: :string, + title: "shorthand", + description: "A shorthand representation of the format", + enum: [ + "number", + "string", + "date", + "date_relative", + "date_iso", + "date_unix", + "coordinates" + ] + }, + %{ + type: :object, + title: "string", + properties: %{ + format: %{ + const: "string", + description: + "Use to explicitly disable any kind of formatting that would otherwise take place, but still return a string." + } + }, + required: [:format], + additionalProperties: false + }, + %{ + type: :object, + title: "number", + properties: + Map.merge( + %{ + format: %{ + const: "number", + description: + "Use to display numeric values and format them according to the user's locale." + } + }, + number_options() + ), + required: [:format], + additionalProperties: false + }, + %{ + type: :object, + title: "date", + properties: + Map.merge( + %{ + format: %{ + const: "date", + description: + "Use to display date-time values and format them according to the user's locale." + } + }, + date_options() + ), + required: [:format], + additionalProperties: false + }, + %{ + type: :object, + title: "date_relative", + properties: %{ + format: %{ + const: "date_relative", + description: """ + Use format: "date_relative" to display a relative date string (e.g. “2 days ago”) by comparing the given value against the current date & time. Only the largest sensible unit is displayed, e.g. the formatter will only display “days” even when other components such as hours, minutes etc. aren't equal to zero. + + The implementation can choose to update the displayed value in appropriate intervals. Also, it can choose to display the absolute date on user interaction, e.g. in a tooltip. + + This format currently doesn't support any options. + """ + } + }, + required: [:format], + additionalProperties: false + }, + %{ + type: :object, + title: "date_iso", + properties: %{ + format: %{ + const: "date_iso", + description: "Use to display date-time values in ISO 8601 extended format." + } + }, + required: [:format], + additionalProperties: false + }, + %{ + type: :object, + title: "date_unix", + properties: + Map.merge( + %{ + format: %{ + const: "date_unix", + description: "Use to display date-time values in seconds since unix epoch." + } + }, + date_unix_options() + ), + required: [:format], + additionalProperties: false + }, + %{ + type: :object, + title: "coordinates", + properties: + Map.merge( + %{ + format: %{ + const: "coordinates", + description: "Use to display latitude & longitude information." + } + }, + coordinates_options() + ), + required: [:format], + additionalProperties: false + } + ] + } + end +end + +defmodule ValueFormatters.Schemas.DefaultFormats do + import ValueFormatters.Schemas + + def json_schema() do + %{ + type: :object, + description: "Default formats for value formatting", + properties: %{ + number: %{ + type: [:object, :null], + description: + "Use to display numeric values and format them according to the user's locale.", + properties: number_options(), + additionalProperties: false + }, + string: %{ + type: [:object, :null], + description: + "Use to explicitly disable any kind of formatting that would otherwise take place.", + properties: %{}, + additionalProperties: false + }, + date: %{ + type: [:object, :null], + description: + "Use to to display date-time values and format them according to the user's locale.", + properties: date_options(), + additionalProperties: false + }, + date_relative: %{ + type: [:object, :null], + description: """ + Use format: "date_relative" to display a relative date string (e.g. “2 days ago”) by comparing the given value against the current date & time. Only the largest sensible unit is displayed, e.g. the formatter will only display “days” even when other components such as hours, minutes etc. aren't equal to zero. + + The implementation can choose to update the displayed value in appropriate intervals. Also, it can choose to display the absolute date on user interaction, e.g. in a tooltip. + + This format currently doesn't support any options. + """, + properties: %{}, + additionalProperties: false + }, + date_iso: %{ + type: [:object, :null], + description: "Use to display date-time values in ISO 8601 extended format.", + properties: %{}, + additionalProperties: false + }, + date_unix: %{ + type: [:object, :null], + description: "Use to display date-time values in seconds since unix epoch.", + properties: date_unix_options(), + additionalProperties: false + }, + coordinates: %{ + type: [:object, :null], + description: "Use to display latitude & longitude information.", + properties: coordinates_options(), + additionalProperties: false + } + }, + additionalProperties: false + } + end +end diff --git a/lib/value_formatters.ex b/lib/value_formatters.ex index 35e4903..e9c0e96 100644 --- a/lib/value_formatters.ex +++ b/lib/value_formatters.ex @@ -195,7 +195,7 @@ defmodule ValueFormatters do value <> separator <> render_function.(unit) end - defp format_string(value, _string_definition), do: {:ok, value} + defp format_string(value, _string_definition), do: {:ok, to_string(value)} defp format_date(value, date_definition, opts) do with {:ok, value} <- pre_process_date_value(value, date_definition, opts) do diff --git a/mix.exs b/mix.exs index 8828d82..e12d6ef 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule ValueFormatters.MixProject do def project do [ app: :value_formatters, - version: "0.1.3", + version: "0.2.0", elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -32,7 +32,8 @@ defmodule ValueFormatters.MixProject do {:mox, "~> 1.0", only: [:dev, :test]}, {:timex, "~> 3.7", only: :test}, {:ok, "~> 2.3.0"}, - {:mix_test_watch, "~> 1.3", only: [:dev, :test]} + {:mix_test_watch, "~> 1.3", only: [:dev, :test]}, + {:jsv, "~> 0.10", optional: true} ] end diff --git a/mix.lock b/mix.lock index fb176b9..3b19b74 100644 --- a/mix.lock +++ b/mix.lock @@ -19,6 +19,7 @@ "hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jsv": {:hex, :jsv, "0.10.1", "a55790331196b92a17034f56cef48438dc407e6dca8cc570eff779f83f8680ea", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:poison, "~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "2b51d3e90d7cdc839516c0c3ba0da21814f4666dbb94bcdf103def8766e78d74"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, @@ -26,6 +27,7 @@ "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "ok": {:hex, :ok, "2.3.0", "0a3d513ec9038504dc5359d44e14fc14ef59179e625563a1a144199cdc3a6d30", [:mix], [], "hexpm", "f0347b3f8f115bf347c704184b33cf084f2943771273f2b98a3707a5fa43c4d5"}, diff --git a/test/schemas_test.exs b/test/schemas_test.exs new file mode 100644 index 0000000..fc9fccd --- /dev/null +++ b/test/schemas_test.exs @@ -0,0 +1,160 @@ +defmodule ValueFormatters.SchemasTest do + use ExUnit.Case, async: true + + alias ValueFormatters.Schemas.{ + Format, + DefaultFormats + } + + describe "Format Schema" do + test "accepts shorthands" do + assert {:ok, _} = validate_format("date") + assert {:ok, _} = validate_format("number") + assert {:ok, _} = validate_format("string") + assert {:ok, _} = validate_format("date_relative") + assert {:ok, _} = validate_format("date_unix") + assert {:ok, _} = validate_format("date_iso") + assert {:ok, _} = validate_format("coordinates") + end + + test "accepts null shorthand" do + assert {:ok, _} = validate_format(nil) + end + + test "accepts extended format" do + assert {:ok, _} = validate_format(%{"format" => "date"}) + end + + test "accepts date options" do + assert {:ok, _} = + validate_format(%{ + "format" => "date", + "time_display" => "short", + "date_display" => "long" + }) + end + + test "accepts number options" do + assert {:ok, _} = + validate_format(%{ + "format" => "number", + "precision" => 3, + "unit" => "°C" + }) + end + + test "accepts date_unix options" do + assert {:ok, _} = + validate_format(%{ + "format" => "date_unix", + "milliseconds" => true + }) + end + + test "accepts coordinates options" do + assert {:ok, _} = + validate_format(%{ + "format" => "coordinates", + "radius_display" => true + }) + end + + test "doesn't accept invalid format type" do + assert {:error, _} = + validate_default_formats(%{ + "format" => "foo" + }) + end + + test "doesn't accept invalid date options" do + assert {:error, _} = + validate_format(%{ + "format" => "date", + "time_display" => "short", + "date_display" => "long", + "foo" => "bar" + }) + end + end + + describe "DefaultFormats Schema" do + test "accepts empty object" do + assert {:ok, _} = validate_default_formats(%{}) + end + + test "accepts null value" do + assert {:ok, _} = validate_default_formats(%{"number" => nil}) + end + + test "accepts number defaults" do + assert {:ok, _} = + validate_default_formats(%{ + "number" => %{ + "precision" => 2, + "unit" => "kg" + } + }) + end + + test "accepts date_unix defaults" do + assert {:ok, _} = + validate_default_formats(%{ + "date_unix" => %{ + "milliseconds" => true + } + }) + end + + test "accepts date defaults" do + assert {:ok, _} = + validate_default_formats(%{ + "date" => %{ + "date_display" => "long", + "time_display" => "short" + } + }) + end + + test "accepts coordinate defaults" do + assert {:ok, _} = + validate_default_formats(%{ + "coordinates" => %{ + "radius_display" => false + } + }) + end + + test "doesn't accept invalid date options" do + assert {:error, _} = + validate_default_formats(%{ + "date" => %{ + "date_display" => "long", + "time_display" => "short", + "foo" => "bar" + } + }) + end + + test "doesn't accept invalid format type" do + assert {:error, _} = + validate_default_formats(%{ + "foo" => %{ + "date_display" => "long", + "time_display" => "short" + } + }) + end + end + + defp validate_format(format) do + format_schema = JSV.build!(Format) + + JSV.validate(format, format_schema) + end + + defp validate_default_formats(default_formats) do + default_formats_schema = JSV.build!(DefaultFormats) + + JSV.validate(default_formats, default_formats_schema) + end +end