تکمیل کلاس DefaultController

پیش از تکمیل کنترلر DefaultController، مجدد نگاهی به ساختار کلاس Routing می‌اندازیم که در آن اِندپوینت‌های معتبر در این RESTful API را مشخص کرده‌ایم:

<?php
namespace Core;

class Routing
{
    public $routes = [
        [
            'route' => 'api/v1/articles',
            'module' => 'Api',
            'controller' => 'DefaultController',
            'action' => 'index',
        ],
        [
            'route' => 'api/v1/signup',
            'module' => 'Api',
            'controller' => 'UserController',
            'action' => 'signup',
        ],
        [
            'route' => 'api/v1/auth',
            'module' => 'Api',
            'controller' => 'UserController',
            'action' => 'auth',
        ],
    ];

    public function __construct()
    {
        return $this->routes;
    }
}

همان‌طور که می‌بینیم، اِندپوینت اول این وظیفه را دارا است تا هر زمانی که کلاینت وارد یوآر‌الِ api/v1/articles شد، اَکشن index قرار گرفته داخل DefaultController اجرا گردد. حال ممکن است این پرسش ایجاد گردد که «چگونه سیستم متوجه خواهد شد که متد انتخابی مثلاً POST ،GET یا غیره است؟» که در پاسخ به این پرسش باید گفت که داخل اَکشن index با استفاده از ساختار switch این قضیه را هندل کرده‌ایم که بر اساس نوع متد، تَسک مربوطه را عملی سازد.

ابتدا متدی تحت عنوان ()index داخل کلاس DefaultController به صورت زیر می‌نویسیم:

public function index($id = null)
{
    header("Access-Control-Allow-Origin: *");
    header("Content-Type: application/json; charset=UTF-8");
    header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE");
    header("Access-Control-Allow-Headers: Authorization");

    switch ($this->requestMethod) {
        case 'GET':
            if (isset($id) && is_numeric($id)) {
                $this->getArticleById($id);
            } else {
                $this->getAllArticles();
            };
            break;
        case 'POST':
            $this->createNewArticle();
            break;
        case 'PUT':
            if (isset($id) && is_numeric($id)) {
                $this->updateArticleById($id);
            } else {
                $this->throwError(406, 'The ID parameter is required.');
            }
            break;
        case 'DELETE':
            if (isset($id) && is_numeric($id)) {
                $this->deleteArticleById($id);
            } else {
                $this->throwError(406, 'The ID parameter is required.');
            }
            break;
        default:
            break;
    }
}

همان‌طور که می‌بینیم، اَکشن ()index یک پارامتر ورودی می‌گیرد که به صورت پیش‌فرض null است سپس داخل بدنهٔ این متد چهار هِدِر سِت کرده‌ایم که عبارتند از:

- هِدِر Access-Control-Allow-Origin که مشخص می‌سازد چه نوع کلاینت‌هایی می‌توانند به ریسپانس ارسالی از سمت سرور دسترسی یابند و مقدار * نیز حاکی از آن است که هر سیستمی می‌تواند به ریسورس‌ها دسترسی داشته باشد.

- هِدِر Content-Type در ریسپانس تایپ (نوع) ریسورس ارسالی از سمت سرور را برای کلاینت مشخص می‌سازد که در این مثال application/json نشانگر آن است که نوع محتوا جیسون است (دستور charset=UTF-8 نیز اِنکودینگ محتوا را مشخص می‌سازد.)

- هِدِر Access-Control-Allow-Methods مشخص می‌کند که کدام متدهای اچ‌تی‌تی‌پی می‌توانند به یک ریسورس خاص دست یابند و در این اَکشن گفته‌ایم که چنین امکانی برای متدهای PUT ،POST ،GET و DELETE فراهم است. 

- هِدِر Access-Control-Allow-Headers در ریسپانس تعیین می‌کند در تبادل دیتا مابین کلاینت و سرور کدام دسته از هِدِرها می‌توانند مورد استفاده قرار گیرند و این در حالی است که برخی هِدِرهای رایج مانند Content-Type یا Accept به صورت پیش‌فرض فعال هستند. در مثال فوق دستور داده‌ایم تا امکان استفاده از هِدِر تحت عنوان Authorization ایجاد گردد.

داخل اَکشن ()index از یک دستور switch استفاده کرده‌ایم که بر اساس پارامتر ورودی‌اش که پراپرتی this->requestMethod$ بوده و حاوی نوع متد ارسالی از سمت کلاینت است، مشخص می‌سازد برای هر متد خاص چه تَسکی می‌باید اجرا گردد.

نکته
کلیهٔ متدهایی که داخل اَکشن ()index فراخوانی خواهند شد از جنس private هستند چرا که نیاز داریم تا فقط و فقط از داخل همین کلاس به آن‌ها دسترسی داشته باشیم.

