Skip to content

Commit 4874656

Browse files
committed
initial commit
0 parents  commit 4874656

19 files changed

+442
-0
lines changed

.formatter.exs

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Used by "mix format"
2+
[
3+
import_deps: [:ash],
4+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
5+
]

.gitignore

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# Ignore .fetch files in case you like to edit your project deps locally.
14+
/.fetch
15+
16+
# If the VM crashes, it generates a dump, let's ignore it too.
17+
erl_crash.dump
18+
19+
# Also ignore archive artifacts (built via "mix archive.build").
20+
*.ez
21+
22+
# Ignore package tarball (built via "mix hex.build").
23+
ash_sanity-*.tar
24+
25+
# Temporary files, for example, from tests.
26+
/tmp/

README.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# AshSanity
2+
3+
**TODO: Add description**
4+
5+
## Installation
6+
7+
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
8+
by adding `ash_sanity` to your list of dependencies in `mix.exs`:
9+
10+
```elixir
11+
def deps do
12+
[
13+
{:ash_sanity, "~> 0.1.0"}
14+
]
15+
end
16+
```
17+
18+
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
19+
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
20+
be found at <https://hexdocs.pm/ash_sanity>.
21+

config/config.exs

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Config
2+
3+
if Config.config_env() == :test do
4+
config :ash_sanity, AshSanity.TestCMS,
5+
project_id: "abc",
6+
dataset: "myset",
7+
token: "supersecret",
8+
cdn: false,
9+
finch_mod: AshSanity.MockFinch
10+
end

lib/ash_sanity.ex

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
defmodule AshSanity do
2+
@moduledoc """
3+
Documentation for `AshSanity`.
4+
"""
5+
end

lib/ash_sanity/cms.ex

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
defmodule AshSanity.CMS do
2+
@moduledoc """
3+
Defines a CMS.
4+
5+
A CMS maps to an underlying Sanity instance.
6+
7+
When used, the CMS expects the `:otp_app`
8+
option. The `:otp_app` should point to an OTP application that has
9+
the CMS configuration. For example, the CMS:
10+
11+
defmodule CMS do
12+
use AshSanity.CMS,
13+
otp_app: :my_app
14+
end
15+
16+
Could be configured with:
17+
18+
config :my_app, CMS,
19+
project_id: "my_project_id",
20+
dataset: "production",
21+
token: "my_sanity_api_token",
22+
cdn: true
23+
24+
"""
25+
26+
@type t :: module
27+
28+
@doc false
29+
defmacro __using__(opts) do
30+
quote bind_quoted: [opts: opts] do
31+
alias AshSanity.Query
32+
33+
otp_app = opts[:otp_app] || raise("Must configure OTP app")
34+
@otp_app otp_app
35+
36+
def all(query) do
37+
query_string = Query.build(query)
38+
39+
{:ok, response} = request(query_string)
40+
41+
response.body["result"]
42+
end
43+
44+
defp request(query_string) do
45+
config = Application.get_env(@otp_app, __MODULE__, [])
46+
47+
Sanity.query(query_string)
48+
|> Sanity.request(
49+
project_id: Keyword.get(config, :project_id),
50+
dataset: Keyword.get(config, :dataset),
51+
token: Keyword.get(config, :token),
52+
cdn: Keyword.get(config, :cdn),
53+
finch_mod: Keyword.get(config, :finch_mod, Finch)
54+
)
55+
end
56+
end
57+
end
58+
end

lib/ash_sanity/data_layer.ex

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
defmodule AshSanity.DataLayer do
2+
@behaviour Ash.DataLayer
3+
4+
@sanity %Spark.Dsl.Section{
5+
name: :sanity,
6+
describe: """
7+
Sanity data layer configuration
8+
""",
9+
modules: [
10+
:cms
11+
],
12+
examples: [
13+
"""
14+
sanity do
15+
cms MyApp.CMS
16+
type "task"
17+
end
18+
"""
19+
],
20+
schema: [
21+
cms: [
22+
type: :atom,
23+
required: true,
24+
doc:
25+
"The cms that will be used to fetch your data. See the `AshSanity.CMS` documentation for more"
26+
],
27+
type: [
28+
type: :string,
29+
doc: """
30+
The type of the document in Sanity.
31+
"""
32+
]
33+
]
34+
}
35+
36+
@sections [@sanity]
37+
38+
use Spark.Dsl.Extension, sections: @sections
39+
40+
def can?(_, :filter), do: true
41+
def can?(_, {:filter_expr, _}), do: true
42+
def can?(_, :read), do: true
43+
44+
def can?(_, _), do: false
45+
46+
def offset(query, offset, _), do: {:ok, %{query | offset: offset}}
47+
48+
def resource_to_query(resource, api) do
49+
%AshSanity.Query{
50+
resource: resource,
51+
api: api
52+
}
53+
end
54+
55+
def filter(query, filter, _resource) do
56+
if query.filter do
57+
{:ok, %{query | filter: Ash.Filter.add_to_filter!(query.filter, filter)}}
58+
else
59+
{:ok, %{query | filter: filter}}
60+
end
61+
end
62+
63+
def select(query, select, _resource), do: {:ok, %{query | select: select}}
64+
65+
def sort(query, sort, _resource), do: {:ok, %{query | sort: sort}}
66+
67+
def run_query(%{filter: filter, api: api} = query, resource, parent \\ nil) do
68+
cms = AshSanity.DataLayer.Info.cms(resource)
69+
type = AshSanity.DataLayer.Info.type(resource)
70+
71+
query = %{query | type: type}
72+
73+
with documents <- cms.all(query) do
74+
{:ok, documents} = cast_documents(documents, resource)
75+
Ash.Filter.Runtime.filter_matches(api, documents, filter, parent: parent)
76+
end
77+
end
78+
79+
defp cast_documents(documents, resource) do
80+
documents
81+
|> Enum.reduce_while({:ok, []}, fn document, {:ok, casted} ->
82+
case cast_document(document, resource) do
83+
{:ok, casted_document} ->
84+
{:cont, {:ok, [casted_document | casted]}}
85+
86+
{:error, error} ->
87+
{:halt, {:error, error}}
88+
end
89+
end)
90+
|> case do
91+
{:ok, documents} ->
92+
{:ok, Enum.reverse(documents)}
93+
94+
{:error, error} ->
95+
{:error, error}
96+
end
97+
end
98+
99+
defp cast_document(document, resource) do
100+
resource
101+
|> Ash.Resource.Info.attributes()
102+
|> Enum.reduce_while({:ok, %{}}, fn attribute, {:ok, attrs} ->
103+
case get_attribute(document, attribute) do
104+
nil ->
105+
{:cont, {:ok, Map.put(attrs, attribute.name, nil)}}
106+
107+
value ->
108+
cast_attribute(attribute, value, attrs)
109+
end
110+
end)
111+
|> case do
112+
{:ok, attrs} ->
113+
{:ok,
114+
%{
115+
struct(resource, attrs)
116+
| __meta__: %Ecto.Schema.Metadata{state: :loaded, schema: resource}
117+
}}
118+
119+
{:error, error} ->
120+
{:error, error}
121+
end
122+
end
123+
124+
defp cast_attribute(attribute, value, attrs) do
125+
case Ash.Type.cast_stored(attribute.type, value, attribute.constraints) do
126+
{:ok, value} ->
127+
{:cont, {:ok, Map.put(attrs, attribute.name, value)}}
128+
129+
:error ->
130+
{:halt, {:error, "Failed to load #{inspect(value)} as type #{inspect(attribute.type)}"}}
131+
132+
{:error, error} ->
133+
{:halt, {:error, error}}
134+
end
135+
end
136+
137+
defp get_attribute(document, attribute) do
138+
Map.get(document, to_string(attribute.name))
139+
end
140+
end

lib/ash_sanity/data_layer/info.ex

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
defmodule AshSanity.DataLayer.Info do
2+
@moduledoc "Introspection functions for AshSanity CMS"
3+
4+
alias Spark.Dsl.Extension
5+
6+
def cms(resource) do
7+
Extension.get_opt(resource, [:sanity], :cms, nil, true)
8+
end
9+
10+
def type(resource) do
11+
Extension.get_opt(resource, [:sanity], :type, nil, true)
12+
end
13+
end

lib/ash_sanity/query.ex

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
defmodule AshSanity.Query do
2+
@moduledoc """
3+
Query helpers for AshSanity
4+
"""
5+
defstruct resource: nil, api: nil, filter: nil, type: nil
6+
7+
def build(query) do
8+
~s(*[_type == "#{query.type}"])
9+
end
10+
end

