الجزء 8: GraphQL

قاعدة البيانات وإدارة المستخدمين

في هذا الفصل، سنبدأ باستخدام قاعدة بيانات لتخزين البيانات ونوسّع التطبيق بإدارة المستخدمين. لكن أولاً، سنعيد هيكلة شيفرة الواجهة الخلفية. يمكن العثور على الشيفرة الحالية للواجهة الخلفية لتطبيق دليل الهاتف على GitHub في الفرع part8-3.

إعادة هيكلة الواجهة الخلفية

حتى الآن، كتبنا كل الشيفرة في ملف index.js. مع نمو التطبيق، لم يعد هذا منطقياً: فكلما طال الملف، ضعفت قابلية قراءته وفهمه. كما أن من ممارسات البرمجة الجيدة فصل مسؤوليات التطبيق المختلفة في وحدات خاصة بها.

لنُعِد الآن هيكلة الواجهة الخلفية بتقسيمها إلى عدة ملفات.

سنبدأ باستخراج مخطط GraphQL الخاص بالتطبيق إلى ملف يُسمّى schema.js:

const typeDefs = /* GraphQL */ `
  type Address {
    street: String!
    city: String!
  }

  type Person {
    name: String!
    phone: String
    address: Address!
    id: ID!
  }

  enum YesNo {
    YES
    NO
  }

  type Query {
    personCount: Int!
    allPersons(phone: YesNo): [Person!]!
    findPerson(name: String!): Person
  }

  type Mutation {
    addPerson(
      name: String!
      phone: String
      street: String!
      city: String!
    ): Person
    editNumber(name: String!, phone: String!): Person
  }
`

module.exports = typeDefs

بعد ذلك، سننقل الشيفرة المسؤولة عن resolvers إلى وحدتها الخاصة resolvers.js:

const { GraphQLError } = require('graphql')
const { v1: uuid } = require('uuid')

let persons = [
  {
    name: 'Arto Hellas',
    phone: '040-123543',
    street: 'Tapiolankatu 5 A',
    city: 'Espoo',
    id: '3d594650-3436-11e9-bc57-8b80ba54c431',
  },
  {
    name: 'Matti Luukkainen',
    phone: '040-432342',
    street: 'Malminkaari 10 A',
    city: 'Helsinki',
    id: '3d599470-3436-11e9-bc57-8b80ba54c431',
  },
  {
    name: 'Venla Ruuska',
    street: 'Nallemäentie 22 C',
    city: 'Helsinki',
    id: '3d599471-3436-11e9-bc57-8b80ba54c431',
  },
]

const resolvers = {
  Query: {
    personCount: () => persons.length,
    allPersons: (root, args) => {
      if (!args.phone) {
        return persons
      }
      const byPhone = (person) =>
        args.phone === 'YES' ? person.phone : !person.phone
      return persons.filter(byPhone)
    },
    findPerson: (root, args) => persons.find((p) => p.name === args.name),
  },
  Person: {
    address: ({ street, city }) => {
      return {
        street,
        city,
      }
    },
  },
  Mutation: {
    addPerson: (root, args) => {
      if (persons.find((p) => p.name === args.name)) {
        throw new GraphQLError(`Name must be unique: ${args.name}`, {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.name,
          },
        })
      }

      const person = { ...args, id: uuid() }
      persons = persons.concat(person)
      return person
    },
    editNumber: (root, args) => {
      const person = persons.find((p) => p.name === args.name)
      if (!person) {
        return null
      }

      const updatedPerson = { ...person, phone: args.phone }
      persons = persons.map((p) => (p.name === args.name ? updatedPerson : p))
      return updatedPerson
    },
  },
}

module.exports = resolvers

للتبسيط، وُضعت الآن مصفوفة persons التي تحمل بيانات الأشخاص في الملف نفسه مع الـ resolvers. وستُزال المصفوفة قريباً عندما ننتقل إلى استخدام قاعدة بيانات لتخزين البيانات.

أخيراً، سننقل أيضاً الشيفرة المسؤولة عن تشغيل خادم Apollo إلى ملفها الخاص server.js:

const { ApolloServer } = require('@apollo/server')
const { startStandaloneServer } = require('@apollo/server/standalone')

const resolvers = require('./resolvers')
const typeDefs = require('./schema')

const startServer = (port) => {
  const server = new ApolloServer({
    typeDefs,
    resolvers,
  })

  startStandaloneServer(server, {
    listen: { port },
  }).then(({ url }) => {
    console.log(`Server ready at ${url}`)
  })
}

module.exports = startServer

أصبح تشغيل خادم Apollo الآن يُعالَج داخل دالة startServer التي عرّفناها بأنفسنا. وهذا يتيح لنا تصدير الدالة وبدء الخادم من خارج الوحدة، من ملف index.js. وتأخذ الدالة كمعامل المنفذ الذي سيستمع عليه Apollo Server.

لنثبّت مكتبة dotenv حتى نتمكن من تعريف متغيرات البيئة في ملف .env:

npm install dotenv

لم تبقَ سوى كمية صغيرة من الشيفرة في index.js. وبعد إعادة الهيكلة، يكون محتواها كما يلي:

require('dotenv').config()

const startServer = require('./server')

const PORT = process.env.PORT || 4000

startServer(PORT)

تُقرأ متغيرات البيئة أولاً من ملف .env باستخدام مكتبة dotenv. ويُقرأ المنفذ المستخدم الآن من متغير بيئة، إن كان معرّفاً. وإذا لم يُعثر على متغير البيئة PORT، يُستخدم المنفذ الافتراضي 4000—وهو أيضاً المنفذ الذي تتوقع الواجهة الأمامية حالياً أن يعمل الخادم عليه. وأخيراً، يُبدأ Apollo Server باستدعاء الدالة startServer.

في الوقت الحالي، محتوى index.js مجرد هيكل أولي، لكنه سيتضمن المزيد مع نمو التطبيق. مثلاً، عندما ننتقل قريباً إلى استخدام قاعدة بيانات لتخزين البيانات، يجب إنشاء اتصال قاعدة البيانات قبل بدء الخادم.

أصبحت مسؤوليات التطبيق الآن مفصولة بوضوح:

  • تعمل index.js كالبرنامج الرئيسي، ومسؤوليتها الوحيدة منطق بدء التشغيل. فهي تضمن بدء أجزاء التطبيق المختلفة بالترتيب الصحيح.
  • يُعرَّف مخطط GraphQL في وحدة schema.js. وهو يصف بنية الـ API—مثلاً، أي الاستعلامات والـ mutations ممكنة عبر الـ API وما أنواع الحقول التي تمتلكها الكائنات المختلفة.
  • يُعرَّف منطق التطبيق الفعلي في وحدة resolvers.js. ومسؤوليتها، مثلاً، تعريف ما يحدث فعلاً في مختلف الاستعلامات، ومن أين تُجلب البيانات، وكيف تُعالَج.
  • تُعرَّف الشيفرة المسؤولة عن إعداد Apollo Server وتشغيله في وحدة منفصلة، server.js.

Mongoose وApollo

لنبدأ الآن باستخدام قاعدة بيانات MongoDB في تطبيقنا. سنقدّم قاعدة البيانات باتباع النهج المستخدم في الجزأين 3 و4.

ثبّت Mongoose:

npm install mongoose

عرّف مخطط person في الملف models/person.js كما يلي:

const mongoose = require('mongoose')

const schema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    minlength: 5
  },
  phone: {
    type: String,
    minlength: 5
  },
  street: {
    type: String,
    required: true,
    minlength: 5
  },
  city: {
    type: String,
    required: true,
    minlength: 3
  },
})

module.exports = mongoose.model('Person', schema)

أدرجنا أيضاً بعض عمليات التحقق. إن required: true، الذي يضمن وجود قيمة، زائد فعلاً: فنحن نضمن بالفعل وجود الحقول عبر GraphQL. لكن من الجيد أيضاً إبقاء التحقق في قاعدة البيانات.

لننشئ وحدة منفصلة db.js للشيفرة التي تنشئ اتصال قاعدة البيانات:

const mongoose = require('mongoose')

const connectToDatabase = async (uri) => {
  console.log('connecting to database URI:', uri)

  try {
    await mongoose.connect(uri)
    console.log('connected to MongoDB')
  } catch (error) {
    console.log('error connection to MongoDB:', error.message)
    process.exit(1)
  }
}

module.exports = connectToDatabase

تعرّف الوحدة الدالة connectToDatabase، التي تستقبل عنوان URI لقاعدة البيانات كمعامل وتتولى الاتصال بقاعدة البيانات.

لنستخدم الوحدة في ملف index.js:

require('dotenv').config()

const connectToDatabase = require('./db')
const startServer = require('./server')

const MONGODB_URI = process.env.MONGODB_URI
const PORT = process.env.PORT || 4000

const main = async () => {
  await connectToDatabase(MONGODB_URI)
  startServer(PORT)
}

main()

لأن صيغة async/await لا يمكن استخدامها إلا داخل الدوال، نعرّف الآن دالة main بسيطة تتولى بدء التطبيق. وهذا يتيح لنا استدعاء الدالة التي تنشئ اتصال قاعدة البيانات باستخدام الكلمة المفتاحية await.

تُستخرج قيمة MONGODB_URI من متغير بيئة، لذا عليك إضافة قيمة مناسبة له في ملف .env بالطريقة نفسها كما في الجزء 3. يستدعي التطبيق أولاً الدالة التي تنشئ اتصال قاعدة البيانات، وبمجرد إنشاء اتصال قاعدة البيانات بنجاح، يبدأ خادم GraphQL.

سيتغيّر محتوى resolvers.js، المسؤول عن منطق التطبيق، شبه كلياً. ويمكننا جعل التطبيق يعمل إلى حد كبير بإجراء التغييرات التالية:

const { GraphQLError } = require('graphql')
const Person = require('./models/person')

const resolvers = {
  Query: {
    personCount: async () => Person.collection.countDocuments(),
    allPersons: async (root, args) => {
      // الفلاتر مفقودة
      return Person.find({})
    },
    findPerson: async (root, args) => Person.findOne({ name: args.name }),
  },
  Person: {
    address: ({ street, city }) => {
      return {
        street,
        city,
      }
    },
  },
  Mutation: {
    addPerson: async (root, args) => {
      const nameExists = await Person.exists({ name: args.name })

      if (nameExists) {
        throw new GraphQLError(`Name must be unique: ${args.name}`, {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.name,
          },
        })
      }

      const person = new Person({ ...args })
      return person.save()
    },
    editNumber: async (root, args) => {
      const person = await Person.findOne({ name: args.name })

      if (!person) {
        return null
      }

      person.phone = args.phone
      return person.save()
    },
  },
}

module.exports = resolvers

التغييرات مباشرة تماماً. لكن هناك بضعة أمور جديرة بالملاحظة. كما نتذكر، في Mongo يُسمّى حقل تعريف الكائن _id وكان علينا سابقاً تحويل اسم الحقل إلى id بأنفسنا. أما الآن فيستطيع GraphQL فعل ذلك تلقائياً.

أمر آخر جدير بالملاحظة هو أن دوال resolver تُعيد الآن promise، بينما كانت سابقاً تُعيد كائنات عادية. وعندما يُعيد resolver قيمة promise، يُرسل خادم Apollo القيمة التي يُحلّ إليها الـ promise.

مثلاً، إذا نُفّذت دالة resolver التالية،

allPersons: async (root, args) => {
  return Person.find({})
},

فإن خادم Apollo ينتظر حلّ الـ promise، ثم يُعيد النتيجة. إذاً يعمل Apollo تقريباً هكذا:

allPersons: async (root, args) => {
  const result = await Person.find({})
  return result
}

لنكمل resolver الـallPersons ليأخذ المعامل الاختياري phone في الحسبان:

Query: {
  // ..
  allPersons: async (root, args) => {
    if (!args.phone) {
      return Person.find({})
    }

    return Person.find({ phone: { $exists: args.phone === 'YES' } })
  },
},

إذاً إذا لم يُعطَ للاستعلام معامل phone، تُعاد جميع الأشخاص. وإذا كانت قيمة المعامل YES، تُعاد نتيجة الاستعلام

Person.find({ phone: { $exists: true }})