در همین راستا، در کیس اول گفته‌ایم که اگر متد برابر با GET بود، با استفاده از یک دستور شرطی چک شود ببینیم که آیا پارامتر ورودی id$ سِت شده و مقدار آن یک عدد است یا خیر که اگر این‌ گونه بود، متد ()getArticleById فراخوانی خواهد شد و در غیر این صورت متد ()getAllArticles فراخوانی می‌گردد. در کیس دوم تست کرده‌ایم ببینیم که آیا متد ارسالی POST است یا خیر که اگر این گونه بود، متد ()createNewArticle فراخوانی خواهد. در کیس سوم متد PUT را چک کرده‌ایم که در صورت مَچ بودن این کیس، متد ()updateArticleById کال (فراخوانی) می‌گردد و در نهایت به کیس آخر می‌رسیم که مقدار DELETE را می‌سنجد که اگر این گونه بود، متد ()deleteArticleById کال خواهد شد و چنانچه هیچ کدام از کیس‌های فوق اجرا نشوند، وارد بلوک default شده که اساساً هیچ اتفاقی داخل آن رخ نخواهد داد.

بررسی متد ()getArticleById

این متد یک پارامتر ورودی تحت عنوان id$ از جنس عدد صحیح می‌گیرد تا بر اساس آن به جدول articles کوئری زده و دیتای مرتبط با مقاله‌ای با آن شناسه را بازگرداند به طوری که داریم:

private function getArticleById(int $id)
{
    $articleModel = new Article((new Database())->connect());
    $result = $articleModel->readById($id);
    $result = $result->fetchAll(\PDO::FETCH_ASSOC);
    if ($result) {
        $this->returnResponse(200, $result);
    } else {
        $this->returnResponse(200, "No article found with id of $id");
    }
}

همان‌طور که ملاحظه می‌شود، ابتدا به ساکن آبجکتی تحت عنوان articleModel$ از روی کلاس Article ساخته و به عنوان پارامتر ورودی کانستراکتور این کلاس، آبجکتی از روی کلاس Database ساخته و متد ()connect آن را فراخوانی نموده‌ایم تا از این پس مُدل Article برای کوئری زدن از یک کانکشن با دیتابیس برخوردار باشد. سپس متغیری ساخته‌ایم تحت عنوان result$ و مقدار آن را برابر با فراخوانی متد ()readById روی آبجکت articleModel$ قرار داده‌ایم و همان‌طور که ملاحظه می‌شود، پارامتر ورودی متد ()getArticleById که id$ است را مجدد به متد ()readById پاس داده‌ایم و در ادامه متد ()fetchAll از کلاس PDO را روی متغیر result$ فراخوانی کرده و نتیجه را مجدد به همین متغیر اختصاص داده‌ایم (پارامتر ورودی PDO::FETCH_ASSOC\ این تضمین را ایجاد می‌کند که آرایه‌ای از جنس Associative بازگردانده شود.) در ادامه، با استفاده از یک دستور شرطی سنجیده‌ایم ببینیم که آیا متغیر result$ حاوی مقداری می‌باشد یا خیر که اگر این گونه بود، متد ()returnResponse را فراخوانی کرده و کد وضعیت 200 را به عنوان آرگومان اول و متغیر result$ که حاوی دیتای مقاله‌ای با شناسهٔ مرتبط با id$ است را به عنوان آرگومان دوم آن در نظر می‌گیریم. برای مثال داریم:

همان‌طور که ملاحظه می‌شود، اِندپوینتی همچون api/v1/articles/1 را در نظر گرفته و متد انتخابی نیز GET است و با کلیک روی دکمهٔ ارسال و یا فشردن دکمهٔ اینتر، خروجی فوق در معرض دید‌مان قرار خواهد گرفت. حال اگر شناسه‌ای همچون 100 که در دیتابیس وجود خارجی ندارد را وارد کنیم، وارد دستور else شده و خروجی جیسون زیر ریترن خواهد شد:

{
    "response": {
        "code": "200 OK",
        "message": "No article found with id of 100"
    }
}

می‌بینیم پیامی حاکی از آن که «مقاله‌ای با شناسهٔ 100 یافت نشد.» برای کلاینت ارسال شده است.

هشدار
نکتهٔ مهم در ارتباط با خروجی جیسونی که در تصویر مشاهده می‌شود آن است که نتیجه در قالب یک Associative Array در اختیار کلاینت قرار گرفته است که کلیدهای این آرایه همان فیلدهای جدول هستند و این مسئله اِسکمای دیتابیس را در دسترس کاربرانی با نیت سوء قرار می‌دهد!

با مد نظر قرار دادن هشداری که در بالا دادیم، نیاز است تا خروجی به شکلی در دسترس کلاینت قرار گیرد که کمترین اطلاعات موجود از ساختار دیتابیس فاش گردد. برای همین منظور، متد ()readById را به صورت زیر ریفتکور می‌کنیم:

public function readById(int $id)
{
    if (is_numeric($id) && $id > 0) {
        $query = "
            SELECT
                $this->articlesTable.id as articleId,
                title as articleTitle,
                content as articleBody,
                created_at as time,
                category_name as catName,
                first_name as authorFirstName,
                last_name as authorLastName
            FROM
                $this->articlesTable
            LEFT JOIN categories ON
                $this->articlesTable.category_id = categories.id
            LEFT JOIN users ON
                $this->articlesTable.user_id = users.id
            WHERE
                $this->articlesTable.id = ?
        ";
        $statement = $this->connection->prepare($query);
        $statement->execute([$id]);
        return $statement;
    }
}

