working on defaults, updating error messages

This commit is contained in:
Zach Daniel 2019-12-08 01:21:09 -05:00
parent 941359bb8f
commit a2e007697a
No known key found for this signature in database
GPG key ID: A57053A671EE649E
19 changed files with 126 additions and 61 deletions

View file

@ -62,3 +62,4 @@ Ash is an open source project, and draws inspiration from similar ideas in other
* Validate that all relationships on all resources in the API have destinations *in* that API, or don't and add in logic to pretend those don't exist through the API.
* Make authorization spit out informative errors (at least for developers)
* Use telemetry and/or some kind of hook system to add metrics
* Forbid impossible auth/creation situations (e.g "the id field is not exposed on a create action, and doesn't have a default, therefore writes will always fail.)

View file

@ -65,9 +65,23 @@ defmodule Ash.Actions.Create do
|> Ash.attributes()
|> Enum.map(& &1.name)
# Map.put_new(attributes, :id, Ecto.UUID.generate())
attributes_with_defaults =
resource
|> Ash.attributes()
|> Stream.filter(&(not is_nil(&1.default)))
|> Enum.reduce(attributes, fn attr, attributes ->
if Map.has_key?(attributes, attr.name) do
attributes
else
Map.put(attributes, attr.name, default(attr))
end
end)
resource
|> struct()
|> Ecto.Changeset.cast(Map.put_new(attributes, :id, Ecto.UUID.generate()), allowed_keys)
|> Ecto.Changeset.cast(attributes_with_defaults, allowed_keys)
|> case do
%{valid?: true} = changeset ->
{:ok, changeset}
@ -78,6 +92,10 @@ defmodule Ash.Actions.Create do
end
end
defp default(%{default: {:constant, value}}), do: value
defp default(%{default: {mod, func}}), do: apply(mod, func, [])
defp default(%{default: function}), do: function.()
defp prepare_create_relationships(changeset, _resource, _relationships) do
{:ok, changeset}
# relationships

View file

@ -41,7 +41,7 @@ defmodule Ash.Api do
```
Then you can interact through that Api with the actions that those resources expose.
For example: `MyApp.Api.create(OneResource, %{attributes: %{name: "thing"}}), or
For example: `MyApp.Api.create(OneResource, %{attributes: %{name: "thing"}})`, or
`MyApp.Api.read(OneResource, %{filter: %{name: "thing"}})`. Corresponding actions must
be defined in your resources in order to call them through the Api.
"""
@ -115,7 +115,7 @@ defmodule Ash.Api do
max_concurrency:
"The total number of side loads (per side load nesting level, per ongoing side load) that can be running. Uses `System.schedulers_online/0` if unset.",
timeout:
"the maximum amount of time to wait (in milliseconds) without receiving a task reply. see `task.supervisor.async_stream/6",
"the maximum amount of time to wait (in milliseconds) without receiving a task reply. see `task.supervisor.async_stream/6`",
shutdown:
"an integer indicating the timeout value. Defaults to 5000 milliseconds"
],

View file

@ -29,7 +29,7 @@ defmodule Ash.Resource do
default_page_size:
"The default page size for any read action. If no page size is specified, this value is used.",
primary_key:
"If true, a default `id` uuid primary key is used. If false, none is created. See the primary_key opts for info on specifying primary key options."
"If true, a default `id` uuid primary key that autogenerates is used. If false, none is created. See the primary_key opts for info on specifying primary key options."
],
required: [:name, :type],
defaults: [
@ -124,7 +124,12 @@ defmodule Ash.Resource do
def define_primary_key(mod, opts) do
case opts[:primary_key] do
true ->
{:ok, attribute} = Ash.Resource.Attributes.Attribute.new(:id, :uuid, primary_key?: true)
{:ok, attribute} =
Ash.Resource.Attributes.Attribute.new(:id, :uuid,
primary_key?: true,
default: &Ecto.UUID.generate/0
)
Module.put_attribute(mod, :attributes, attribute)
false ->

View file

@ -28,7 +28,7 @@ defmodule Ash.Resource.Actions do
end
end
@doc "Returns an `allow` rule. See `Ash.Authorization.Rule.new/2 for more."
@doc "Returns an `allow` rule. See `Ash.Authorization.Rule.new/2` for more."
defmacro allow(check, opts \\ []) do
quote bind_quoted: [check: check, opts: opts] do
case Ash.Authorization.Rule.allow(check, opts) do
@ -44,7 +44,7 @@ defmodule Ash.Resource.Actions do
end
end
@doc "Returns an `allow_unless` rule. See `Ash.Authorization.Rule.new/2 for more."
@doc "Returns an `allow_unless` rule. See `Ash.Authorization.Rule.new/2` for more."
defmacro allow_unless(check, opts \\ []) do
quote bind_quoted: [check: check, opts: opts] do
case Ash.Authorization.Rule.allow_unless(check, opts) do
@ -60,7 +60,7 @@ defmodule Ash.Resource.Actions do
end
end
@doc "Returns an `allow_only` rule. See `Ash.Authorization.Rule.new/2 for more."
@doc "Returns an `allow_only` rule. See `Ash.Authorization.Rule.new/2` for more."
defmacro allow_only(check, opts \\ []) do
quote bind_quoted: [check: check, opts: opts] do
case Ash.Authorization.Rule.allow_only(check, opts) do
@ -76,7 +76,7 @@ defmodule Ash.Resource.Actions do
end
end
@doc "Returns a `deny` rule. See `Ash.Authorization.Rule.new/2 for more."
@doc "Returns a `deny` rule. See `Ash.Authorization.Rule.new/2` for more."
defmacro deny(check, opts \\ []) do
quote bind_quoted: [check: check, opts: opts] do
case Ash.Authorization.Rule.deny(check, opts) do
@ -92,7 +92,7 @@ defmodule Ash.Resource.Actions do
end
end
@doc "Returns a `deny_unless` rule. See `Ash.Authorization.Rule.new/2 for more."
@doc "Returns a `deny_unless` rule. See `Ash.Authorization.Rule.new/2` for more."
defmacro deny_unless(check, opts \\ []) do
quote bind_quoted: [check: check, opts: opts] do
case Ash.Authorization.Rule.deny_unless(check, opts) do
@ -108,7 +108,7 @@ defmodule Ash.Resource.Actions do
end
end
@doc "Returns a `deny_only` rule. See `Ash.Authorization.Rule.new/2 for more."
@doc "Returns a `deny_only` rule. See `Ash.Authorization.Rule.new/2` for more."
defmacro deny_only(check, opts \\ []) do
quote bind_quoted: [check: check, opts: opts] do
case Ash.Authorization.Rule.deny_only(check, opts) do

View file

@ -1,19 +1,32 @@
defmodule Ash.Resource.Attributes.Attribute do
@doc false
defstruct [:name, :type, :primary_key?]
defstruct [:name, :type, :primary_key?, :default]
@type t :: %__MODULE__{
name: atom(),
type: Ash.type(),
primary_key?: boolean()
primary_key?: boolean(),
default: (() -> term)
}
@schema Ashton.schema(
opts: [primary_key?: :boolean],
defaults: [primary_key?: false],
opts: [
primary_key?: :boolean,
default: [
{:function, 0},
{:tuple, {:module, :atom}},
{:tuple, {{:const, :constant}, :any}}
]
],
defaults: [
primary_key?: false
],
describe: [
primary_key?: "Whether this field is, or is part of, the primary key of a resource."
primary_key?:
"Whether this field is, or is part of, the primary key of a resource.",
default:
"A one argument function that returns a default value, an mfa that does the same, or a raw value via specifying `{:constant, value}`."
]
)
@ -22,17 +35,37 @@ defmodule Ash.Resource.Attributes.Attribute do
@spec new(atom, Ash.Type.t(), Keyword.t()) :: {:ok, t()} | {:error, term}
def new(name, type, opts) do
case Ashton.validate(opts, @schema) do
{:ok, opts} ->
{:ok,
%__MODULE__{
name: name,
type: type,
primary_key?: opts[:primary_key?] || false
}}
with {:ok, opts} <- Ashton.validate(opts, @schema),
{:default, {:ok, default}} <- {:default, cast_default(type, opts)} do
{:ok,
%__MODULE__{
name: name,
type: type,
primary_key?: opts[:primary_key?],
default: default
}}
else
{:error, error} -> {:error, error}
{:default, _} -> {:error, [{:default, "is not a valid default for type #{inspect(type)}"}]}
end
end
{:error, error} ->
{:error, error}
defp cast_default(type, opts) do
case Keyword.fetch(opts, :default) do
{:ok, default} when is_function(default, 0) ->
{:ok, default}
{:ok, {mod, func}} when is_atom(mod) and is_atom(func) ->
{:ok, {mod, func}}
{:ok, {:constant, default}} ->
case Ash.Type.cast_input(type, default) do
{:ok, value} -> {:ok, {:constant, value}}
:error -> :error
end
:error ->
{:ok, nil}
end
end
end

View file

@ -49,7 +49,7 @@ defmodule Ash.MixProject do
{:ecto, "~> 3.0"},
{:ets, "~> 0.8.0"},
{:ex_doc, "~> 0.21", only: :dev, runtime: false},
{:ashton, "~> 0.3.10"}
{:ashton, "~> 0.4.0"}
]
end
end

View file

@ -1,5 +1,5 @@
%{
"ashton": {:hex, :ashton, "0.3.10", "ce0ab19f154c7fe8fefbc1486fdf7b601a0fa944555284182755197b1c073464", [:mix], [], "hexpm"},
"ashton": {:hex, :ashton, "0.4.0", "5d6fd833da9102d72a8b911498b55605282132450598af7e13b681a794067a4c", [:mix], [], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"dataloader": {:hex, :dataloader, "1.0.6", "fb724d6d3fb6acb87d27e3b32dea3a307936ad2d245faf9cf5221d1323d6a4ba", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
"db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},

View file

@ -15,6 +15,11 @@ defmodule Ash.Test.Actions.CreateTest do
end
end
defmodule PostDefaults do
def garbage2(), do: "garbage2"
def garbage3(), do: "garbage3"
end
defmodule Post do
use Ash.Resource, name: "posts", type: "post"
use Ash.DataLayer.Ets, private?: true
@ -27,6 +32,9 @@ defmodule Ash.Test.Actions.CreateTest do
attributes do
attribute :title, :string
attribute :contents, :string
attribute :tag, :string, default: {:constant, "garbage"}
attribute :tag2, :string, default: &PostDefaults.garbage2/0
attribute :tag3, :string, default: {PostDefaults, :garbage3}
end
relationships do

View file

@ -49,7 +49,7 @@ defmodule Ash.Test.Resource.ApiTest do
test "it fails if `interface?` is not a boolean" do
assert_raise(
Ash.Error.ApiDslError,
"`use Ash.Test.Resource.ApiTest.Api, ...` interface? must be of type :boolean",
"`use Ash.Test.Resource.ApiTest.Api, ...` interface? must be boolean",
fn ->
defapi(interface?: 10) do
end
@ -60,7 +60,7 @@ defmodule Ash.Test.Resource.ApiTest do
test "it fails if `max_page_size` is not an integer" do
assert_raise(
Ash.Error.ApiDslError,
"`use Ash.Test.Resource.ApiTest.Api, ...` max_page_size must be of type :integer",
"`use Ash.Test.Resource.ApiTest.Api, ...` max_page_size must be integer",
fn ->
defapi(max_page_size: "ten") do
end
@ -82,7 +82,7 @@ defmodule Ash.Test.Resource.ApiTest do
test "it fails if `default_page_size` is not an integer" do
assert_raise(
Ash.Error.ApiDslError,
"`use Ash.Test.Resource.ApiTest.Api, ...` default_page_size must be of type :integer",
"`use Ash.Test.Resource.ApiTest.Api, ...` default_page_size must be integer",
fn ->
defapi(default_page_size: "ten") do
end
@ -104,7 +104,7 @@ defmodule Ash.Test.Resource.ApiTest do
test "it fails if the parallel_side_load supervisor is not an atom" do
assert_raise(
Ash.Error.ApiDslError,
"option supervisor at parallel_side_load must be of type :atom",
"option supervisor at parallel_side_load must be atom",
fn ->
defapi do
parallel_side_load(supervisor: "foo")
@ -116,7 +116,7 @@ defmodule Ash.Test.Resource.ApiTest do
test "it fails if the max_concurrency is not an integer" do
assert_raise(
Ash.Error.ApiDslError,
"option max_concurrency at parallel_side_load must be of type :integer",
"option max_concurrency at parallel_side_load must be integer",
fn ->
defapi do
parallel_side_load(supervisor: :foo, max_concurrency: "foo")
@ -140,7 +140,7 @@ defmodule Ash.Test.Resource.ApiTest do
test "it fails if the timeout is not an integer" do
assert_raise(
Ash.Error.ApiDslError,
"option timeout at parallel_side_load must be of type :integer",
"option timeout at parallel_side_load must be integer",
fn ->
defapi do
parallel_side_load(supervisor: :foo, timeout: "foo")
@ -164,7 +164,7 @@ defmodule Ash.Test.Resource.ApiTest do
test "it fails if the shutdown is not an integer" do
assert_raise(
Ash.Error.ApiDslError,
"option shutdown at parallel_side_load must be of type :integer",
"option shutdown at parallel_side_load must be integer",
fn ->
defapi do
parallel_side_load(supervisor: :foo, shutdown: "foo")

View file

@ -48,7 +48,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.CreateTest do
test "it fails if `primary?` is not a boolean" do
assert_raise(
Ash.Error.ResourceDslError,
"option primary? at actions -> create -> default must be of type :boolean",
"option primary? at actions -> create -> default must be boolean",
fn ->
defposts do
actions do
@ -62,7 +62,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.CreateTest do
test "it fails if `rules` is not a list" do
assert_raise(
Ash.Error.ResourceDslError,
"option rules at actions -> create -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}",
"option rules at actions -> create -> default must be [{:struct, Ash.Authorization.Rule}]",
fn ->
defposts do
actions do
@ -76,7 +76,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.CreateTest do
test "it fails if the elements of the rules list are not rules" do
assert_raise(
Ash.Error.ResourceDslError,
"option rules at actions -> create -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}",
"option rules at actions -> create -> default must be [{:struct, Ash.Authorization.Rule}]",
fn ->
defposts do
actions do

View file

@ -48,7 +48,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.DestroyTest do
test "it fails if `primary?` is not a boolean" do
assert_raise(
Ash.Error.ResourceDslError,
"option primary? at actions -> destroy -> default must be of type :boolean",
"option primary? at actions -> destroy -> default must be boolean",
fn ->
defposts do
actions do
@ -62,7 +62,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.DestroyTest do
test "it fails if `rules` is not a list" do
assert_raise(
Ash.Error.ResourceDslError,
"option rules at actions -> destroy -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}",
"option rules at actions -> destroy -> default must be [{:struct, Ash.Authorization.Rule}]",
fn ->
defposts do
actions do
@ -76,7 +76,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.DestroyTest do
test "it fails if the elements of the rules list are not rules" do
assert_raise(
Ash.Error.ResourceDslError,
"option rules at actions -> destroy -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}",
"option rules at actions -> destroy -> default must be [{:struct, Ash.Authorization.Rule}]",
fn ->
defposts do
actions do

View file

@ -48,7 +48,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.ReadTest do
test "it fails if `primary?` is not a boolean" do
assert_raise(
Ash.Error.ResourceDslError,
"option primary? at actions -> read -> default must be of type :boolean",
"option primary? at actions -> read -> default must be boolean",
fn ->
defposts do
actions do
@ -62,7 +62,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.ReadTest do
test "it fails if `rules` is not a list" do
assert_raise(
Ash.Error.ResourceDslError,
"option rules at actions -> read -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}",
"option rules at actions -> read -> default must be [{:struct, Ash.Authorization.Rule}]",
fn ->
defposts do
actions do
@ -76,7 +76,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.ReadTest do
test "it fails if the elements of the rules list are not rules" do
assert_raise(
Ash.Error.ResourceDslError,
"option rules at actions -> read -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}",
"option rules at actions -> read -> default must be [{:struct, Ash.Authorization.Rule}]",
fn ->
defposts do
actions do

View file

@ -48,7 +48,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.UpdateTest do
test "it fails if `primary?` is not a boolean" do
assert_raise(
Ash.Error.ResourceDslError,
"option primary? at actions -> update -> default must be of type :boolean",
"option primary? at actions -> update -> default must be boolean",
fn ->
defposts do
actions do
@ -62,7 +62,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.UpdateTest do
test "it fails if `rules` is not a list" do
assert_raise(
Ash.Error.ResourceDslError,
"option rules at actions -> update -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}",
"option rules at actions -> update -> default must be [{:struct, Ash.Authorization.Rule}]",
fn ->
defposts do
actions do
@ -76,7 +76,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.UpdateTest do
test "it fails if the elements of the rules list are not rules" do
assert_raise(
Ash.Error.ResourceDslError,
"option rules at actions -> update -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}",
"option rules at actions -> update -> default must be [{:struct, Ash.Authorization.Rule}]",
fn ->
defposts do
actions do

View file

@ -56,7 +56,7 @@ defmodule Ash.Test.Resource.AttributesTest do
test "raises if you pass an invalid value for `primary_key?`" do
assert_raise(
Ash.Error.ResourceDslError,
"option primary_key? at attributes -> attribute must be of type :boolean",
"option primary_key? at attributes -> attribute must be boolean",
fn ->
defposts do
attributes do

View file

@ -55,7 +55,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do
test "fails if destination_field is not an atom" do
assert_raise(
Ash.Error.ResourceDslError,
"option destination_field at relationships -> belongs_to -> foobar must be of type :atom",
"option destination_field at relationships -> belongs_to -> foobar must be atom",
fn ->
defposts do
relationships do
@ -69,7 +69,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do
test "fails if source_field is not an atom" do
assert_raise(
Ash.Error.ResourceDslError,
"option source_field at relationships -> belongs_to -> foobar must be of type :atom",
"option source_field at relationships -> belongs_to -> foobar must be atom",
fn ->
defposts do
relationships do
@ -111,7 +111,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do
test "fails if `primary_key?` is not a boolean" do
assert_raise(
Ash.Error.ResourceDslError,
"option primary_key? at relationships -> belongs_to -> foobar must be of type :boolean",
"option primary_key? at relationships -> belongs_to -> foobar must be boolean",
fn ->
defposts do
relationships do
@ -126,7 +126,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do
test "fails if `define_field?` is not a boolean" do
assert_raise(
Ash.Error.ResourceDslError,
"option define_field? at relationships -> belongs_to -> foobar must be of type :boolean",
"option define_field? at relationships -> belongs_to -> foobar must be boolean",
fn ->
defposts do
relationships do
@ -140,7 +140,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do
test "fails if `field_type` is not an atom" do
assert_raise(
Ash.Error.ResourceDslError,
"option field_type at relationships -> belongs_to -> foobar must be of type :atom",
"option field_type at relationships -> belongs_to -> foobar must be atom",
fn ->
defposts do
relationships do

View file

@ -36,7 +36,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasManyTest do
test "fails if destination_field is not an atom" do
assert_raise(
Ash.Error.ResourceDslError,
"option destination_field at relationships -> has_many -> foobar must be of type :atom",
"option destination_field at relationships -> has_many -> foobar must be atom",
fn ->
defposts do
relationships do
@ -50,7 +50,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasManyTest do
test "fails if source_field is not an atom" do
assert_raise(
Ash.Error.ResourceDslError,
"option source_field at relationships -> has_many -> foobar must be of type :atom",
"option source_field at relationships -> has_many -> foobar must be atom",
fn ->
defposts do
relationships do

View file

@ -36,7 +36,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasOneTest do
test "fails if destination_field is not an atom" do
assert_raise(
Ash.Error.ResourceDslError,
"option destination_field at relationships -> has_one -> foobar must be of type :atom",
"option destination_field at relationships -> has_one -> foobar must be atom",
fn ->
defposts do
relationships do
@ -50,7 +50,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasOneTest do
test "fails if source_field is not an atom" do
assert_raise(
Ash.Error.ResourceDslError,
"option source_field at relationships -> has_one -> foobar must be of type :atom",
"option source_field at relationships -> has_one -> foobar must be atom",
fn ->
defposts do
relationships do

View file

@ -55,7 +55,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
test "it fails if you dont pass an atom for `source_field_on_join_table`" do
assert_raise(
Ash.Error.ResourceDslError,
"option source_field_on_join_table at relationships -> many_to_many -> foobars must be of type :atom",
"option source_field_on_join_table at relationships -> many_to_many -> foobars must be atom",
fn ->
defposts do
relationships do
@ -69,7 +69,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
test "it fails if you dont pass an atom for `destination_field_on_join_table`" do
assert_raise(
Ash.Error.ResourceDslError,
"option destination_field_on_join_table at relationships -> many_to_many -> foobars must be of type :atom",
"option destination_field_on_join_table at relationships -> many_to_many -> foobars must be atom",
fn ->
defposts do
relationships do
@ -85,7 +85,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
test "it fails if you dont pass an atom for `source_field`" do
assert_raise(
Ash.Error.ResourceDslError,
"option source_field at relationships -> many_to_many -> foobars must be of type :atom",
"option source_field at relationships -> many_to_many -> foobars must be atom",
fn ->
defposts do
relationships do
@ -101,7 +101,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
test "it fails if you dont pass an atom for `destination_field`" do
assert_raise(
Ash.Error.ResourceDslError,
"option destination_field at relationships -> many_to_many -> foobars must be of type :atom",
"option destination_field at relationships -> many_to_many -> foobars must be atom",
fn ->
defposts do
relationships do