본문 바로가기

배움의 즐거움/프로그래밍

(32) Graphql-ruby - Mutation의 모든 것

반응형






Mutation Root

GraphQL mutation 은 모두 mutation keyword 로 시작한다.

mutation($accountNumber: ID!, $newBalance: Int!) {
# ^^^^ here
  setAccountBalance(accountNumber: $accountNumber, newBalance: $newBalance) {
    # ...
  }
}


mutation 으로 시작하는 동작들은 GraphQL 런타임에 의해서 특별한 취급을 받는다. 루트 필드들은 순차적으로 실행되도록 보장된다. 이러한 방법으로 여러개의 mutation 의 결과를 예측할 수 있다.

mutation 은 특정한 GraphQL 객체인 Mutation 에 의해 실행된다. 이 객체는 다른 GraphQL 객체들과 같이 정의되어 있다.

class Types::Mutation < Types::BaseObject
  # ...
end


그리고 나면 이 것은 스키마에 mutation(...)의 형태로 연결된다.

class Schema < GraphQL::Schema
  # ...
  mutation(Types::Mutation)
end

이제 들어오는 요청이 mutation 키워드를 사용하면 이 것은 Mutation으로 간다.


Mutation Classes

GraphQL mutation 은 특수한 필드이다. 데이터를 읽고 계산을 수행하는 대신에 그들은 어플리케이션 상태를 수정한다. 예를 들어 mutation 필드는 다음과 같은 것 들을 할 수 있다.

  • 데이터의 생성, 수정, 제거
  • 이미 존재하는 데이터와의 관계를 구축
  • 카운터 증가
  • 파일 생성, 수정, 제거
  • 캐쉬 클리어


이러한 액션들은 side effects 라고 불리운다.

모든 GraphQL 필드와 같이 mutation 필드는

  • arguments 라고 불리우는 인풋을 받는다.
  • fields 를 통해 값을 리턴한다.


GraphQL-Ruby 는 mutation 생성을 돕기 위해 두 개의 클래스를 포함한다.


두 개 외에도 단순히 field API를 사용하여 mutation 필드를 작성할 수 있다.

추가적인 null 헬퍼 메서드는 mutation 에 null 설정을 허용하기 위해서 GraphQL::Schema::Mutation 를 상속받은 클래스로부터 제공된다. 이 것은 필수가 아니며 기본값은 true 이다.


Example mutation class


우선 베이스 클래스를 추가해야 한다.
class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation
end


그리고 이 것을 너의 mutation 으로 확장한다.

class Mutations::CreateComment < Mutations::BaseMutation
  null true

  argument :body, String, required: true
  argument :post_id, ID, required: true

  field :comment, Types::Comment, null: true
  field :errors, [String], null: false

  def resolve(body:, post_id:)
    post = Post.find(post_id)
    comment = post.comments.build(body: body, author: context[:current_user])
    if comment.save
      # 성공적으로 생성, 생성된 객체를 빈 에러와 함께 리턴한다.
      {
        comment: comment,
        errors: [],
      }
    else
      # 저장 실패, 에러와 함께 클라이언트에게 리턴한다.
      {
        comment: nil,
        errors: comment.errors.full_messages
      }
    end
  end
end

#resolve  메서드는 field 이름들과 매치하는 심볼들과 함께 해쉬를 리턴해야 한다.

(Mutation Errors 에 대한 더 자세한 정보)


Hooking up mutations


Mutation 은 반드시 mutation root에 mutation:  키워드와 함께 연결되어야 한다.

class Types::Mutation < Types::BaseObject
  field :create_comment, mutation: Mutations::CreateComment
end


Auto-loading arguments


대부분의 케이스에서 GraphQL mutation 은 주어진 글로벌 relay ID 에 반해서 동작할 것이다. 이 글로벌 relay ID 를 이용한 객체의 로딩은 mutation 의 resolver 에 있는 반복되는 코드를 필요로 한다.

대안적인 방법은 argument 를 정의할 때 loads: argument 를 사용하는 것이다.

class Mutations::AddStar < Mutations::BaseMutation
  argument :post_id, ID, required: true, loads: Types::Post

  field :post, Types::Post, null: true

  def resolve(post:)
    post.star

    {
      post: post,
    }
  end
end

post_id argument 가 Types::Post 객체 타입을 로드함을 명시함으로써, Post 객체는 Schema#object_from_id 를 통해서 제공된 post_id와 함께 로드될 것이다.

_id 로 끝나거나 loads: 메서드를 사용하는 argument 의 _id 접미사는 모두 제거된다. 예를 들어, 위의 mutation resolver 는 post_id argument 대신에 객체가 로드된 post: 를 받는다.

loads: 옵션은 또한 ID 들의 리스트와도 사용할 수 있다.

class Mutations::AddStars < Mutations::BaseMutation
  argument :post_ids, [ID], required: true, loads: Types::Post

  field :posts, [Types::Post], null: true

  def resolve(posts:)
    posts.map(&:star)

    {
      posts: posts,
    }
  end
end 

_ids 로 끝나거나 loads: 메서드를 사용하는 변수의 _id 접미사는 모두 제거되며 s 가 이름 뒤에 붙는다. 예를 들어, mutation resolver 은 post_ids 변수 대신에 모든 객체가 로드된 posts 변수를 받는다.

어떤 경우에는 결과 변수 이름을 변경하고 싶을 것이다. 이 때는 as: 변수를 사용하여 할 수 있다.


class Mutations::AddStar < Mutations::BaseMutation
  argument :post_id, ID, required: true, loads: Types::Post, as: :something

  field :post, Types::Post, null: true

  def resolve(something:)
    something.star

    {
      post: something
    }
  end
end

 위의 예시에서는 loads: 는 구체적인 타입으로 제공되었지만 추상 타입 또한 지원된다.(예를 들어 interace 나 union 같은)



Mutation errors

mutation 내에서 에러는 어떻게 핸들링 할까? 몇 가지 옵션을 살펴보자.


Raising Errors


에러를 핸들링 하는 한 가지 방법은 다음과 같다.

def resolve(id:, attributes:)
  # 만약 데이터가 유효하지 않다면 crash 될 것이다.
  Post.find(id).update!(attributes.to_h)
  # ...
end

또는

def resolve(id:, attributes:)
  if post.update(attributes)
    { post: post }
  else
    raise GraphQL::ExecutionError, post.errors.full_messages.join(", ")
  end
end

이러한 종류의 에러 핸들링은 에러 상태를 표현한다.( HTTP 500 또는 top-level "errors"  키를 통해서) 그러나 이 것은 GraphQL의 타입 시스템의 이점을 사용하지 않을 뿐더러 오직 한번에 하나의 에러만을 표현할 수 있다. 이렇게 하는 것은 되긴 되나 더 나은 방안은 에러를 데이터처럼 취급하는 것이다.


Errors as Data


에러를 핸들링하는 또 다른 방법은 에러타입을 스키마에 추가하는 것이다.

class Types::UserError < Types::BaseObject
  description "A user-readable error"

  field :message, String, null: false,
    description: "A description of the error"
  field :path, [String], null: true,
    description: "Which input value this error came from"
end

그리고 해당 에러 필드를 사용하고자 하는 mutation 에 추가한다.

class Mutations::UpdatePost < Mutations::BaseMutation
  # ...
  field :errors, [Types::UserError], null: false
end


그리고 mutation의 resolve 메서드에 errors:가 hash 형태로 리턴되도록 한다.

def resolve(id:, attributes:)
  post = Post.find(id)
  if post.update(attributes)
    {
      post: post,
      errors: [],
    }
  else
    # Rails 모델 에러를 GraphQL-ready error hash로 변환
    user_errors = post.errors.map do |attribute, message|
      # This is the GraphQL argument which corresponds to the validation error:
      path = ["attributes", attribute.camelize]
      {
        path: path,
        message: message,
      }
    end
    {
      post: post,
      errors: user_errors,
    }
  end
end


이제 필드가 페이로드에 errors 를 리턴하므로 이것은 errors 를 들어오는 mutation 의 일부로써 지원한다. 예를 들어보자.

mutation($postId: ID!, $postAttributes: PostAttributes!) {
  updatePost(id: $postId, attributes: $postAttributes) {
    # 이 부분은 성공,실패 모든 경우에 보여진다
    post {
      title
      comments {
        body
      }
    }
    # 실패 했을 경우, 에러가 이 리스트 안에 있게된다
    errors {
      path
      message
    }
  }
}

실패했을 경우, 너는 아래와 같은 응답을 받게 될 것이다.

{
  "data" => {
    "createPost" => {
      "post" => nil,
      "errors" => [
        { "message" => "Title can't be blank", "path" => ["attributes", "title"] },
        { "message" => "Body can't be blank", "path" => ["attributes", "body"] }
      ]
    }
  }
}

그 후 클라이언트 앱은 사용자가 필드를 올바르게 수정할 수 있도록해당 에러 메시지를 사용자에게 보여줄 것이다. 


Nullable Mutation Payload Fields


위에 설명한 것 처럼 "에러를 데이터로써 취급하는 것" 의 혜택을 받으려면 mutation 필드가 반드시 null: true 를 가져야 한다. 왜 그런 것일까?

non-null 필드( null: false) 의 경우, 만약 nil 을 리턴하면 GraphQl 은 쿼리를 중단하고 해당 필드를 응답으로부터 모두 제거한다. mutation 에서 에러가 발생하면 다른 필드들도 nil 을 리턴할 수 있다. 그러므로 만약 다른 필드가 null: false를 가지고 있는데 nil 을 리턴하게 된다면 GraphQL 은 당황하게 되고 에러를 포함해서 전체 mutation 을 응답으로부터 제거하게 될 것이다!

풍부한 에러 데이터를 갖기 위해서는, 심지어 다른 필드가 nil 일 때에도 오류가 발생할 때 타입 시스템을 준수할 수 있도록 해당 필드는 반드시 null: true를 가져야 한다.


Here’s an example of a nullable field (good!):

class Mutations::UpdatePost < Mutations::BaseMutation
  # rich errors 을 사용하기 위해서는 반드시 `null: true` 를 사용해야 한다.
  field :post, Types::Post, null: true
  # ...
end


Mutation authorization


mutation 을 실행하기 전에, 몇가지 해야할 것이 있다.

  • 현재 사용자가 해당 mutation 을 수행할 수 있는 권한이 있는지 확인
  •  ID 인풋을 사용하여 데이터베이스로부터 몇개의 객체 로드
  • 해당 사용자가 로드된 객체를 수정할 수 있는지 권한 확인


Checking the user permissions


데이터베이스로 부터 데이터를 로드하기 앞서, 해당 사용자가 권한이 있는지를 확인하고 싶을 것이다. 예를 들어 오직 .admin?사용자만 Mutation.promoteEmployee 를 실행할 수 있다고 보자.

muation #ready?의 메서드를 이용해서 구현할 수 있다.

class Mutations::PromoteEmployee < Mutations::BaseMutation
  def ready?(**args)
    if !context[:current_user].admin?
      raise GraphQL::ExecutionError, "Only admins can run this mutation"
    else
      # 해당 mutation 을 계속 하기 위해서 true 값 리턴
      true
    end
  end

  # ...
end

 이제 non-admin 사용자가 mutation 을 시도한다면 이는 실행되지 않을 것이다. 대신에 에러를 응답메시지로 받게 될 것이다. 

추가적으로 #ready? 는 errors as data 를 사용하여 false, { ... } 를 리턴할 수 있다.

def ready?
  if !context[:current_user].allowed?
    return false, { errors: ["You don't have permission to do this"]}
  else
    true
  end
end


Loading and authorizing objects


때때로 mutation 은 ID 를 인풋으로 받은 뒤 이를 데이터베이스로부터 정보를 로드하는데 사용한다. GraphQL-Ruby 는 옵션이 있다면 ID 를 로드할 수 있다.

간단히 말해서 여기 예시가 있다.

class Mutations::PromoteEmployee < Mutations::BaseMutation
  # `employeeId` 는 ID 이고 Types::Employee 는 객체의 타입이다
  argument :employee_id, ID, required: true, loads: Types::Employee

  # `:employee_id` 는 데이터베이스로부터 객체를 가져오도록 사용된다.
  #  그리고 나면 객체는`Employee.authorized?` 로 권한이 부여되고
  #  모든게 유효하다면 객체는 여기로 삽입된다.
  def resolve(employee:)
    employee.promote!
  end
end

 이 것의 동작원리는 다음과 같다. 만약 너가 loads: 옵션을 전달하면 이 것은

  • 자동적으로 이름으로부터 _id 를 제거하고 이를 as: 옵션으로 전달한다
  • 주어진 ID (Schema.object_from_id 를 사용하여)를 이용하여 객체를 가져오기 위해서 prepare hook 를 추가한다
  • 가져온 객체의 타입이 loads: type 과 매치하는지 확인한다 (Schema.resolve_type 를 사용하여)
  • 가져온 객체를 해당 타입의 .authorized? 후크를 사용하여 실행하기(더보기 Authorization)
  • object-style name (employee:)을 사용하여 #resolve 에 삽입하기


위의 경우에서 만약 object_from_id 로 제공받은 변수의 값이 값을 리턴하지 않을 때, mutation 은 에러와 함께 실패로 처리된다.

만약 이렇게 동작하는 것을 원하지 않는다면 사용하지 않으면 된다. 대신에 타입 ID 를 사용해서 매개변수를 생성할 수 있다. 

# 특별한 loading 을 사용하지 않음
argument :employee_id, ID, required: true


Can this user perform this action?


가끔은 특정한 사용자-객체-행동의 조합을 권한부여를 체크해야 할 수도 있다. 예를 들어서 사용자는 모든  employees 를 승진 시킬 수 없다. 그들은 오직 그들이 관리하는 employees 만 승진시킬 수 있어야 한다.

이 것은 #authorized? 메서드 구현을 통해서 이뤄질 수 있다. 예를 들어,

def authorized?(employee:)
  context[:current_user].manager_of?(employee)
end

#authorized? 가 false 를 리턴하면 mutation 은 중단될 것이고 true 를 리턴하면 이어질 것이다.


Adding errors

에러를 데이터로써 추가하기 위해서는 false 와 함께 리턴할 수 있다. 예를 들어

def authorized?(employee:)
  if context[:current_user].manager_of?(employee)
    true
  else
    return false, { errors: ["Can't promote an employee you don't manage"] }
  end
end

 대안으로, GraphQL::ExecutionError 를 발생시킴으로써 top-level error 를 추가할 수 있다. 예를 들어

def authorized?(employee:)
  if !context[:current_user].manager_of?(employee)
    raise GraphQL::ExecutionError, "You can only promote your _own_ employees"
  end
end

위의 두 경우 모두 mutation 은 중단될 것이다.


Finally, doing the work

이제 사용자가 권한 확인이 되었고, 데이터가 로드되었고, 객체가 유효성 검사를 거쳤으니  #resolve 를 통해서 데이터베이스를 수정할 수 있다.

def resolve(employee:)
  if employee.promote
    {
      employee: employee,
      errors: [],
    }
  else
    # See "Mutation Errors" for more:
    {
      errors: employee.errors.full_messages
    }
  end
end


* 해당 글은 번역기 돌리다가 크롬 번역기 말도 안되는 해석에 지친 본인이 나중에 참고할 의도로 대충대충 발로 해석한 것이니 참고용으로만 사용하시길 바랍니다.

* 출처: http://graphql-ruby.org/mutations/mutation_root.html

* 출처: http://graphql-ruby.org/mutations/mutation_classes.html

* 출처: http://graphql-ruby.org/mutations/mutation_errors.html

* 출처: http://graphql-ruby.org/mutations/mutation_authorization.html



반응형