از کیورد as که پیش از این با کاربردش آشنا شدیم برای تک‌تک ستون‌های جدول استفاده کرده تا نامی دلخواه و در عین حال غیریکسان با فیلدهای جدول مربوطه انتخاب کرده‌ایم. اگر مجدد اِندپوینت فوق را اجرا کنیم، در خروجی خواهیم داشت:

{
    "response": {
        "code": "200 OK",
        "message": [
            {
                "articleId": "1",
                "articleTitle": "What Is Linux?",
                "articleBody": "DescriptionLinux is a family of open source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991 by Linus Torvalds. Linux is typically packaged in a Linux distribution.",
                "time": "2019-05-18 09:51:41",
                "catName": "Linux",
                "authorFirstName": "Behzad",
                "authorLastName": "Moradi"
            }
        ]
    }
}

در حقیقت، این کار یک لایهٔ امنیتی به وب سرویس اضافه می‌کند تا بدین طریق حدس زدن نام دقیق فیلد‌های جداول دیتابیس دشوارتر گردد.

بررسی متد ()getAllArticles

سازوکار این متد نیز مشابه ()getArticleById است با این تفاوت که در این متد قصد داریم تا کلیهٔ مقالات را از دیتابیس فراخوانی کنیم که برای همین منظور، از متدی تحت عنوان ()read استفاده نموده‌ایم به طوری که داریم:

private function getAllArticles()
{
    $articleModel = new Article((new Database())->connect());
    $result = $articleModel->read();
    $result = $result->fetchAll(\PDO::FETCH_ASSOC);
    if ($result) {
        $this->returnResponse(200, $result);
    } else {
        $this->returnResponse(200, 'No articles found.');
    }
}

جهت تست، اِندپوینت api/v1/articles را داخل نرم‌افزار Postman وارد کرده و روی دکمهٔ ارسال کلیک می‌کنیم:

در کل دو مقاله تاکنون به ثبت رسیده است که می‌بینیم هر دوی آن‌ها در دسترس کلاینت قرار گرفته‌اند. می‌بینیم که کماکان این متد اِسکمای جدول را فاش ساخته است که برای رفع این مشکل، متد ()read موجود در مُدل Article را نیز به صورت زیر تغییر می‌دهیم:

public function read()
{
    $query = "
        SELECT
            $this->articlesTable.id as articleId,
            title as articleTitle,
            content as articleBody,
            created_at as time,
            category_name as catName,
            first_name as authorFirstName,
            last_name as authorLastName
        FROM
            $this->articlesTable
        LEFT JOIN categories ON
            $this->articlesTable.category_id = categories.id
        LEFT JOIN users ON
            $this->articlesTable.user_id = users.id;
    ";
    $statement = $this->connection->prepare($query);
    $statement->execute();
    return $statement;
}

اکنون اگر مجدد کوئری فوق را اجرا کنیم، در خروجی خواهیم داشت:

{
    "response": {
        "code": "200 OK",
        "message": [
            {
                "articleId": "1",
                "articleTitle": "What Is Linux?",
                "articleBody": "DescriptionLinux is a family of open source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991 by Linus Torvalds. Linux is typically packaged in a Linux distribution.",
                "time": "2019-05-18 09:51:41",
                "catName": "Linux",
                "authorFirstName": "Behzad",
                "authorLastName": "Moradi"
            },
            {
                "articleId": "2",
                "articleTitle": "What Is PHP?",
                "articleBody": "DescriptionPHP: Hypertext Preprocessor is a general-purpose programming language originally designed for web development. It was originally created by Rasmus Lerdorf in 1994; the PHP reference implementation is now produced by The PHP Group.",
                "time": "2019-05-18 09:51:41",
                "catName": "PHP",
                "authorFirstName": "Behzad",
                "authorLastName": "Moradi"
            }
        ]
    }
}

حال فرض کنیم که صدها مقاله در جدول articles ثبت شده باشد و این در حالی است که با رفتن به این اِندپوینت یک کوئری سنگین به دیتابیس خواهد خورد تا کلیهٔ مقالات را در دسترس کلاینت قرار دهد و اینجا است که می‌باید با مفهومی تحت عنوان Pagination آشنا بود که در همین راستا توصیه می‌کنیم به مقالهٔ آشنایی با اصول نام‌گذاری صحیح ریسورس‌ها در معماری RESTful API مراجعه نمایید.

فرض کنیم که کلیهٔ تَسک‌های مرتبط با متد GET یا به عبارتی فِچ کردن مقالات از دیتابیس نیاز به لاگین بودن در سیستم ندارند و از همین روی هر کلاینتی می‌تواند به هر شکلی که بخواهد با استفاده از اِندپوینت‌هایی که برای این منظور در نظر گرفته شده‌اند به مقالات دسترسی داشته باشد.

هشدار
در عین حال توجه داشته باشیم که در حال حاضر این وب سرویس می‌تواند در معرض حملاتی از جنس DoS قرار گیرد بدین شکل که بیش از حد توان سرور کوئری‌های سنگین برای این سرویس از جانب کلاینت‌های مختلف ارسال گردد تا جایی که سرور با کمبود منابع مواجه شده و از دسترس خارج گردد.