أي الكائنات التي يمتلك فيها الحقل phone قيمة. وإذا كانت قيمة المعامل NO، يُعيد الاستعلام الكائنات التي لا يمتلك فيها الحقل phone أي قيمة:

Person.find({ phone: { $exists: false }})

التحقق من الصحة

كما في GraphQL، يُتحقق الآن من المدخلات باستخدام عمليات التحقق المعرّفة في مخطط mongoose. ولمعالجة أخطاء التحقق المحتملة في المخطط، يجب إضافة كتلة try/catch لمعالجة الأخطاء إلى الدالة save. وعندما نصل إلى catch، نرمي استثناء GraphQLError مع رمز الخطأ :

Mutation: {
  addPerson: async (root, args) => {
      const nameExists = await Person.exists({ name: args.name })

      if (nameExists) {
        throw new GraphQLError(`Name must be unique: ${args.name}`, {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.name,
          },
        })
      }

      const person = new Person({ ...args })


      try {
        await person.save()
      } catch (error) {
        throw new GraphQLError(`Saving person failed: ${error.message}`, {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.name,
            error
          }
        })
      }

      return person

  },
    editNumber: async (root, args) => {
      const person = await Person.findOne({ name: args.name })

      if (!person) {
        return null
      }

      person.phone = args.phone


      try {
        await person.save()
      } catch (error) {
        throw new GraphQLError(`Saving number failed: ${error.message}`, {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.name,
            error
          }
        })
      }

      return person

    }
}

أضفنا أيضاً خطأ Mongoose والبيانات التي تسببت في الخطأ إلى كائن extensions الذي يُستخدم لإيصال مزيد من المعلومات عن سبب الخطأ إلى المستدعي. ويمكن للواجهة الأمامية عندها عرض هذه المعلومات للمستخدم، الذي يمكنه إعادة المحاولة بمدخلات أفضل.

يمكن العثور على شيفرة الواجهة الخلفية على Github، الفرع part8-4.

المستخدم وتسجيل الدخول

لنضف إدارة المستخدمين إلى تطبيقنا. للتبسيط، لنفترض أن جميع المستخدمين لديهم كلمة المرور نفسها وهي مضمّنة مباشرة في النظام. سيكون من المباشر حفظ كلمات مرور فردية لجميع المستخدمين باتباع المبادئ من الجزء 4، لكن لأن تركيزنا على GraphQL، سنتجاوز كل هذه المتاعب الإضافية هذه المرة.

لننشئ مخطط المستخدم في الملف models/user.js:

const mongoose = require('mongoose')

const schema = new mongoose.Schema({
  username: {
    type: String,
    required: true,
    minlength: 3
  },
  friends: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Person'
    }
  ],
})

module.exports = mongoose.model('User', schema)

يرتبط كل مستخدم بمجموعة من الأشخاص الآخرين في النظام عبر حقل friends. والفكرة أنه عندما يضيف مستخدم، مثلاً mluukkai، شخصاً، مثلاً Arto Hellas، إلى القائمة، يُضاف الشخص إلى قائمة friends الخاصة به. وبهذه الطريقة، يمكن للمستخدمين المسجّلين دخولهم الحصول على عرض مخصّص خاص بهم في التطبيق.

تُعالَج عملية تسجيل الدخول وتحديد هوية المستخدم بالطريقة نفسها التي استخدمناها في الجزء 4 عندما استخدمنا REST، أي باستخدام الرموز (tokens).

لنوسّع مخطط GraphQL هكذا:

type User {
  username: String!
  friends: [Person!]!
  id: ID!
}

type Token {
  value: String!
}

type Query {
  // ..
  me: User
}

type Mutation {
  // ...
  createUser(username: String!): User
  login(username: String!, password: String!): Token
}

يُعيد الاستعلام me المستخدم المسجّل دخوله حالياً. ويُنشأ المستخدمون الجدد عبر mutation الـcreateUser، ويتم تسجيل الدخول عبر mutation الـlogin.

لنثبّت مكتبة jsonwebtoken:

npm install jsonwebtoken

تكون resolvers الـ mutations الجديدة كما يلي:

const jwt = require('jsonwebtoken')
const User = require('./models/user')

Mutation: {
  // ..
  createUser: async (root, args) => {
    const user = new User({ username: args.username })

    return user.save()
      .catch(error => {
        throw new GraphQLError(`Creating the user failed: ${error.message}`, {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.username,
            error
          }
        })
      })
  },
  login: async (root, args) => {
    const user = await User.findOne({ username: args.username })

    if ( !user || args.password !== 'secret' ) {
      throw new GraphQLError('wrong credentials', {
        extensions: {
          code: 'BAD_USER_INPUT'
        }
      })
    }

    const userForToken = {
      username: user.username,
      id: user._id,
    }

    return { value: jwt.sign(userForToken, process.env.JWT_SECRET) }
  },
},

mutation المستخدم الجديدة مباشرة. تتحقق mutation الـ login من صلاحية زوج اسم المستخدم/كلمة المرور. وإذا كان صالحاً بالفعل، تُعيد رمز jwt مألوفاً من الجزء 4. لاحظ أنه يجب تعريف JWT_SECRET في ملف .env.

يتم الآن إنشاء المستخدم كما يلي:

mutation {
  createUser (
    username: "mluukkai"
  ) {
    username
    id
  }
}

تبدو mutation تسجيل الدخول هكذا:

mutation {
  login (
    username: "mluukkai"
    password: "secret"
  ) {
    value
  }
}

كما في الحالة السابقة مع REST، الفكرة الآن أن يضيف المستخدم المسجّل دخوله الرمز الذي يحصل عليه عند تسجيل الدخول إلى جميع طلباته. وكما في REST، يُضاف الرمز إلى استعلامات GraphQL باستخدام ترويسة Authorization.

في Apollo Explorer، تُضاف الترويسة إلى الاستعلام هكذا:

يعرض Apollo Explorer الترويسات مع Authorization وBearer token

في الواجهة الخلفية، الطريقة الأكثر ملاءمة لتمرير الرمز الذي يصل مع الطلب إلى الـ resolvers هي استخدام context الخاص بـ Apollo Server. وباستخدام context، يمكننا تنفيذ أمور مشتركة بين جميع الاستعلامات والـ mutations، مثلاً تحديد هوية المستخدم المرتبط بالطلب.

لنغيّر بدء تشغيل الواجهة الخلفية بحيث يتضمن الكائن المُمرَّر كمعامل ثانٍ إلى دالة startStandaloneServer حقل context، ولننشئ دالة مساعدة getUserFromAuthHeader للتحقق من صلاحية الرمز وإيجاد المستخدم في قاعدة البيانات:

const { ApolloServer } = require('@apollo/server')
const { startStandaloneServer } = require('@apollo/server/standalone')
const jwt = require('jsonwebtoken')

const resolvers = require('./resolvers')
const typeDefs = require('./schema')
const User = require('./models/user')


const getUserFromAuthHeader = async (auth) => {
  if (!auth || !auth.startsWith('Bearer ')) {
    return null
  }

  const decodedToken = jwt.verify(auth.substring(7), process.env.JWT_SECRET)
  return User.findById(decodedToken.id).populate('friends')
}


const startServer = (port) => {
  const server = new ApolloServer({
    typeDefs,
    resolvers,
  })

  startStandaloneServer(server, {
    listen: { port },

    context: async ({ req }) => {
      const auth = req.headers.authorization
      const currentUser = await getUserFromAuthHeader(auth)
      return { currentUser }
    },

  }).then(({ url }) => {
    console.log(`Server ready at ${url}`)
  })
}

module.exports = startServer

إذاً تستخرج الشيفرة التي عرّفناها أولاً الرمز الموجود في ترويسة Authorization الخاصة بالطلب. وتفكّ دالة المساعدة getUserFromAuthHeader ترميز الرمز وتبحث عن المستخدم المقابل في قاعدة البيانات. وإذا لم يكن الرمز صالحاً أو تعذّر العثور على المستخدم، تُعيد الدالة null.

وأخيراً، يُضبط حقل context المسمّى currentUser على كائن المستخدم المقابل للطالب، أو على null إذا لم يُعثر على مستخدم:

context: async ({ req }) => {
  const auth = req.headers.authorization
  const currentUser = await getUserFromAuthHeader(auth)
  return { currentUser }
},

تُمرَّر قيمة context إلى الـ resolvers كـ المعامل الثالث. وresolver الخاص باستعلام me بسيط جداً: فهو يُعيد فقط المستخدم المسجّل دخوله حالياً، الذي يحصل عليه من معامل resolver المسمّى context، من الحقل currentUser:

Query: {
  // ...
  me: (root, args, context) => {
    return context.currentUser
  }
},

إذا كانت الترويسة تحتوي على رمز صالح، يُعيد الاستعلام تفاصيل المستخدم الذي يحدده الرمز.

يعرض Apollo Studio كائن استجابة الاستعلام

قائمة الأصدقاء

لنكمل الواجهة الخلفية للتطبيق بحيث تتطلب إضافة الأشخاص وتعديلهم تسجيل الدخول، وتُضاف الأشخاص المضافون تلقائياً إلى قائمة أصدقاء المستخدم.

لنزل أولاً من قاعدة البيانات كل الأشخاص غير الموجودين في قائمة أصدقاء أي مستخدم.

تتغيّر mutation الـaddPerson هكذا:

Mutation: {

  addPerson: async (root, args, context) => {
    const currentUser = context.currentUser

    if (!currentUser) {
      throw new GraphQLError('not authenticated', {
        extensions: {
          code: 'UNAUTHENTICATED',
        }
      })
    }


    const nameExists = await Person.exists({ name: args.name })

    if (nameExists) {
      throw new GraphQLError(`Name must be unique: ${args.name}`, {
        extensions: {
          code: 'BAD_USER_INPUT',
          invalidArgs: args.name,
        },
      })
    }

    const person = new Person({ ...args })

    try {
      await person.save()
      currentUser.friends = currentUser.friends.concat(person)
      await currentUser.save()
    } catch (error) {
      throw new GraphQLError(`Saving person failed: ${error.message}`, {
        extensions: {
          code: 'BAD_USER_INPUT',
          invalidArgs: args.name,
          error
        }
      })
    }

    return person
  },
  //...
}

إذا تعذّر العثور على مستخدم مسجّل دخوله في context، يُرمى GraphQLError برسالة مناسبة. ويتم الآن إنشاء الأشخاص الجدد بصيغة async/await لأنه إذا نجحت العملية، يُضاف الشخص المنشأ إلى قائمة أصدقاء المستخدم.

لنضف أيضاً إمكانية إضافة شخص إلى قائمة أصدقائك. مخطط الـ mutation كما يلي:

type Mutation {
  // ...
  addAsFriend(name: String!): User
}

وresolver الخاص بالـ mutation:

  addAsFriend: async (root, args, { currentUser }) => {
    if (!currentUser) {
      throw new GraphQLError('not authenticated', {
        extensions: { code: 'UNAUTHENTICATED' },
      })
    }

    const nonFriendAlready = (person) =>
      !currentUser.friends
        .map((f) => f._id.toString())
        .includes(person._id.toString())

    const person = await Person.findOne({ name: args.name })

    if (!person) {
      throw new GraphQLError("The name didn't found", {
        extensions: {
          code: 'BAD_USER_INPUT',
          invalidArgs: args.name,
        },
      })
    }

    if (nonFriendAlready(person)) {
      currentUser.friends = currentUser.friends.concat(person)
    }

    await currentUser.save()

    return currentUser
  },

لاحظ كيف يستخرج resolver المستخدم المسجّل دخوله من context عبر التفكيك. فبدلاً من حفظ currentUser في متغير منفصل داخل دالة

addAsFriend: async (root, args, context) => {
  const currentUser = context.currentUser

يُستقبل مباشرة في تعريف معاملات الدالة:

addAsFriend: async (root, args, { currentUser }) => {

يُعيد الاستعلام التالي الآن قائمة أصدقاء المستخدم:

query {
  me {
    username
    friends{
      name
      phone
    }
  }
}

يمكن العثور على شيفرة الواجهة الخلفية على Github الفرع part8-5.

13. قاعدة البيانات، الجزء 1

14. قاعدة البيانات، الجزء 2

15. قاعدة البيانات، الجزء 3

16. المستخدم وتسجيل الدخول

17. مراجعة