اذهب إلى المحتوى

نموذج عن بناء قواعد البيانات وإدارة المستخدمين في تطبيق ويب


ابراهيم الخضور

سنضيف في هذا المقال آليةً لإدارة المستخدمين في تطبيقنا، لكن دعونا أولًا نستخدم قاعدة بيانات لتخزين بيانات التطبيق.

استخدام المكتبة Mongoose مع المكتبة Apollo

ثبِّت المكتبتين Mongoose، وMongoose-unique-validator كالتالي:

npm install mongoose mongoose-unique-validator

سنقلّد ما فعلناه في القسمين 3، و4.

لقد عرّفنا سابقًا تخطيط الأشخاص كالتالي:

const mongoose = require('mongoose')

const schema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    unique: 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)

لقد أضفنا أيضًا عدة مقيّمات validator، وهي required:true التي تتحقق من أنّ القيمة موجودة، وطبعًا لا حاجة فعلية لهذا المقيّم لأن GraphQL تتأكد من وجود الحقل تلقائيًا. لكن بالطبع من الجيد وجود مقيّمات في قاعدة البيانات.

يمكن تشغيل التطبيق ليعمل عمومًا، بتنفيذ التعديلات التالية:

const { ApolloServer, UserInputError, gql } = require('apollo-server')
const mongoose = require('mongoose')
const Person = require('./models/person')

const MONGODB_URI = 'mongodb+srv://fullstack:halfstack@cluster0-ostce.mongodb.net/graphql?retryWrites=true'

console.log('connecting to', MONGODB_URI)

mongoose.connect(MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false, useCreateIndex: true })
  .then(() => {
    console.log('connected to MongoDB')
  })
  .catch((error) => {
    console.log('error connection to MongoDB:', error.message)
  })

const typeDefs = gql`
  ...
`
const resolvers = {
  Query: {
    personCount: () => Person.collection.countDocuments(),
    allPersons: (root, args) => {
      // filters missing
      return Person.find({})
    },
    findPerson: (root, args) => Person.findOne({ name: args.name })
  },
  Person: {
    address: root => {
      return {
        street: root.street,
        city: root.city
      }
    }
  },
  Mutation: {
    addPerson: (root, args) => {
      const person = new Person({ ...args })
      return person.save()
    },
    editNumber: async (root, args) => {
      const person = await Person.findOne({ name: args.name })
      person.phone = args.phone
      return person.save()
    }
  }
}

تُعَدّ التعديلات التي أجريت واضحة تمامًا. لكن سنشير إلى عدة نقاط تستحق الوقوف عندها. كما نتذكر، فإنّ الحقل المُعرِّف لكائن في Mongo يدعى "id_". ولقد كان علينا سابقًا تحويل اسم الحقل إلى المعرّف id بأنفسنا، لكن ستنفّذ لنا المكتبة GraphQL الآن هذا الأمر تلقائيًا؛ أما الملاحظة الأخرى الجديرة بالاهتمام هي أنّ دوال المحللات ستعيد وعودًا promises، وقد كانت تعيد سابقًا كائنات. فعندما يعيد المحلل وعدًا، سيُعيد خادم Apollo القيمة التي يشير إليها الوعد.

فلو نُفِّذت على سبيل المثال دالة المحلل التالية:

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

سينتظر خادم Apollo حتى تجري معالجة الوعد، ومن ثم يعيد النتيجة. إذًا فالمكتبة Apollo تعمل تقريبيًا كالتالي:

Person.find({}).then( result => {
  // يعيد النتيجة 
})

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

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

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

فلو لم يمتلك الاستعلام المعامل phone، ستعيد الاستجابة كل الأشخاص. أما إن امتلك المعامل القيمة YES، ستعيد نتيجة الاستعلام الكائنات التي يمتلك فيها الحقل phone قيمة.

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

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

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

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

كما هي الحال في GraphQL، سيتم تقييم صحة البيانات المدخلة باستخدام المقيّمات المعرّفة ضمن تخطيط Mongoose. وللتعامل مع الأخطاء الناتجة عن تقييم البيانات في التخطيط، لا بد من إضافة معالجات أخطاء على شكل كتل try/catch إلى التابع save. وفي حال وصل تنفيذ الشيفرة إلى الجزء catch، سنظهر عندها الاستثناء المناسب.

Mutation: {
  addPerson: async (root, args) => {
      const person = new Person({ ...args })

      try {
        await person.save()
      } catch (error) {
        throw new UserInputError(error.message, {
          invalidArgs: args,
        })
      }
      return person
  },
    editNumber: async (root, args) => {
      const person = await Person.findOne({ name: args.name })
      person.phone = args.phone

      try {
        await person.save()
      } catch (error) {
        throw new UserInputError(error.message, {
          invalidArgs: args,
        })
      }
      return person
    }
}

ستجد شيفرة الواجهة الخلفية ضمن الفرع part8-4 في المستودع المخصص للتطبيق على Github.

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

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

سيكون التخطيط الخاص بالمستخدِم على النحو التالي:

const mongoose = require('mongoose')

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

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

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

أما عن الآلية التي تتعامل مع تسجيل الدخول والتحقق من المستخدمين فهي نفسها التي اعتمدناها في القسم 4 عندما استخدمنا REST وشهادات التحقق.

لنوسّع الآن التخطيط ليصبح كالتالي:

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 معلومات المستخدم الذي سجّل دخوله. وتجري إضافة مستخدمين جدد باستخدام الطفرة createUser، كما يجري تسجيل الدخول باستخدام الطفرة login.

تمثل الشيفرة التالية شيفرة المحللات للطفرات التي سنستخدمها:

const jwt = require('jsonwebtoken')

