Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add constraints phase #4

Merged
merged 7 commits into from
Sep 12, 2024
Merged

Add constraints phase #4

merged 7 commits into from
Sep 12, 2024

Conversation

twist900
Copy link
Member

@twist900 twist900 commented Sep 9, 2024

Validates input nodes against constraints defined by the constraints directive in your Absinthe schema. Constraints can be applied to fields and arguments, enforcing rules such as min, max, etc. These constraints can be applied to both individual items and lists.

⚠️ ⚠️ ⚠️ Note that constraints are intentionally implemented using GraphQL directives, which means that they will be rendered on the graphql schema itself and visible to the FE. For example:

"Overrides for location-specific service pricing."
input LocationOverrideInput {
  duration: Int @constraints(min: 300, max: 43200)
  price: Decimal @constraints(min: 0, max: 100000000)
  priceType: ServicePriceType
  locationId: ID!
}

Example usage

Add this phase to your pipeline in your router:

  pipeline =
    config
    |> Absinthe.Plug.default_pipeline(opts)
    |> AbsintheHelpers.Phases.ApplyConstraints.add_to_pipeline(opts)

Add the constraints directive's prototype schema to your schema:

  defmodule MyApp.Schema do
    use Absinthe.Schema
    @prototype_schema AbsintheHelpers.Directives.Constraints
    # ...
  end

Apply constraints to a field or argument:

  field :my_list, list_of(:integer) do
    directive(:constraints, [min_items: 2, max_items: 5, min: 1, max: 100])

    resolve(&MyResolver.resolve/3)
  end

  field :my_field, :integer do
    arg :my_arg, non_null(:string), directives: [constraints: [min: 10]]

    resolve(&MyResolver.resolve/3)
  end

Base automatically changed from add-transformations-phase to main September 10, 2024 09:36
@twist900 twist900 self-assigned this Sep 10, 2024
@twist900 twist900 force-pushed the add-constraints-phase branch 3 times, most recently from cc66e90 to 1e9ed35 Compare September 10, 2024 11:44

defp get_constraint_module(constraint_name) do
String.to_existing_atom(
"Elixir.AbsintheHelpers.Constraints.#{Macro.camelize(Atom.to_string(constraint_name))}"
Copy link
Member Author

@twist900 twist900 Sep 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that unlike transforms, here we can't pass constraint modules directly as whatever we pass to a directive is rendered in the graphql schema itself 😓

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔

well.. I'm still not fan of "guessing modules". But at this point it's just an implementation detail - it will work either way.

One thing I would suggest is dropping the Constaints modules. Right not they are mostly boilerplate, and logic form there could easily fit in this one - where all of them are documented. Something to think about

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can keep the modules separate for now, lets see how this grows, its a reversible decision :)

else: {:ok, node}
end

def call(node = %{data: data}, {:max, max}) when is_binary(data) do

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would the Constraints directive work in conjunction with Transformation? e.g If I've IDs in string format that I would like to transform to integers but also use the min/max constraints, what would be the order of execution? Is it transformation first and then constraints or do we get to set the order we like?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we decide on the order, it depends on the order its added to the pipeline, in our case I've added constraints then transforms here:

https://github.com/surgeventures/system/blob/e321afcf26677b0dc40b86d5a37f740422ef0d81/apps/shedul-umbrella/apps/partners_api_gql/lib/partners_api_gql/router.ex#L44-L49

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

%{message: :min_not_met, details: %{field: "override_ids", min: 5, value: 1}},
%{
message: :min_items_not_met,
details: %{field: "location_ids", min_items: 2, items: [1]}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it's missing scenario for max_items_not_met.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now done here

Comment on lines 33 to 35
field :my_list, list_of(:integer), directives: [constraints: [min_items: 2, max_items: 5, min: 1, max: 100]] do
resolve(&MyResolver.resolve/3)
end

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are already using field with body, maybe this form would be more readable:

image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've now updated docs and test scenarios for demonstration of this 👍 ✅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, note how it is still useful to have directive inline as a key when it is applied to an arg and there is several of these args received by the field

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep

Comment on lines +14 to +15
|> Absinthe.Plug.default_pipeline(opts)
|> AbsintheHelpers.Phases.ApplyConstraints.add_to_pipeline(opts)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do wonder if this is needed at all. They way I understand directives, they can (should) already include the implementation (with the extend block?).

I think it would simplify a little bit the introduction into projects.

Copy link
Member Author

@twist900 twist900 Sep 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The directive is extended, but we still need to hook into the pipeline to interpret and execute the added constraints and their params

Copy link
Member Author

@twist900 twist900 Sep 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated README here too

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌 I see it now.

Copy link

@mpmiszczyk mpmiszczyk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do review (rethink? is that right word?) values being returned in errors. I'm still 👌 with discussing it, just would like to be sure that it will be considered.

And the valuse is the only real "issue" I have.

@@ -1,16 +1,132 @@
# AbsintheHelpers
# Absinthe Helpers

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice.

Since you are using README.md you can use it at a main page of the docs - like we are doing here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✅

phase: __MODULE__,
message: reason,
extra: %{
details: Map.merge(details, %{field: node.name})

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

that much better than what we got.

I would be much better if we could provide full path to it (like create_booing.service.cost), but I'm not sure if that's possible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be much better if we could provide full path to it (like create_booing.service.cost), but I'm not sure if that's possible.

yep, in a separate PR 👍

Comment on lines 17 to 25
if String.length(data) > max,
do: {:error, :max_exceeded, %{max: max, value: data}},
else: {:ok, node}
end

def call(node = %{data: data}, {:max, max}) do
if data > max,
do: {:error, :max_exceeded, %{max: max, value: data}},
else: {:ok, node}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if String.length(data) > max,
do: {:error, :max_exceeded, %{max: max, value: data}},
else: {:ok, node}
end
def call(node = %{data: data}, {:max, max}) do
if data > max,
do: {:error, :max_exceeded, %{max: max, value: data}},
else: {:ok, node}
if String.length(data) > max,
do: {:error, :max_exceeded, %{max: max}},
else: {:ok, node}
end
def call(node = %{data: data}, {:max, max}) do
if data > max,
do: {:error, :max_exceeded, %{max: max}},
else: {:ok, node}

I would be against passing values here. I'm always afraid, that if that's a struct we will have problems with serializing those. But what worries me more, is that we are putting into error parameters send to us from users. Some services are putting those errors into logs (all returned errors from graplQL - since we don't really have error codes...) and it could lead to leaking of confidential information :/

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(and that goes for all values added to errors)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, removed user values from errors ✅

Comment on lines +14 to +15
|> Absinthe.Plug.default_pipeline(opts)
|> AbsintheHelpers.Phases.ApplyConstraints.add_to_pipeline(opts)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌 I see it now.


defp get_constraint_module(constraint_name) do
String.to_existing_atom(
"Elixir.AbsintheHelpers.Constraints.#{Macro.camelize(Atom.to_string(constraint_name))}"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔

well.. I'm still not fan of "guessing modules". But at this point it's just an implementation detail - it will work either way.

One thing I would suggest is dropping the Constaints modules. Right not they are mostly boilerplate, and logic form there could easily fit in this one - where all of them are documented. Something to think about

end

def call(node = %{data: data = %Decimal{}}, {:max, max}) do
if is_integer(max) and Decimal.gt?(data, max),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I do wonder if we need to use the Decimal here 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember that these constraint values are reflected in the GraphQL schema. I'm unsure how we should represent decimals in that context. Should we represent them as strings and parse them here, or as floats? In the Surgex parsers, we compared decimals against integers, maybe we'll be fine here as well, this is reversible too

@twist900 twist900 merged commit 9bce778 into main Sep 12, 2024
4 checks passed
@twist900 twist900 deleted the add-constraints-phase branch September 12, 2024 08:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants