GithubHelp home page GithubHelp logo

ethelo / kronky Goto Github PK

View Code? Open in Web Editor NEW
105.0 105.0 19.0 51 KB

Kronky bridges the gap between Ecto and Absinthe GraphQL by listing validation messages in a mutation payload.

License: MIT License

Elixir 100.00%
absinthe ecto elixir graphql validation

kronky's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

kronky's Issues

Getting BadMapError when Ecto.Changeset error is a unique constraint

Here is the stack trace:

Request: POST /api/graphiql
** (exit) an exception was raised:
    ** (BadMapError) expected a map, got: {:constraint, :unique}
        (elixir) lib/map.ex:437: Map.get({:constraint, :unique}, :key, nil)
        (absinthe) lib/absinthe/middleware/map_get.ex:9: Absinthe.Middleware.MapGet.call/2
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:209: Absinthe.Phase.Document.Execution.Resolution.reduce_resolution/1
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:168: Absinthe.Phase.Document.Execution.Resolution.do_resolve_field/4
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:153: Absinthe.Phase.Document.Execution.Resolution.do_resolve_fields/6
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:72: Absinthe.Phase.Document.Execution.Resolution.walk_result/5
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:98: Absinthe.Phase.Document.Execution.Resolution.walk_results/6
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:87: Absinthe.Phase.Document.Execution.Resolution.walk_result/5
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:257: Absinthe.Phase.Document.Execution.Resolution.build_result/4
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:153: Absinthe.Phase.Document.Execution.Resolution.do_resolve_fields/6
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:72: Absinthe.Phase.Document.Execution.Resolution.walk_result/5
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:98: Absinthe.Phase.Document.Execution.Resolution.walk_results/6
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:87: Absinthe.Phase.Document.Execution.Resolution.walk_result/5
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:257: Absinthe.Phase.Document.Execution.Resolution.build_result/4
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:153: Absinthe.Phase.Document.Execution.Resolution.do_resolve_fields/6
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:72: Absinthe.Phase.Document.Execution.Resolution.walk_result/5
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:257: Absinthe.Phase.Document.Execution.Resolution.build_result/4
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:153: Absinthe.Phase.Document.Execution.Resolution.do_resolve_fields/6
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:72: Absinthe.Phase.Document.Execution.Resolution.walk_result/5
        (absinthe) lib/absinthe/phase/document/execution/resolution.ex:53: Absinthe.Phase.Document.Execution.Resolution.perform_resolution/3