const JWT_SECRET = 'NEED_HERE_A_SECRET_KEY'

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

    return user.save()
      .catch(error => {
        throw new UserInputError(error.message, {
          invalidArgs: args,
        })
      })
  },
  login: async (root, args) => {
    const user = await User.findOne({ username: args.username })

    if ( !user || args.password !== 'secred' ) {
      throw new UserInputError("wrong credentials")
    }

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

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

تُنفّذ الطفرة التي تسجّل مستخدِم جديد بطريقة مباشرة. حيث تتحقق طفرة تسجيل الدخول إن كان الزوج اسم مستخدم/كلمة مرور صالحين، فإن كان كذلك ستعيد شهادة تحقق من النوع "JSON-WEB-TOKEN" والتي تعاملنا معها في القسم 4.

وتتكرر الفكرة السابقة عندما استخدمنا REST. حيث يحصل المستخدم الذي سجل دخوله على شهادة تحقق يضيفها إلى جميع الطلبات التي سيرسلها إلى الخادم. ستضاف أيضًا شهادة التحقق إلى استعلامات GraphQL باستخدام الترويسة Authorization.

تُضاف الترويسة في أرضية عمل GraphQL إلى الاستعلام كالتالي:

add_auth_header_grapql_01.png

لنوسّع الآن تعريف الكائنserver بإضافة معامل ثالث هو context العائد لدوال المحللات إلى استدعاء الدالة البانية:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {
      const auth = req ? req.headers.authorization : null
      if (auth && auth.toLowerCase().startsWith('bearer ')) {
          const decodedToken = jwt.verify(
              auth.substring(7), JWT_SECRET
          )
          const currentUser = await User.findById(decodedToken.id).populate('friends')
          return { currentUser }
      }
  }})

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

يَعُدّ محلل الطفرة me بسيطًا، فهو يعيد فقط المستخدِم الذي سجل دخوله والذي يحصل عليه من الحقل currentUser للمعامل context لدالة المحلل. لكن إن لم يسجّل أي مستخدم دخوله، أي لا وجود لشهادة تحقق صحيحة مرفقة مع الطلب، سيعيد الاستعلام القيمة null:

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

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

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

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

Mutation: {
  addPerson: async (root, args, context) => {
    const person = new Person({ ...args })
    const currentUser = context.currentUser

    if (!currentUser) {
      throw new AuthenticationError("not authenticated")
    }

    try {
      await person.save()
      currentUser.friends = currentUser.friends.concat(person)
      await currentUser.save()
    } catch (error) {
      throw new UserInputError(error.message, {
        invalidArgs: args,
      })
    }

    return person
  },
  //...
}

إن لم نستطع الحصول على تفاصيل المستخدم الذي سجل دخوله من خلال المعامل context، سيُرمى استثناء.

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

لنضف آلية لإضافة مستخدمين موجودين إلى قائمة الأصدقاء. ستكون الطفرة كالتالي:

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

وستكون دالة المحلل الخاص بالطفرة كالتالي:

  addAsFriend: async (root, args, { currentUser }) => {
    const nonFriendAlready = (person) => 
      !currentUser.friends.map(f => f._id).includes(person._id)

    if (!currentUser) {
      throw new AuthenticationError("not authenticated")
    }

    const person = await Person.findOne({ name: 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 }) => {

ستجد شيفرة الواجهة الخلفية ضمن الفرع part8-5 في المستودع المخصص للتطبيق على Github.

التمارين

1. قاعدة بيانات: القسم الأول

عدّل تطبيق المكتبة بحيث يحفظ البيانات ضمن قاعدة بيانات. يمكن أن تجد تخطيط Mongoose للكتب ولمؤلفين على Github.

لنغير تخطيط GraphQL للكتب قليلًا:

type Book {
  title: String!
  published: Int!
  author: Author!
  genres: [String!]!
  id: ID!
}

لكي يحتوي كائن الكتاب على كل تفاصيل المؤلف وليس الاسم فقط.

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

لا تهتم بالأمور التالية حاليًا:

  • الاستعلام allBooks مستخدمًا المعاملات.
  • الحقل bookCount من الكائن author.
  • الحقل author من الكتاب.
  • الطفرة editAuthor.

2. قاعدة بيانات: القسم الثاني

أكمل تطبيقك بحيث تعمل كل الاستعلامات والطفرات بطريقة صحيحة، ماعدا allBooks مع المعامل author.

يمكنك الاستعانة بالمعلومات الواردة في توثيق Mongoose.

3. قاعدة بيانات: القسم الثالث

أكمل التطبيق بحيث يصبح قادرًا على معالجة أخطاء التقييم (كتاب بعنوان قصير جدًا أو اسم مؤلف مثلًا) بطريقة منطقية. ويعني ذلك أن تسبب هذه الأخطاء الاستثناء UserInputError الذي يُرمى مع رسالة خطأ.

4. تسجيل دخول المستخدمين

أضف آلية لإدارة المستخدمين إلى التطبيق. وسّع التخطيط ليصبح كالتالي:

type User {
  username: String!
  favoriteGenre: String!
  id: ID!
}

type Token {
  value: String!
}

type Query {
  // ..
  me: User
}

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

أنشئ محللاتٍ للاستعلام me وللطفرتين الجديدتين createUser وlogin. يمكنك أيضًا اعتبار أنّ لكل المستخدمين نفس كلمة السر التي تُكتب مسبقًا ضمن الشيفرة.

إجعل الطفرتين addBook وeditauthor قابلتين للتنفيذ إذا أرفق الاستعلام بشهادة تحقق صالحة فقط.

ترجمة -وبتصرف- للفصل DataBase and user administration من سلسلة Deep Dive Into Modern Web Development


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...