-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
New Mutation API Brainstorming #1275
Comments
cc others who expressed interest in this before: @swalkinshaw @eapache @xuorig @cjoudrey @etripier @Pallinder @chadwilken @cisacke (If you're not interested, you can click "unsubscribe" on the right!) |
I'd like to solve this in "layers", where:
Here's one idea for an API: class Mutations::UpdatePost < GraphQL::Schema::Mutation
description "Update the attributes of a blog post by ID"
argument :post_id, ID, null: false, description: "The ID of the post to update", inject: :post
argument :attributes, Types::PostAttributes, null: false
# Return fields must be `null: true`, because if client_errors is present, the rest will be null
returns :post, Types::Post, null: true
# Any returned `GraphQL::ClientError` instances will go here
returns_client_errors
# equivalent to
# returns :client_errors, [GraphQL::Mutation::ClientErrorType], null: true, description: "..."
# This method corresponds to `post_id...inject: :post` above,
# it will be called _before_ `resolve` and the return value will be passed to `resolve`.
# So, it loads the post from ID, or returns an error.
# It's hooked up to GraphQL-Ruby's `lazy_values` API (aka: works with graphql-batch!).
def post(post_id)
Loaders::ObjectLoader.find("Post", post_id).then do |post|
if post.nil?
GraphQL::ClientError.new("Post ##{post_id} not found")
else
post
end
end
end
# This is called after arguments are loaded, but before resolve.
# It can return true, false, or client errors
def authorize(post:, attributes:)
if post.author_id == @context[:current_user].id
true
else
# could also return `false` here to fail silently
GraphQL::ClientError.new("You can only update posts which you have written.")
end
end
# Finally, this is called. It may return a Hash or client errors
def resolve(post:, attributes:)
if post.update(attributes)
{ post: post }
else
post.errors.full_messages.map { |m| GraphQL::ClientError.new(m) }
end
end
end Using classes means you can use familiar techniques to DRY your code and instill conventions in your codebase. For example, a lot of the code above could be generalized to an |
I like a lot of what's in that example; here are some notes based on what we've got internally: AuthorizationThe auth use case you demonstrate is legitimate, but there is another case worth mentioning: when the auth check does not depend on anything in the arguments (e.g. only depends on the ErrorsI find the name We also follow the ActiveRecord/GraphQL errors approach of providing fields on our errors when possible: type UserError {
message: String!
field: [String!]
} We have a bunch of helpers and logic around translating and validating AR errors, so that we can do One other tiny note on the topic of errors: we went with making our |
Should also note that the specific I think this gem could even include a simple version of The example looks great 👍 . I wonder if it makes sense to provide a separate "hook" method for errors specifically. A kind of "post_resolve". |
We do the exact same, except Again, no strong opinion, just thought I'd mention we're talking about the same thing 👍 |
We also don't have an equivalent to |
I used to return validation errors as part of data, but had major issues:
Better approach is to pass errors via top-level Here is how I changed it in my code tb/northwind-graphql-ruby#33 and here is example mutation spec for testing it. I use |
@tb there are several limitations to that approach also, so I wouldn't say it's a better approach just yet. For example, this part of the spec is worrying:
Meaning that the Another point to consider is that the errors key is not typed, making it a way less attractive option for clients. It's also very difficult to manage its evolution without breaking clients. There are pros, and cons unfortunately, to both approaches. |
@xuorig ... in fact graphql spec already includes it https://github.com/facebook/graphql/blob/master/spec/Section%207%20--%20Response.md#errors |
@tb sorry, includes what? Edit: The GraphQL spec includes errors, the discussion here is whether it's the best place to display user facing errors. |
I love these discussions about authorization and errors, don't mean to derail them but do we feel these need to be solved at the same time as making mutations definable via the class-based API? In other words, would it make sense to include a version of |
Maybe the minimal things we need to discuss for a good base class are generation of input / payload types and how extensible the base class is. These days im leaning towards have a barebones base class mutation which doesn't generate an input and payload types. The single input requirement and payload type are gone since RelayModern. My hopes are we can support mutations like |
Just wanted to add our (hackerone) two cents: How do you express errors to clients? How do you convert ids to objects in a consistent way?
Any help in consistency here would be 💯 How do you authorize a mutation? Structure response Reference Mutations::UpdateMeMutation = GraphQL::Relay::Mutation.define do
name 'UpdateMe'
input_field :tshirt_size, !types.String
return_field :me, -> { UserType }
standardized_mutation # this is how you opt-in to the error handling
resolve ->(_, args, ctx) {
user = ctx[:current_user]
if user.nil?
ctx[:response_builder].add_authentication_required_error
else
user.update(tshirt_size: args.tshirt_size)
ctx[:response_builder]
.add_return_field(:me, user)
.add_errors_from_model(user)
end
}
end Sample schema snippet:
|
@eapache For what it's worth, I think there are already a few ways to do ahead-of-time authorization - enough so that we may not need a special case for mutations. For one, you can use a schema whitelist. The Authorization DSL also does some ahead-of-time checking; you can recreate it by combining a query analyzer with some custom instrumentation. Some of my team members have been using the schema whitelist approach. Personally, I think a more general, non-mutation-specific tool makes a bit more sense in an ahead-of-time context because you don't need any information obtained within the resolver. |
@rmosolgo That looks fantastic! I particularly like that the authorization functionality works well with |
Whitelist visibility approach is not desirable for us because we don't want to hide the mutations from introspection or anything like that; just prevent them from being run. Analyzers and instrumentation operate at the request level and don't provide a way to reject a single mutation while permitting the rest of the request to execute. |
Thanks everyone for this discussion! It sounds like we're on to something 😎 I have a few responses:
What if we used (😱 )
Glad to hear we're basically on the same page. I don't have a strong opinion on the name, so we can work out those details down the line.
That's 👍 with me. We'll have layers, so people can mess with it if they want.
For me, the requirement is that bells and whistles fit in nicely. I don't think I can confidently build the base model without also building the luxury model, so I'd like to build them together.
👍
Thanks @bwillis, that looks pretty similar to the github flow: fetch object, then authorize. One trick is that we put auth in our
I think these two things are complementary: sometimes you want to completely hide a mutation, but other times you want to conditionally allow/disallow it (eg, you can only update your own comments). But for that to work, the mutation must be visible.
I think the design goal of GraphQL is:
For example, I heard that Relay completely disregards |
Wanted to throw another example out there for authorizing mutations. We already had an authorization instrumentation for fields and types which we also wanted to use for mutations so all of our auth logic can live in one spot. To do that, we first added a custom def root_resolver(original_resolver, root_resolver)
-> (obj, args, ctx) do
# Call the `resolve_root` proc
root = root_resolver.call(obj, args, ctx)
# Pass the result to the original resolve instead of `obj`
original_resolver.call(root, args, ctx)
end
end Our authorization instrumentation is essentially: def authorize_object_resolver(action, original_resolver)
-> (obj, args, ctx) do
# In our mutations, `obj` will be the result of `resolve_root`
return unless ctx[:current_ability].can?(action, obj)
original_resolver.call(obj, args, ctx)
end
end Then our mutations look like: Mutations::UserUpdateMutation = GraphQL::Relay::Mutation.define do
name 'UserUpdate'
description 'Update a User'
# Custom `authorization` instrumentation
authorize :update
input_field :id, !types.ID
input_field :user, !Types::Input::UserInputType
# Custom `resolve_root` instrumentation
resolve_root -> (obj, args, ctx) { User.find(args[:id]) }
# `user` here would normally be `nil` since all mutation fields exist on the root MutationType
resolve -> (user, args, ctx) do
# At this point, the user has already been authorized with:
# ctx[:current_ability].can?(:update, user)
if user.update_attributes(args[:user])
{ success: true, user: user }
else
{ success: false, errors: user.errors }
end
end So |
Thanks for sharing that example, @jeffcarbs , that's a nice way to show how auth can be fitted in programmatically! I'm going start chipping away at #1310, feel free to review there if you're interested. |
I just read this article from the GraphQL weekly https://blog.graph.cool/graphql-directive-permissions-authorization-made-easy-54c076b5368e it's on the Javascript implementation and how to fit directives into permission checking nonetheless I find it very relatable to Mutation authorization and generally Query authorization like in the article the author mentions that |
Starting to work on built-in loading/auth in #1609 |
GraphQL::Relay::Mutation
is really showing its age, and I'd like to replace it with a new class-based API for structuring mutations. I want to answer a few specific questions that have come up over the years. Here are some issues and the current best practice:id
s to objects in a consistent way? Current best practice is to load ids as needed."errors"
key, maybe return"errors":
as part of the mutation response, maybe a dedicatedClientError
type.So, I have some thoughts about how to solve this which I will add below as a comment, and I'd love to hear if anyone else has other issues to consider or suggestions to propose.
The text was updated successfully, but these errors were encountered: