איך לבנות GraphQL API לאפליקציית ריילס

07/05/2021

גרף-קיו-אל מספק דרך מודרנית יותר מ REST לשליפת מידע דרך API, ובצורה בה ה Client קובע איזה מידע הוא יקבל. באופן הזה במקום לפנות ל Endpoint מסוים כדי לקבל את רשימת אנשי הקשר, ואז ל Endpoint אחר כדי לקבל את מספרי הטלפון של כולם, אפשר לפנות ל Endpoint אחד ולבקש לקבל את כל הפרטים במכה אחת.

בעבר פרסמתי פה מדריך לשליפת מידע משרת דרך GraphQL. אני ממליץ לקרוא את המדריך בקישור לפני שממשיכים לפוסט של היום, שמראה איך לבנות שרת GraphQL.

1. חלק 0: התקנת ה Gem

בשביל לעבוד עם GraphQL ביישום ריילס נצטרך להתקין gem ולהפעיל generator. כל ההוראות בדף ה gem בקישור https://graphql-ruby.org/. בקצרה יש להוסיף ל Gemfile את השורה:

gem 'graphql'

ואז להריץ:

$ bundle install
$ rails generate graphql:install

הקוד מייצר את כל התשתית ל GraphQL בתוך היישום שלנו.

2. חלק 1: הגדרת הטיפוסים

בשביל שהשרת יוכל לענות על שאילתות GraphQL אנחנו צריכים להגדיר את אוסף השדות שאנחנו מאפשרים ללקוחות למשוך, ואת היחסים בין שדות אלה. ב GraphQL זה נקרא הגדרת טיפוסים והגדרת סכימה. סכימה היא המבנה שמארגן את הטיפוסים, וכל טיפוס מתאים למודל במערכת או לטבלה בבסיס הנתונים.

לדוגמה אם יש לנו יישום של יומן פגישות נוכל להגדיר את הטיפוס הבא כדי לייצג פגישה:

# app/graphql/types/meeting_type.rb
module Types
  class MeetingType < Types::BaseObject
    description 'A meeting'
    field :id, ID, null: false
    field :title, String, null: false
    field :starts_at, GraphQL::Types::ISO8601DateTime, null: false
    field :participants, [Types::ContactInfoType], null: true
  end
end

כל field מגדיר מאפיין מסוים ש client יוכל לבקש בתוך השאילתה. הפרמטר השלישי לפונקציה field הוא סוג השדה. נשים לב ש:

  1. הסוג הראשון ID מייצג פשוט מזהה מספרי
  2. הסוג השני, String מייצג מחרוזת
  3. הסוג השלישי, GraphQL::Types::ISO8601DateTime מייצג תאריך
  4. הסוג הרביעי שרשום בתוך סוגריים מרובעים מייצג מערך של טיפוס מסוג אחר, במקרה שלנו זה מערך של Types::ContactInfoType.

אנחנו יכולים לחשוב על טיפוס בתור משהו ש"מתאים" למודל במערכת (ותכף נראה את החלק בקוד שיוצר מודל ומחבר אותו לטיפוס הפגישה), ואז כל field ממופה לפונקציה על אותו המודל. כלומר הקוד הזה יעבוד בגלל שיש לי במערכת מודל של Meeting עם הפונקציות id, title, starts_at ו participants.

המודל שמתאים לטיפוס נראה כך:

class Meeting < ApplicationRecord
  has_many :contact_info_meetings
  has_many :participants, class_name: :ContactInfo, through: :contact_info_meetings, source: :contact_info
end

והטבלה בבסיס הנתונים נראית כך:

  create_table "meetings", force: :cascade do |t|
    t.string "title"
    t.datetime "starts_at"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

הטיפוס השני ביישום הוא הטיפוס שמתאים לאנשי הקשר - Types::ContactInfoType. הקוד שלו נראה כך:

module Types
  class ContactInfoType < Types::BaseObject
    description 'A person in a meeting'
    field :id, ID, null: false
    field :email, String, null: false
    field :phone, String, null: false
  end
end

ועם שני הטיפוסים האלה אנחנו מוכנים להמשיך להגדיר את הסכימה.

3. חלק 2: הגדרת הסכימה

סכימת GraphQL היא נקודת ההתחלה שמחברת בין האוביקטים במערכת שלנו למידע ש GraphQL API צריך להחזיר ללקוחות. במקרה שלנו אנחנו יודעים שיש מודל בשם Meeting והוא מתאים ל Types::MeetingType, ואנחנו יודעים של meeting יש פונקציה בשם participants שמחזירה רשימה של אוביקטי ContactInfo שיתאימו בתורם ל Types::ContactInfoType.

זה אומר שאם יש לי meeting מסוים ביד שקיבלתי מ Meeting.find, אני יכול להשתמש בו כדי להחזיר ללקוח כל מידע שהוא ירצה לגבי מאפייני הפגישה או האנשים שמשתתפים בה.

הסכימה אם כן היא נקודת ההתחלה והיא שמחברת בין בקשה שמגיעה מלקוח לאוביקט Meeting במערכת.

קוד הסכימה נראה כך:

# app/graphql/mainapp_schema.rb
class MainappSchema < GraphQL::Schema
  query(Types::QueryType)

  field :meeting, MeetingType, null: false do
    description 'Find a meeting by ID'
    argument :id, ID, required: true
  end

  def meeting(id:)
    Meeting.find(id)
  end
end

פה יש כמה דברים חדשים:

  1. הפונקציה field מקבלת בלוק (במקום אוסף פרמטרים).

  2. בתוך הבלוק אני מפעיל פונקציה בשם argument כדי לציין שה meeting הזה תלוי בארגומנט id

  3. הפונקציה meeting שמוגדרת במחלקה עם שם זהה לשם השדה היא האחראית על שליפת המידע. היא מקבלת כפרמטר את הארגומנט שהשדה קיבל וצריכה להחזיר אוביקט שדרכו GraphQL יוכל להמשיך לחשב את התשובה לכל השאילתה.

  4. לא הראיתי כאן, אבל אותה פונקציה meeting יכולה לקבל גם אוביקט Context שעובר ישירות מהקונטרולר. אנחנו משתמשים באוביקט זה בשביל להעביר למשל את מזהה המשתמש הנוכחי ולוודא שלמשתמש יש הרשאה לגשת למידע.

4. חלק 3: הגדרת ה Controller וה Route

בשביל שנוכל להעביר שאילתות מבחוץ לשרת ה GraphQL שלנו צריך לבנות Controller שיקבל את השאילתות ויריץ אותן. ה Generator של ה gem בונה אחד אוטומטית וכך נראה הקוד שלו:

class GraphqlController < ApplicationController
  # If accessing from outside this domain, nullify the session
  # This allows for outside API access while preventing CSRF attacks,
  # but you'll have to authenticate your user separately
  protect_from_forgery with: :null_session

  def execute
    variables = prepare_variables(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      # Query context goes here, for example:
      # current_user: current_user,
    }
    result = MainappSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue => e
    raise e unless Rails.env.development?
    handle_error_in_development e
  end

  private

  # Handle variables in form data, JSON body, or a blank value
  def prepare_variables(variables_param)
    case variables_param
    when String
      if variables_param.present?
        JSON.parse(variables_param) || {}
      else
        {}
      end
    when Hash
      variables_param
    when ActionController::Parameters
      variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables.
    when nil
      {}
    else
      raise ArgumentError, "Unexpected parameter: #{variables_param}"
    end
  end

  def handle_error_in_development(e)
    logger.error e.message
    logger.error e.backtrace.join("\n")

    render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500
  end
end

שווה לשים לב:

  1. אם אנחנו רוצים לפנות ל API מתוך Service פנימי, כדאי להשתמש בשורה:
protect_from_forgery with: :null_session

שמאפסת את ה Session כי ממילא אין Session בגישה מסרביס פנימי.

  1. בקוד שנוצר אוטומטית ה context הוא אוביקט ריק, אבל באופן כללי מידע שנעביר שם יגיע לאוביקטי הטיפוסים שהגדרנו בסכימה והם יוכלו להשתמש במידע כדי לוודא שלמשתמש יש הרשאות לגשת למידע. בדרך כלל נרצה להעביר שם את המשתמש הנוכחי, כמו שמופיע בהערה בקוד שנוצר אוטומטית.

5. חלק 4: דוגמה לשאילתה

קוד ה Node.JS הבא מתחבר ל API שיצרנו ומריץ שאילתה שמחזירה את הפרטים על פגישה מספר 1 ואת רשימת האימיילים של אנשי הקשר שמשתתפים בה:

const { request, gql } = require('graphql-request');

const query = gql`
    {
        meeting(id: 1) {
            title
            participants {
                email
            }
    }
}
`
request('http://localhost:3000/graphql', query).then((data) => {
    console.log(data.meeting.participants);
})

שימו לב איך השאילתה מושכת את השדות title ו participants, וגם מציינת איזה שדות מתוך participants לקחת. ב GraphQL יהיה לנו מאוד קל להוסיף סרביסים חדשים או לעדכן את הקוד של סרביסים קיימים כך שימשכו בדיוק את המידע שהם צריכים, בלי שנצטרך לעדכן את אפליקציית הריילס המקורית כל פעם שלקוח צריך מידע חדש.