From 36289b48834a1e93e4423dd1a5f2183f29474546 Mon Sep 17 00:00:00 2001 From: Mat Trudel Date: Wed, 20 Dec 2023 10:39:29 -0500 Subject: [PATCH] Add JSON matcher (#20) --- lib/machete.ex | 4 +- lib/machete/matchers/json_matcher.ex | 61 +++++++++++++++++++++ mix.exs | 2 + test/machete/matchers/json_matcher_test.exs | 22 ++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 lib/machete/matchers/json_matcher.ex create mode 100644 test/machete/matchers/json_matcher_test.exs diff --git a/lib/machete.ex b/lib/machete.ex index beb470f..501d2d6 100644 --- a/lib/machete.ex +++ b/lib/machete.ex @@ -29,7 +29,7 @@ defmodule Machete do At its heart, Machete provides the following two things: - * A new `~>` operator (the 'squiggle arrow') that does flexible matching of + * A new `~>` operator (the 'squiggle arrow') that does flexible matching of its left operator with its right operator * A set of parametric matchers such as `string()` or `integer()` which can match against general types @@ -84,6 +84,7 @@ defmodule Machete do * [`float()`](`Machete.FloatMatcher.float/1`) matches float values * [`integer()`](`Machete.IntegerMatcher.integer/1`) matches integer values * [`iso8601_datetime()`](`Machete.ISO8601DateTimeMatcher.iso8601_datetime/1`) matches ISO8601 formatted strings + * [`json()`](`Machete.JSONMatcher.json/1`) matches JSON formatted structures * [`is_a()`](`Machete.IsAMatcher.is_a/1`) matches against a struct type * [`naive_datetime()`](`Machete.NaiveDateTimeMatcher.naive_datetime/1`) matches `NaiveDateTime` instances * [`pid()`](`Machete.PIDMatcher.pid/1`) matches process IDs @@ -174,6 +175,7 @@ defmodule Machete do import Machete.IntegerMatcher import Machete.IsAMatcher import Machete.ISO8601DateTimeMatcher + import Machete.JSONMatcher import Machete.ListMatcher import Machete.MapMatcher import Machete.MaybeMatcher diff --git a/lib/machete/matchers/json_matcher.ex b/lib/machete/matchers/json_matcher.ex new file mode 100644 index 0000000..f2cded3 --- /dev/null +++ b/lib/machete/matchers/json_matcher.ex @@ -0,0 +1,61 @@ +defmodule Machete.JSONMatcher do + @moduledoc """ + Defines a matcher that matches JSON documents + """ + + import Machete.Mismatch + import Machete.Operators + + defstruct matcher: nil + + @typedoc """ + Describes an instance of this matcher + """ + @opaque t :: %__MODULE__{} + + @doc """ + Matches against JSON documents whose deserialization matches a provided matcher + + Takes a matcher as its sole (mandatory) argument + + Examples: + + iex> assert "{}" ~> json(map()) + true + + iex> assert ~s({"a": 1}) ~> json(%{"a" => 1}) + true + + iex> assert "[]" ~> json(list()) + true + + iex> assert "[1,2,3]" ~> json([1,2,3]) + true + + iex> assert ~s("abc") ~> json(string()) + true + + iex> assert "123" ~> json(integer()) + true + + iex> assert "true" ~> json(boolean()) + true + + iex> assert "null" ~> json(nil) + true + """ + @spec json(term()) :: t() + def json(matcher), do: struct!(__MODULE__, matcher: matcher) + + defimpl Machete.Matchable do + def mismatches(%@for{} = a, b) when is_binary(b) do + Jason.decode(b) + |> case do + {:ok, document} -> document ~>> a.matcher + _ -> mismatch("#{inspect(b)} is not parseable JSON") + end + end + + def mismatches(%@for{}, b), do: mismatch("#{inspect(b)} is not a string") + end +end diff --git a/mix.exs b/mix.exs index 225b31e..5a5c3ef 100644 --- a/mix.exs +++ b/mix.exs @@ -27,6 +27,7 @@ defmodule Machete.MixProject do defp deps do [ + {:jason, "~> 1.4"}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false} @@ -58,6 +59,7 @@ defmodule Machete.MixProject do Machete.IntegerMatcher, Machete.IsAMatcher, Machete.ISO8601DateTimeMatcher, + Machete.JSONMatcher, Machete.ListMatcher, Machete.MapMatcher, Machete.MaybeMatcher, diff --git a/test/machete/matchers/json_matcher_test.exs b/test/machete/matchers/json_matcher_test.exs new file mode 100644 index 0000000..d549b88 --- /dev/null +++ b/test/machete/matchers/json_matcher_test.exs @@ -0,0 +1,22 @@ +defmodule JSONMatcherTest do + use ExUnit.Case, async: true + use Machete + + import Machete.Mismatch + + doctest Machete.JSONMatcher + + test "produces a useful mismatch for non strings" do + assert 1 ~>> json(term()) ~> mismatch("1 is not a string") + end + + test "produces a useful mismatch for non-parseable strings" do + assert "%" ~>> json(term()) ~> mismatch("\"%\" is not parseable JSON") + end + + test "produces a useful mismatch for content mismatches" do + assert "[1]" + ~>> json([]) + ~> mismatch("List is 1 elements in length, expected 0") + end +end