mix.exs

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule AshSanity.MixProject do
2+
use Mix.Project
3+
4+
def project do
5+
[
6+
app: :ash_sanity,
7+
version: "0.1.0",
8+
elixir: "~> 1.14",
9+
start_permanent: Mix.env() == :prod,
10+
elixirc_paths: elixirc_paths(Mix.env()),
11+
deps: deps()
12+
]
13+
end
14+
15+
if Mix.env() == :test do
16+
def application() do
17+
[
18+
applications: [:ash],
19+
mod: {AshSanity.TestApp, []}
20+
]
21+
end
22+
end
23+
24+
defp elixirc_paths(:test), do: ["lib", "test/support"]
25+
defp elixirc_paths(_), do: ["lib"]
26+
27+
defp deps do
28+
[
29+
{:ash, "~> 2.15"},
30+
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
31+
{:sanity, "~> 1.3"},
32+
{:mox, "~> 1.1", only: :test}
33+
]
34+
end
35+
end

mix.lock

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
%{
2+
"ash": {:hex, :ash, "2.15.12", "03da15f8c597dea4cce550e580feb19f3eb8a93ebf44d6b3b2c797d288d4a593", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 1.1.20 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.6", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9c240f7f80b508cd5d14e7e9d6000fa01800bc397247e3467dde8fc6cae0ac23"},
3+
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
4+
"castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"},
5+
"comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"},
6+
"credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"},
7+
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
8+
"dotenv_parser": {:hex, :dotenv_parser, "2.0.0", "0f999196857e4ee18cbba1413018d5e4980ab16b397e3a2f8d0cf541fe683181", [:mix], [], "hexpm", "e769bde2dbff5b0cd0d9d877a9ccfd2c6dd84772dfb405d5a43cceb4f93616c5"},
9+
"earmark": {:hex, :earmark, "1.4.46", "8c7287bd3137e99d26ae4643e5b7ef2129a260e3dcf41f251750cb4563c8fb81", [:mix], [], "hexpm", "798d86db3d79964e759ddc0c077d5eb254968ed426399fbf5a62de2b5ff8910a"},
10+
"ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 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", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"},
11+
"elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"},
12+
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
13+
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
14+
"finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [: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", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"},
15+
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
16+
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
17+
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
18+
"mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [: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", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"},
19+
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"},
20+
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
21+
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
22+
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
23+
"sanity": {:hex, :sanity, "1.3.0", "53839bdc238950c30c364ea8bbef8c6bca18c8a92e138f35574448aa38bfb529", [:mix], [{:finch, "~> 0.5", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "3e22cba7f9ed0646ae3f5550c44787ea3fb6c6386c96bb5feaa3d9648e141d3e"},
24+
"sourceror": {:hex, :sourceror, "0.14.0", "b6b8552d0240400d66b6f107c1bab7ac1726e998efc797f178b7b517e928e314", [:mix], [], "hexpm", "809c71270ad48092d40bbe251a133e49ae229433ce103f762a2373b7a10a8d8b"},
25+
"spark": {:hex, :spark, "1.1.43", "5817cefa41c6f7105989fa40c044c05bf2cab7b81c8ecbd963bdbdf6eeabc85a", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "29e42b900f3a7666e67fba270ff10d7b9fc693c8c2179b6bd65aa6b8426d30ca"},
26+
"stream_data": {:hex, :stream_data, "0.6.0", "e87a9a79d7ec23d10ff83eb025141ef4915eeb09d4491f79e52f2562b73e5f47", [:mix], [], "hexpm", "b92b5031b650ca480ced047578f1d57ea6dd563f5b57464ad274718c9c29501c"},
27+
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
28+
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},
29+
}

test/filter_test.exs

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule AshSanity.FilterTest do
2+
use AshSanity.CMSCase
3+
4+
alias AshSanity.MockFinch
5+
alias AshSanity.Test.{Api, Post}
6+
7+
require Ash.Query
8+
9+
import Mox
10+
setup :verify_on_exit!
11+
12+
describe "with no filter applied" do
13+
test "retrieves all data" do
14+
expect(MockFinch, :request, fn request, Sanity.Finch, [receive_timeout: 30_000] ->
15+
{:ok, %Finch.Response{body: ~s({"result": []}), headers: [], status: 200}}
16+
end)
17+
18+
assert [] == Api.read!(Post)
19+
end
20+
end
21+
end

test/support/api.ex

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
defmodule AshSanity.Test.Api do
2+
@moduledoc false
3+
use Ash.Api
4+
5+
resources do
6+
resource(AshSanity.Test.Post)
7+
end
8+
end

0 commit comments

Comments
 (0)