** (EXIT from #PID<0.448.0>) shell process exited with reason: an exception was raised:
    ** (ErlangError) Erlang error: {{%BadMapError{term: {:constraint, :unique}}, [{Map, :get, [{:constraint, :unique}, :key, nil], [file: 'lib/map.ex', line: 437]}, {Absinthe.Middleware.MapGet, :call, 2, [file: 'lib/absinthe/middleware/map_get.ex', line: 9]}, {Absinthe.Phase.Document.Execution.Resolution, :reduce_resolution, 1, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 209]}, {Absinthe.Phase.Document.Execution.Resolution, :do_resolve_field, 4, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 168]}, {Absinthe.Phase.Document.Execution.Resolution, :do_resolve_fields, 6, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 153]}, {Absinthe.Phase.Document.Execution.Resolution, :walk_result, 5, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 72]}, {Absinthe.Phase.Document.Execution.Resolution, :walk_results, 6, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 98]}, {Absinthe.Phase.Document.Execution.Resolution, :walk_result, 5, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 87]}, {Absinthe.Phase.Document.Execution.Resolution, :build_result, 4, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 257]}, {Absinthe.Phase.Document.Execution.Resolution, :do_resolve_fields, 6, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 153]}, {Absinthe.Phase.Document.Execution.Resolution, :walk_result, 5, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 72]}, {Absinthe.Phase.Document.Execution.Resolution, :walk_results, 6, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 98]}, {Absinthe.Phase.Document.Execution.Resolution, :walk_result, 5, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 87]}, {Absinthe.Phase.Document.Execution.Resolution, :build_result, 4, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 257]}, {Absinthe.Phase.Document.Execution.Resolution, :do_resolve_fields, 6, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 153]}, {Absinthe.Phase.Document.Execution.Resolution, :walk_result, 5, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 72]}, {Absinthe.Phase.Document.Execution.Resolution, :build_result, 4, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 257]}, {Absinthe.Phase.Document.Execution.Resolution, :do_resolve_fields, 6, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 153]}, {Absinthe.Phase.Document.Execution.Resolution, :walk_result, 5, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 72]}, {Absinthe.Phase.Document.Execution.Resolution, :perform_resolution, 3, [file: 'lib/absinthe/phase/document/execution/resolution.ex', line: 53]}]}, {MyAppWeb.Endpoint, :call, [%Plug.Conn{adapter: {Plug.Cowboy.Conn, :...}, assigns: %{}, before_send: [], body_params: %Plug.Conn.Unfetched{aspect: :body_params}, cookies: %Plug.Conn.Unfetched{aspect: :cookies}, halted: false, host: "localhost", method: "POST", owner: #PID<0.448.0>, params: %Plug.Conn.Unfetched{aspect: :params}, path_info: ["api", "graphiql"], path_params: %{}, port: 4000, private: %{}, query_params: %Plug.Conn.Unfetched{aspect: :query_params}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %Plug.Conn.Unfetched{aspect: :cookies}, req_headers: [{"accept", "application/json"}, {"accept-encoding", "gzip, deflate, br"}, {"accept-language", "en-US,en;q=0.9"}, {"connection", "keep-alive"}, {"content-length", "583"}, {"content-type", "application/json"}, {"host", "localhost:4000"}, {"origin", "http://localhost:4000"}, {"referer", "http://localhost:4000/api/graphiql"}, {"user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.108 Safari/537.36"}], request_path: "/api/graphiql", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}], scheme: :http, script_name: [], secret_key_base: nil, state: :unset, status: nil}, []]}}
        (phoenix) lib/phoenix/endpoint/cowboy2_handler.ex:42: Phoenix.Endpoint.Cowboy2Handler.init/2
        (cowboy) /Users/terence/Workspace/myapp/deps/cowboy/src/cowboy_handler.erl:41: :cowboy_handler.execute/2
        (cowboy) /Users/terence/Workspace/myapp/deps/cowboy/src/cowboy_stream_h.erl:296: :cowboy_stream_h.execute/3
        (cowboy) /Users/terence/Workspace/myapp/deps/cowboy/src/cowboy_stream_h.erl:274: :cowboy_stream_h.request_process/3
        (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3

I was able to make this package work properly on all Ecto.Changeset validations, but for some reason, it keeps getting this exception when it fails the unique constraint check.

Here is the schema:

defmodule MyApp.Authentication.User do
  @moduledoc """
  User schema
  """

  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :first_name, :string
    field :last_name, :string
    field :password, :string, virtual: true
    field :password_hash, :string

    timestamps()
  end

  @doc false
  def registration_changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :password, :first_name, :last_name])
    |> validate_required([:email, :password])
    |> validate_format(:email, ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/)
    |> unique_constraint(:email)
    |> validate_length(:password, min: 8, max: 30)
    |> validate_confirmation(:password)
    |> hash_password()
  end

  defp hash_password(%Ecto.Changeset{valid?: true, changes: %{password: pass}} = changeset) do
    put_change(changeset, :password_hash, Pbkdf2.hash_pwd_salt(pass))
  end

  defp hash_password(changeset), do: changeset
end

This is the schema:

defmodule MyAppWeb.Schema.AuthenticationTypes do
  use Absinthe.Schema.Notation
  import Kronky.Payload

  alias MyAppWeb.Resolvers.Authentication

  object :user, description: "A user" do
    field :id, :id, description: "Unique identifier"
    field :email, :string, description: "User's email address"
    field :first_name, :string, description: "User's first name"
    field :last_name, :string, description: "User's last name"
    field :inserted_at, :naive_datetime, description: "Date and time when user was created"
  end

  object :session, description: "Details for the user's session" do
    field :authentication_token, :string, description: "Unique session token to be used for subsequent requests"
    field :user, :user, description: "User payload"
  end

  input_object :user_sign_up_input, description: "Parameters for user registration" do
    field :email, non_null(:string), description: "Email address of the user"
    field :password, non_null(:string), description: "Hard password that should be at least 8 characters"
    field :password_confirmation, non_null(:string), description: "Repeat the password to ensure correctness"
    field :first_name, :string, description: "Optional first name"
    field :last_name, :string, description: "Optional last name"
  end

  payload_object(:session_payload, :session)

  object :authentication_mutations do
    field :sign_up, :session_payload, description: "Registers a new user" do
      arg :input, non_null(:user_sign_up_input)
      resolve &Authentication.sign_up/3
      middleware &build_payload/2
    end
  end
end

This is the resolver:

defmodule MyAppWeb.Resolvers.Authentication do
  alias MyApp.Authentication
  alias MyApp.Authentication.User
  alias MyApp.Guardian

  def sign_up(_parent, args, _resolution) do
    case Authentication.create_user(args[:input]) do
      {:ok, %User{} = user} ->
        {:ok, token, _claims} = Guardian.encode_and_sign(user)

        {:ok, %{authentication_token: token, user: user}}
      {:error, %Ecto.Changeset{} = changeset} ->
        {:ok, changeset}
    end
  end
end

Am I supposed to be doing something to not get that error on a unique constraint or is this something that's not addresses by this package?

error_payload/1 does not match Ecto.Changeset case as suggested in Docs

Hello,

I am having trouble using the error_payload/1 function in my resolvers. The error is a match error when I pass an Ecto.Changeset as argument. I am willing to work on this and submit a PR, but, I wanted to open a discussion about the approach. Should I update the docs or should I implement the case function for Ecto.Changeset?

The line that says that error_payload can take an Ecto.Changeset: here

The implementation: here

error_payload exposes :template

I don't know if it is intended but :error_payload returns also the template to the client

  def create(arg1, arg2, arg3) do
    {:ok, error_payload([%ValidationMessage{field: :email,
                                            code: "not found",
                                            template: "Something horrible has happened",
                                            message: "does not exist"}])}
  end

returns this result

{
  "data": {
    "createUser": {
      "successful": false,
      "result": null,
      "messages": [
        {
          "template": "Something horrible has happened",
          "message": "does not exist",
          "field": "email",
          "code": "not found"
        }
      ]
    }
  }
}

What is the purpose of having both template and message and returning them both? Is there any way preventing :template from being available to the client?

Inconsistencies

From the docs:

image

I'm trying to make impossible states impossible, and I don't seem to be able to.

  • If an unsuccessful result is always null, then what's the point of the successful fields?
  • The typings for the validation messages is something like: (ValidiationMessage | null)[] | null. The docs says that it's empty when there are no errors - but it can also be null. Which on is it?
  • The validation messages are typed in a way that individual messages can be null. What does that mean?

Generally speaking, why not make the return type impossible to represent something impossible?

  • I would enforce that the messages must be either ValidiationMessage[] or ValidiationMessage[] | null. Having individual null messages is just both confusing and adds lots of uncertainty about the meaning of such cases.
  • IMO saying that result will always be null when unsuccessful is a mistake. Sometimes a user posting a form can be partially successful. Having a result with partial changes applied and a list of errors models this already. I think there should be no success flag at all - extracting success from a payload should be as easy success = () => messages || message.length == 0 || !result.

In our case, having to handle these cases has shown to be very laborious, and particularly misleading for the frontend team, especially when using more strong typed languages that aim at eliminating runtime errors (Elm, TS, etc). The fact that an impossible state is made possible makes the frontend developer have to address it, regardless of how absurd it is.

Maybe a bit of love on the base Payload concept would be great before too much adoption?

protocol Jason.Encoder not implemented for %Kronky.Payload

I tried to use Jason instead of Poison

(Protocol.UndefinedError) protocol Jason.Encoder not implemented for %Kronky.Payload{messages: [%Kronky.ValidationMessage{code: :unknown, field: "base", key: "base", message: "You must login or register to continue.", options: [], template: "You must login or register to continue."}], result: nil, successful: false}, Jason.Encoder protocol must always be explicitly implemented.

If you own the struct, you can derive the implementation specifying which fields should be encoded to JSON:

    @derive {Jason.Encoder, only: [....]}
    defstruct ...

It is also possible to encode all fields, although this should be used carefully to avoid accidentally leaking private information when new fields are added:

    @derive Jason.Encoder
    defstruct ...

Finally, if you don't own the struct you want to encode to JSON, you may use Protocol.derive/3 placed outside of any module:
...

Support Absinthe.Resolution errors in build_payload/2

Hi,

In order to support Absinthe.Resolution errors from Middleware in build_payload/2 like in Absinthe documentation:

defmodule MyApp.Web.Authentication do
  @behaviour Absinthe.Middleware

  def call(resolution, _config) do
    case resolution.context do
      %{current_user: _} ->
        resolution
      _ ->
        resolution
        |> Absinthe.Resolution.put_result({:error, "unauthenticated"})
    end
  end
end

We could change Kronky.Payload.build_payload signature for:

def build_payload(%{value: value, errors: []} = resolution, _config) do
    result = convert_to_payload(value)
    Absinthe.Resolution.put_result(resolution, {:ok, result})
  end

and add this function:

  def build_payload(%{errors: errors} = resolution, _config) do
    result = convert_to_payload({:error, errors})
    Absinthe.Resolution.put_result(resolution, {:ok, result})
  end

This way, we won't have to return an ok tuple in custom Middleware and it will be compatible with third party Middleware.

Here are the tests:

    test "error from resolution, validation message" do
      message = %ValidationMessage{code: :required}
      resolution = resolution_error([message])
      result = build_payload(resolution, nil)

      assert_error_payload([message], result)
    end

    test "error from resolution, string message" do
      resolution = resolution_error(["an error"])
      result = build_payload(resolution, nil)

      message = %ValidationMessage{code: :unknown, message: "an error", template: "an error"}
      assert_error_payload([message], result)
    end

    test "error from resolution, string message list" do
      resolution = resolution_error(["an error", "another error"])
      result = build_payload(resolution, nil)

      messages = [
        %ValidationMessage{code: :unknown, message: "an error", template: "an error"},
        %ValidationMessage{code: :unknown, message: "another error", template: "another error"}
      ]
      assert_error_payload(messages, result)
    end

    test "error from resolution, error list" do
      messages = [%ValidationMessage{code: :required}, %ValidationMessage{code: :max}]
      resolution = resolution_error(messages)

      result = build_payload(resolution, nil)

      assert %{value: value} = result

      assert_error_payload(messages, result)
    end

What do you think about it ?

I will do a Pull Request

Ecto 3.0?

Has anyone tried if this is compatible with ecto 3.0?

Could not serialize term %Kronky.Payload{messages: [], result: true, successful: true} as type Boolean

Hi there,
I have a mutation which returns a boolean ( true or false). This is my mutation:

   field :revoke_token, :boolean do
      resolve(fn _, %{context: context} ->
        context[:current_user]
        |> Accounts.signout()
        {:ok, true}
      end)

and this is how I am calling it in react.

 <RevokeTokenMutation mutation={REVOKE_TOKEN}>
            {(revokeToken, { client }) => (
              <a className="navbar-item" href="#logout" onClick={this.logout(revokeToken, client)}>
                Sign out
              </a>
            )}
          </RevokeTokenMutation>

and logout function is

  private logout(revokeToken: Function, client: ApolloClient<any>) {
    return async (event: React.MouseEvent<HTMLElement>) => {
      event.preventDefault();
      const response: MutationResult<RevokeTokenData> = await revokeToken();

      console.log("Response " + response);
      const errors = response.data!.revokeToken.errors;
      if (!errors) {
        window.localStorage.removeItem('yummy:token');
        await client.resetStore();
        this.props.redirect('/', { notice: 'You are well disconnected' });
      }
    };
  }

I am unable to get past the error. Can I not use boolean type?

Error with :validation_option

Hi,

I have this validation rule in my model :
validate_length(:content, min: 30)

I have a error if options { key, value} is present in my mutation query :
mutation { createRecipe(title: "a title", content: "less than 30") { result { id, title }, messages { template, options { key, value } } } }

The error is :
** (exit) an exception was raised: ** (BadMapError) expected a map, got: {:count, 30} (elixir) lib/map.ex:424: Map.get({:count, 30}, :key, nil) (absinthe) lib/absinthe/middleware/map_get.ex:9: Absinthe.Middleware.MapGet.call/2

I think there is a type error with options resolution. Do you have an idea ?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.