در ارتباط با تَسک‌های افزودن یک مقالهٔ جدید، ویرایش و حذف مقالات نیاز داریم تا با هویت کاربر آشنا باشیم و از همین روی نیاز داریم تا از متد ()auth که در UserController نوشتیم برای این منظور استفاده نماییم که در ادامهٔ این آموزش خواهیم دید که به چه شکل می‌توان این کار را انجام داد. 

بررسی متد ()createNewArticle

همان‌طور که داخل این متد می‌بینیم، از دستور try و catch استفاده نموده‌ایم چرا که قصد فراخوانی کلاس JWT را داریم و پیش از این هم گفتیم با توجه به اینکه ممکن است در حین فراخوانی این کلاس با اِسکپشنی مواجه شویم، می‌باید کدهای اصلی را داخل بلوک try قرار داده و چنانچه هیچ مشکلی رخ ندهد، دستورات داخل این بلوک اجرا می‌شوند و در غیر این صورت وارد بلوک catch شده و متدی تحت عنوان ()getMessage که مرتبط با کلاس Exception بوده و حاوی ماهیت ارور است را چاپ می‌کنیم:

private function createNewArticle()
{
    try {
        $this->validateRequest();

        $this->validateParams('articleTitle', $this->request['request_params']['articleTitle'], true);
        $this->validateParams('articleContent', $this->request['request_params']['articleContent'], true);
        $this->validateParams('catId', $this->request['request_params']['catId'], true);

        $token = $this->getBearerToken();
        $payload = JWT::decode($token, parent::JWT_SECRET_KEY, ['HS256']);

        $articleModel = new Article((new Database())->connect());
        $this->request['request_params']['usrId'] = $payload->userId;
        $isInserted = $articleModel->create($this->request['request_params']);

        if ($isInserted) {
            $this->returnResponse(201, 'New article added.');
        } else {
            $this->returnResponse(501, 'Unable to add a new article.');
        }
    } catch (\Exception $e) {
        echo $e->getMessage();
    }
}

داخل بلوک try متدی را فراخوانی نموده‌ایم تحت عنوان ()getBearerToken که هنوز داخل BaseController ساخته نشده است که در ابتدا می‌باید این متد را نوشته، آن را تفسیر کنیم و در ادامه مجدد به بررسی متد ()createNewArticle برمی‌گردیم. برای این منظور، در انتهای کلاس BaseController متدی تحت عنوان ()getBearerToken به صورت زیر می‌نویسیم:

public function getBearerToken()
{
    $headers = apache_request_headers();
    if (isset($headers['Authorization'])) {
        if (preg_match('/Bearer\s(\S+)/', $headers['Authorization'], $matches)) {
            return $matches[1];
        }
    }
    $this->throwError(404, 'Token is not found');
}

گفتیم که به منظور ایجاد یک مقالهٔ‌ جدید و یا آپدیت و حذف مقالات قدیمی نیاز به این داریم تا در وب سرویس لاگین باشیم و یکی از راه‌کارهایی که از آن طریق می‌توان در معماری رِست هویت کاربر را مابین سرور و کلاینت ردوبدل کرد، هِدِری است تحت عنوان Authorization و اگر توجه کرده باشیم، داخل متد ()index نیز از دستور زیر استفاده کردیم:

header("Access-Control-Allow-Headers: Authorization");

گفتیم که Access-Control-Allow-Headers هِدِرهایی که مجاز به استفاده از آن‌ها خواهیم بود را مشخص می‌سازد و از آنجا که نیاز به استفاده از Authorization داریم، به وب سرور دستور داده‌ایم که استفاده از این هِدِر مجاز است.

در زبان برنامه‌نویسی پی‌اچ‌پی متدی تعبیه شده تحت عنوان ()apache_request_headers که دربرگیرندهٔ‌ کلیهٔ هِدِرهایی است که سِت شده‌اند. برای همین منظور، متغیری ساخته‌ایم تحت عنوان headers$ و این متد را به آن اختصاص داده‌ سپس با اولین دستور شرطی چک کرده‌ایم ببینیم که آیا داخل متغیر headers$ کلید Authorization سِت شده است یا خیر؛ اگر این گونه بود،‌ وارد دومین دستور شرطی می‌شویم و در غیر این صورت با فراخوانی متد ()throwError و انتخاب کد وضعیتی همچون 404، پیامی با این مضمون که «توکن یافت نشد.» در دسترس کلاینت قرار می‌دهیم.

گفتیم که اگر متغیر headers$ داری کلید Authorization باشد، وارد دومین دستور شرطی می‌شویم و همان‌طور که می‌بینیم، با استفاده از رِگولار اِسکپرشن دستور داده‌ایم که تابع ()preg_match داخل ['headers['Authorization$ بگردد و هر استرینگی که پس از Bearer قرار داشت را داخل آرایه‌ای به نامی دلخواه همچون matches$ بریزد سپس اِلِمان یکم این آرایه را ریترن کرده‌ایم. در واقع، آنچه قاعداً در این خانه از آرایهٔ matches$ قرار می‌گیرد توکنی است که در حین ارسال ریکوئست در نظر خواهیم گرفت.

در این مرحله از آموزش نیاز است تا با مفهومی تحت عنوان Bearer Authentication آشنا شویم که یکی از ویژگی‌های پروتکل HTTP است که از آن طریق می‌توان با استفاده از ارسال توکن در بخش هِدِر، به سرور این پیام را داد که کلاینت مذکور معتبر بوده و اجازهٔ دسترسی به برخی اِندپوینت‌های خاص را دارا است به طوری که ساختار کلی و نحوهٔ استفاده از این هِدِر به صورت زیر است:

Authorization: Bearer <token>

به عبارتی، نام هِدِر Authorization است و مقدار در نظر گرفته شده برای آن استرینگ Bearer به علاوه یک توکن است. واژهٔ‌ انگلیسی «Bear» به عنوان اسم به معنی «خرس» است اما به عنوان فعل به معنی «حمل کردن» است و بالتبع برای اسم فاعلِ «Bearer» می‌توان معادلی همچون «حامل» یا «حمل‌کننده» در نظر گرفت. با این توضیحات، <Bearer <token را می‌توان این گونه تفسر نمود که «به حامل این توکن اجازهٔ دسترسی داده شود.» و لازم به یادآوری است که به دلیل مسائل امنیتی، از این هِدِر صرفاً می‌باید در پروتکل HTTPS استفاده نمود مضاف بر اینکه درج کیورد Bearer الزامی بوده و مشخص‌کنندهٔ نوع Authorization است.

نقطهٔ مقابل Bearer Authentication، ساختاری تحت عنوان Basic Authentication است که از آن طریق کلاینت هِدِر Authorization را به صورت زیر ارسال می‌کند:

Authorization: Basic ZGVtbzpwQDU1dzByZA

می‌بینیم که ابتدا کیورد Basic نوشته شده تا نوع درخواست مشخص گردد سپس یک اِسپیس قرار گرفته و استرینگی به صورت username:password که اِنکود شده باشد پس از آن قرار می‌گیرد (این استرینگ می‌باید با فرمت Base64 اِنکود گردد.) همچنین لازم به یادآوری است که Basic Authentication نیز می‌باید صرفاً با استفاده از HTTPS ارسال گردد تا امنیت آن افزایش یابد.

با ذکر این توضیحات، حال مجدد می‌توانیم به تفسیر کدهای داخل متد ()createNewArticle بپردازیم. داخل بلوک try ابتدا دستور ()validateRequest را فراخوانی کرده‌ایم تا این اطمینان حاصل گردد که Content-Type برابر با application/json است سپس با فراخوانی متد ()validateParams تضمین کرده‌ایم که پارامترهایی همچون articleContent ،articleTitle و catId الزامی بوده و حتماً باید در ریکوئست ارسالی موجود باشند.

در ادامه، متغیری ساخته‌ایم تحت عنوان token$ و مقدار آن را برابر با متد ()getBearerToken قرار داده‌ایم که این وظیفه را دارا است تا از داخل هِدِرهای ارسالی، توکنی که در هِدِر Authorization قرار گرفته است را ریترن می‌کند. سپس متغیری دیگری ساخته‌ایم به نام payload$ که مقدار آن برابر با فراخوانی متد ()decode از کلاس JWT است که سه پارامتر ورودی می‌گیرد؛ پارامتر اول توکنی است که از سمت کلاینت ارسال شده، پارامتر دوم کلید سری است که پیش از این دیدیم چگونه آن را در قالب کانستنت JWT_SECRET_KEY ذخیره‌ سازیم و پارامتر سوم هم الگوریتم هَشینگ مورد استفاده است به طوری که یک خروجی فرضی از محتویات متغیر payload$ به صورت زیر خواهد بود:

object(stdClass)#6 (4) {
    ["iat"]=> int(1558166641)
    ["iss"]=> string(9) "localhost"
    ["exp"]=> int(1558170241)
    ["userId"]=> int(1)
}

می‌بینیم کلیهٔ مقادیری که داخل متد ()auth برای ساخت یک توکن در نظر گرفته بودیم در اختیارمان قرار دارند. در ادامه، یک آبجکت از روی مُدل Article ساخته و آن را در متغیری به نام articleModel$ ذخیره کرده‌ایم و نیاز به توضیح نیست که کلیهٔ مُدل‌ها برای کارکرد صحیح خود نیاز به آبجکتی از روی کلاس Database دارند و از همین روی متد ()connect این کلاس را فراخوانی کرده و به عنوان پارامتر ورودی کلاس Article در نظر گرفته‌ایم.

در حین ساخت توکن داخل متد ()auth دیدیم که شناسهٔ کاربر را به عنوان یکی از مقادیر Payload در نظر گرفتیم و نیاز به توضیح نیست که برای ثبت یک مقاله‌ٔ جدید، به شناسهٔ کاربر نیاز داریم. از همین روی، کلید جدیدی تحت عنوان usrId برای ['this->request['request_params$ ساخته و مقدار آن را برابر با payload->userId$ قرار می‌‌دهیم و در نهایت ['this->request['request_params$ را به عنوان پارامتر ورودی تابع ()create در نظر می‌گیریم و آن را در متغیری تحت عنوان isAdded$ ذخیره می‌سازیم. اگر مقدار این متغیر برابر با true بود، وارد بلوک if شده و با استفاده از متد ()returnResponse کد وضعیت 201 و پیامی با این مضمون که «مقالهٔ جدید افزوده شد.» برای کلاینت ارسال می‌کنیم و در غیر این صورت، وارد بلوک else شده و کد وضعیت + پیامی مرتبط مبنی عدم موفقیت در ثبت مقاله‌ای جدید در نظر می‌گیریم.

حال نیاز است تا قابلیت افزودن مقالهٔ‌ جدید را تست کنیم که برای همین منظور، وارد نرم‌افزار Postman شده و تنظیمات تصویر زیر را مد نظر قرار می‌دهیم:

همان‌طور که می‌بینیم، ابتدا وارد اِندپوینت api/v1/auth شده تا در وب سرویس لاگین نماییم که پس از کلیک بر روی دکمهٔ ارسال، می‌بینیم که توکنی در اختیارمان قرار می‌گیرد. حال می‌باید این توکن را کپی کرده و همان‌طور که در ادامه مشاهده می‌کنیم، به عنوان مقدار هِدِر Authorization در نظر بگیریم:

در توضیح پیرامون تصویر فوق می‌توان گفت که پس از انتخاب متد POST و وارد کردن اِندپوینت api/v1/articles، در تَب مربوط به Headers ابتدا Content-Type را با مقدار application/json در نظر گرفته سپس Authorization را با مقدار Bearer به همراه یک اِسپیس سپس توکنی که کپی کرده بودیم را وارد می‌نماییم. حال نیاز است تا در تَب مربوط به Body مقادیر مورد نیاز را وارد نماییم به طوری که داریم:

همان‌طور که می‌بینیم در تَب مربوط به Body جیسون زیر را وارد کرده‌ایم:

{
    "request_params": {
        "articleTitle": "New article title",
        "articleContent": "New article content",
        "catId": "2"
    }
}

که با کلیک بر روی دکمهٔ ارسال، در بخش خروجی جیسیون زیر در معرض دیدمان قرار خواهد گرفت:

{
    "response": {
        "code": "201 Created",
        "message": "New article added."
    }
}

اکنون اگر به دیتابیس مراجعه کنیم، خواهیم دید که رکورد جدیدی با شناسهٔ ۳ با مقادیر فوق‌الذکر ثبت شده است:

+----+---------+-------------+-------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------+
| id | user_id | category_id | title             | content                                                                                                                                                                                                                                           | created_at          |
+----+---------+-------------+-------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------+
|  1 |       1 |           1 | What Is Linux?    | DescriptionLinux is a family of open source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991 by Linus Torvalds. Linux is typically packaged in a Linux distribution.        | 2019-05-18 09:51:41 |
|  2 |       1 |           2 | What Is PHP?      | DescriptionPHP: Hypertext Preprocessor is a general-purpose programming language originally designed for web development. It was originally created by Rasmus Lerdorf in 1994; the PHP reference implementation is now produced by The PHP Group. | 2019-05-18 09:51:41 |
|  3 |       1 |           2 | New article title | New article content                                                                                                                                                                                                                               | 2019-05-18 12:42:52 |
+----+---------+-------------+-------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------+

آنچه در ارتباط با متد ()createNewArticle حائز اهمیت است اینکه توکن ارسالی از طرف کلاینت این تضمین را ایجاد می‌کند که کلاینت معتبر است اما در عین حال می‌توان یک لایهٔ امنیتی بیشتر نیز به این متد به صورت زیر اضافه نمود:

private function createNewArticle()
{
    try {
        $this->validateRequest();
            
        $this->validateParams('articleTitle', $this->request['request_params']['articleTitle'], true);
        $this->validateParams('articleContent', $this->request['request_params']['articleContent'], true);
        $this->validateParams('catId', $this->request['request_params']['catId'], true);

        $token = $this->getBearerToken();
        $payload = JWT::decode($token, parent::JWT_SECRET_KEY, ['HS256']);

        $user = new User((new Database())->connect());
        $result = $user->fetchUserById($payload->userId);
        $result = $result->fetchAll(\PDO::FETCH_ASSOC);
        if (!$result) {
            $this->throwError(401, 'No user found');
        }

        $articleModel = new Article((new Database())->connect());
        $this->request['request_params']['usrId'] = $payload->userId;
        $isInserted = $articleModel->create($this->request['request_params']);

        if ($isInserted) {
            $this->returnResponse(201, 'New article added.');
        } else {
            $this->returnResponse(501, 'Unable to add a new article.');
        }
    } catch (\Exception $e) {
        echo $e->getMessage();
    }
}

همان‌طور که می‌بینیم، آبجکتی از روی کلاس User ساخته تا در نهایت چک کنیم ببینیم که آیا payload->userId$ موجود در توکن دستکاری شده است یا خیر به طوری که نتیجه را در متغیری تحت عنوان result$ ریخته و چنانچه چنین کاربری وجود خارجی نداشت، با استفاده از متد ()throwError با کد وضعیتی همچون 401 و پیامی با این مضمون که «کاربر یافت نشد.» کلاینت را از غیرمعتبر بودن درخواست مطلع می‌سازیم و در غیر این صورت، طبق روال گذشته ادامهٔ دستورات اجرا خواهند شد.

بررسی متد ()updateArticleById

ساختار این متد تا حد بسیاری شبیه به ()createNewArticle است با این تفاوت که در آن متد ()update از مُدل Article فراخوانی شده است به طوری که داریم: 

private function updateArticleById(int $id)
{
    try {
        $this->validateRequest();

        $this->validateParams('articleTitle', $this->request['request_params']['articleTitle'], true);
        $this->validateParams('articleContent', $this->request['request_params']['articleContent'], true);
        $this->validateParams('catId', $this->request['request_params']['catId'], true);

        $token = $this->getBearerToken();
        $payload = JWT::decode($token, parent::JWT_SECRET_KEY, ['HS256']);

        $user = new User((new Database())->connect());
        $result = $user->fetchUserById($payload->userId);
        $result = $result->fetchAll(\PDO::FETCH_ASSOC);

        if (!$result) {
            $this->throwError(401, 'No user found');
        }

        $articleModel = new Article((new Database())->connect());
        $this->request['request_params']['usrId'] = $payload->userId;
        $isUpdated = $articleModel->update($id, $this->request['request_params']);

        if ($isUpdated) {
            $this->returnResponse(200, 'The article updated.');
        } else {
            $this->returnResponse(501, 'Unable to update the article.');
        }
    } catch (\Exception $e) {
        echo $e->getMessage();
    }
}

این متد یک پارامتر ورودی تحت عنوان id$ از جنس عدد صحیح می‌گیرد که به نوعی شناسهٔ مقاله‌ای است که قصد به‌روزرسانی آن را داریم و همچنین این شناسه را به عنوان پارامتر اول متد ()update در نظر گرفته و پارامتر دوم این متد نیز ['this->request['request_params$ می‌باشد که حاوی کلیهٔ داده‌های جدیدی است که می‌باید جایگزین مقادیر قبلی در دیتابیس شوند.

جهت تست، اِندپوینت api/v1/articles/1 را مد نظر قرار داده، متد PUT را در نرم‌افزار Postman انتخاب کرده، در تَب مربوط به Headers هِدِر Content-Type را برابر با application/json قرار داده و هِدِر Authorization را برابر با استرینگ Bearer به علاوهٔ یک اسپیس و توکنی که قبلاً به دست آوردیم کرده و در تَب مربوط به Body نیز همان‌طور که در تصویر زیر می‌بینیم، جیسونی حاوی کلیدهای مورد نیاز می‌سازیم:

می‌بینیم که در خروجی کد وضعیت و پیامی مرتبط با به‌روزرسانی مقاله ریترن شده است و چنانچه به جدول articles رجوع کنیم، می‌بینیم مقاله‌‌ای با شناسهٔ ۱ به صورت زیر آپدیت شده است:

+----+---------+-------------+---------------------------+-----------------------------+---------------------+
| id | user_id | category_id | title                     | content                     | created_at          |
+----+---------+-------------+---------------------------+-----------------------------+---------------------+
|  1 |       1 |           1 | The article title updated | The article content updated | 2019-05-18 09:51:41 |
+----+---------+-------------+---------------------------+-----------------------------+---------------------+

بررسی متد ()deleteArticleById

متدی که برای حذف یک مقاله نوشته‌ایم نیز تا حد بسیاری مشابه متد ()updateArticleById است با این تفاوت که متد ()delete مُدل Article در آن فراخوانی شده است:

private function deleteArticleById(int $id)
{
    try {
        $token = $this->getBearerToken();
        $payload = JWT::decode($token, parent::JWT_SECRET_KEY, ['HS256']);

        $user = new User((new Database())->connect());
        $result = $user->fetchUserById($payload->userId);
        $result = $result->fetchAll(\PDO::FETCH_ASSOC);

        if (!$result) {
            $this->throwError(401, 'No user found');
        }

        $articleModel = new Article((new Database())->connect());
        $isDeleted = $articleModel->delete($id);

        if ($isDeleted) {
            $this->returnResponse(200, 'The article deleted.');
        } else {
            $this->returnResponse(501, 'Unable to delete the article.');
        }
    } catch (\Exception $e) {
        echo $e->getMessage();
    }
}

فرض کنیم قصد داریم مقاله‌ای با شناسهٔ ۱ را از دیتابیس حذف کنیم که برای این منظور خواهیم داشت:

همان‌طور که می‌بینیم، ابتدا متد DELETE را انتخاب نموده سپس اِندپوینت api/v1/articles/1 را در نظر گرفته و طبق روال قبل، هِدِر Authorization را نیز با مقادیر درست سِت کرده‌ایم به طوری که با کلیک بر روی دکمهٔ ارسال، مقالهٔ مذکور با موفقیت از دیتابیس حذف خواهد شد.

به طور کلی، در این مرحله از آموزش، کلاس DefaultController ساختاری به صورت زیر خواهد داشت:

<?php
namespace Api\Controllers;

use Api\Config\Database;
use Api\Models\Article;
use Api\Models\User;
use Core\BaseController;
use Firebase\JWT\JWT;

class DefaultController extends BaseController
{
    public function index($id = null)
    {
        header("Access-Control-Allow-Origin: *");
        header("Content-Type: application/json; charset=UTF-8");
        header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE");
        header("Access-Control-Allow-Headers: Authorization");

        switch ($this->requestMethod) {
            case 'GET':
                if (isset($id) && is_numeric($id)) {
                    $this->getArticleById($id);
                } else {
                    $this->getAllArticles();
                };
                break;
            case 'POST':
                $this->createNewArticle();
                break;
            case 'PUT':
                if (isset($id) && is_numeric($id)) {
                    $this->updateArticleById($id);
                } else {
                    $this->throwError(406, 'The ID parameter is required.');
                }
                break;
            case 'DELETE':
                if (isset($id) && is_numeric($id)) {
                    $this->deleteArticleById($id);
                } else {
                    $this->throwError(406, 'The ID parameter is required.');
                }
                break;
            default:
                break;
        }
    }

    private function getArticleById(int $id)
    {
        $articleModel = new Article((new Database())->connect());
        $result = $articleModel->readById($id);
        $result = $result->fetchAll(\PDO::FETCH_ASSOC);
        if ($result) {
            $this->returnResponse(200, $result);
        } else {
            $this->returnResponse(200, "No article found with id of $id");
        }
    }

    private function getAllArticles()
    {
        $articleModel = new Article((new Database())->connect());
        $result = $articleModel->read();
        $result = $result->fetchAll(\PDO::FETCH_ASSOC);
        if ($result) {
            $this->returnResponse(200, $result);
        } else {
            $this->returnResponse(200, 'No articles found.');
        }
    }

    private function createNewArticle()
    {
        try {
            $this->validateRequest();

            $this->validateParams('articleTitle', $this->request['request_params']['articleTitle'], true);
            $this->validateParams('articleContent', $this->request['request_params']['articleContent'], true);
            $this->validateParams('catId', $this->request['request_params']['catId'], true);

            $token = $this->getBearerToken();
            $payload = JWT::decode($token, parent::JWT_SECRET_KEY, ['HS256']);

            $user = new User((new Database())->connect());
            $result = $user->fetchUserById($payload->userId);
            $result = $result->fetchAll(\PDO::FETCH_ASSOC);
            if (!$result) {
                $this->throwError(401, 'No user found');
            }

            $articleModel = new Article((new Database())->connect());
            $this->request['request_params']['usrId'] = $payload->userId;
            $isInserted = $articleModel->create($this->request['request_params']);

            if ($isInserted) {
                $this->returnResponse(201, 'New article added.');
            } else {
                $this->returnResponse(501, 'Unable to add a new article.');
            }
        } catch (\Exception $e) {
            echo $e->getMessage();
        }
    }

    private function updateArticleById(int $id)
    {
        try {
            $this->validateRequest();

            $this->validateParams('articleTitle', $this->request['request_params']['articleTitle'], true);
            $this->validateParams('articleContent', $this->request['request_params']['articleContent'], true);
            $this->validateParams('catId', $this->request['request_params']['catId'], true);

            $token = $this->getBearerToken();
            $payload = JWT::decode($token, parent::JWT_SECRET_KEY, ['HS256']);

            $user = new User((new Database())->connect());
            $result = $user->fetchUserById($payload->userId);
            $result = $result->fetchAll(\PDO::FETCH_ASSOC);

            if (!$result) {
                $this->throwError(401, 'No user found');
            }

            $articleModel = new Article((new Database())->connect());
            $this->request['request_params']['usrId'] = $payload->userId;
            $isUpdated = $articleModel->update($id, $this->request['request_params']);

            if ($isUpdated) {
                $this->returnResponse(200, 'The article updated.');
            } else {
                $this->returnResponse(501, 'Unable to update the article.');
            }
        } catch (\Exception $e) {
            echo $e->getMessage();
        }
    }

    private function deleteArticleById(int $id)
    {
        try {
            $token = $this->getBearerToken();
            $payload = JWT::decode($token, parent::JWT_SECRET_KEY, ['HS256']);

            $user = new User((new Database())->connect());
            $result = $user->fetchUserById($payload->userId);
            $result = $result->fetchAll(\PDO::FETCH_ASSOC);

            if (!$result) {
                $this->throwError(401, 'No user found');
            }

            $articleModel = new Article((new Database())->connect());
            $isDeleted = $articleModel->delete($id);

            if ($isDeleted) {
                $this->returnResponse(200, 'The article deleted.');
            } else {
                $this->returnResponse(501, 'Unable to delete the article.');
            }
        } catch (\Exception $e) {
            echo $e->getMessage();
        }
    }
}

همچنین لازم به یادآوی است که از خط چهارم تا هشتم کلاس‌های مورد استفاده در این کنترلر به اصطلاح use شده‌اند.

جمع‌بندی
در این آموزش دیدیم که به چه شکل می‌توانیم کلیهٔ تَسک‌های مرتبط با مُدل Article را با استفاده از کنترلرِ DefaultController هندل نماییم. به عبارتی، با استفاده از اِندپوینتی به آدرس api/v1/articles با استفاده از یک دستور switch نوع متد را مشخص نموده سپس بر آن اساس اَکشن مربوطه را اجرا نمودیم.

rocket
نظرات
اگر login نکردی برامون ایمیلت رو بنویس: