پرش به مطلب اصلی

کلاس پیکربندی Dorapi

در این فصل، نحوه ایجاد و تنظیم کلاس Dorapi برای هر مدل را بررسی خواهیم کرد. این کلاس نقش مهمی در تعریف قوانین مجاز برای فیلدها، روابط، فیلترها و مرتب‌سازی‌ها ایفا می‌کند.


ایجاد کلاس Dorapi

برای هر مدل، یک کلاس Dorapi ایجاد می‌شود که قوانین مجاز را تعریف می‌کند. این کلاس با دستور زیر ساخته می‌شود:

php artisan dornica:make-dorapi User
نمونه کلاس Dorapi برای مدل User
<?php

namespace App\Dorapi;

use Dornica\APIKit\Dorapi\Builders\Field;
use Dornica\APIKit\Dorapi\Builders\Relation;
use Dornica\APIKit\Dorapi\Dorapi;
use Dornica\APIKit\Dorapi\DorapiProperty;
use Dornica\APIKit\Dorapi\Enums\FilterOperator;
use Dornica\APIKit\Dorapi\Enums\SortDirection;

class UserDorapi extends Dorapi
{
/**
* Define the default fields for the API response
*
* @return Field
*/
public function fields(): Field
{
return DorapiProperty::field()
->addDefault('id')
->addDefault('created_at')
->addDefault(
column: fn() => UserRole::query()
->select('name')
->whereColumn('users.role_id', 'user_roles.id')
->limit(1),
alias: 'user_role_name'
)
->add(
column: fn() => Comment::query()
->whereColumn('comments.user_id', 'users.id')
->selectRaw('count(*)'),
alias: 'user_comment_count'
);
}

/**
* Define the filterable fields and their allowed operators
*
* @return array
*/
public function filters(): array
{
return [
DorapiProperty::filter()
->make('id')
->allowedOperators([
FilterOperator::EQUAL,
FilterOperator::IN
]),
];
}

/**
* Define the available relations for eager loading
*
* @return Relation
*/
public function relations(): Relation
{
return DorapiProperty::relation();
}

/**
* Define sortable fields
*
* Example usage in query string:
* - Regular sort: sort[0]=id:asc
* - Relation-based sort: sort[0]=relation.field:desc
*
* @return Sort
*/
public function sorts(): Sort
{
return DorapiProperty::sort()
->add('id')
->add('user_comment_count');
}
}

این کلاس شامل تعریف‌های زیر است:

  • فیلدهای قابل انتخاب
  • فیلترهای مجاز
  • مرتب‌سازی‌ها
  • روابط قابل populate

است.


فیلدها (Fields)

تعریف فیلدهای مجاز

public function fields(): Field
{
return DorapiProperty::field()
->addDefault('code')
->addDefault('title')
->addDefault('is_active')
->addDefault(
column: fn() => Comment::query()
->whereColumn('comments.user_id', 'users.id')
->selectRaw('count(*)'),
alias: 'user_comment_count'
)
->add('description');
}

رفتار سیستم

  • اگر fields ارسال نشود، فقط فیلدهای تعریف‌شده به عنوان پیش فرض (Default) برگردانده می‌شوند
  • فیلد id همیشه به‌صورت خودکار اضافه می‌شود
  • درخواست فیلد تعریف‌نشده → نادیده گرفته می‌شود (بدون خطا)
  • فیلدهای محاسباتی (Closure) نیز دقیقاً مانند سایر فیلدها قابل درخواست هستند

در این مثال:

  • addDefault فیلدهایی را تعریف می‌کند که به صورت پیش‌فرض در پاسخ API برگردانده می‌شوند
  • add فیلدهایی را تعریف می‌کند که مجاز هستند ولی فقط در صورت درخواست کاربر در خروجی قرار می‌گیرند
  • برای فیلدهای محاسباتی (Subquery)، می‌توان یک Closure به جای نام ستون پاس داد — در این حالت پارامتر alias الزامی است

فیلدهای محاسباتی (Closure Field)

زمانی که مقدار فیلد مستقیماً در جدول وجود ندارد و باید با یک Query محاسبه شود، می‌توان یک Closure به عنوان column پاس داد:

->add(
column: fn() => Comment::query()
->whereColumn('comments.user_id', 'users.id')
->selectRaw('count(*)'),
alias: 'user_comment_count'
)

یا با addDefault اگر می‌خواهید به صورت پیش‌فرض برگردانده شود:

->addDefault(
column: fn() => Province::query()
->select('name')
->whereColumn('provinces.id', 'cities.province_id')
->limit(1),
alias: 'province_name'
)

نکات مهم:

  • پارامتر alias نام فیلدی است که در خروجی API نمایش داده می‌شود — بدون alias اکسپشن پرتاب می‌شود
  • Closure باید یک Query Builder برگرداند
  • DorAPI این Query را به صورت SubQuery در بخش SELECT قرار می‌دهد
  • پس از تعریف، می‌توان از alias در بخش sorts نیز برای مرتب‌سازی استفاده کرد

استفاده در Request

GET /api/products?fields[0]=title&fields[1]=price&fields[2]=user_comment_count

روابط (Populate)

تعریف روابط مجاز

public function relations(): Relation
{
return DorapiProperty::relation()
->addDefault('parent')
->add('createdBy')
->add('updatedBy');
}

قواعد مهم

  • اگر رابطه‌ای تعریف نشده باشد، قابل populate نیست
  • اگر populate ارسال نشود، فقط موارد تعریف‌شده به عنوان پیش فرض (Default) برگردانده می‌شوند
  • populate پیش‌فرض lazy نیست، فقط با درخواست کاربر اجرا می‌شود
  • حداکثر آیتم هر رابطه: 10 (قابل تنظیم در config)

مثال

GET /api/products?populate[0]=category

فیلترها (Filters)

تعریف فیلترها

public function filters(): array
{
return [
DorapiProperty::filter()
->make('is_active')
->allowedOperators([
FilterOperator::EQUAL,
FilterOperator::IN,
])
->default(1)
->customQuery(fn($query, $value, $operator) =>
$query->where('is_active', $operator, $value)
)
->labelResolver(fn($value) => is_array($value) ?
array_map(fn($val) => IsActive::from($val)->label(), $value) :
IsActive::from($value)->label()),
];
}

در مثال بالا:

  • make نام فیلدی که قرار است فیلتر شود را مشخص می‌کند
  • allowedOperators اپراتورهایی را مشخص می‌کند که کاربر اجازه دارد در درخواست استفاده کند
  • default مقدار پیش‌فرض فیلتر را تعیین می‌کند؛ اگر کاربر فیلتر ارسال نکند این مقدار اعمال می‌شود
  • customQuery امکان تعریف منطق دلخواه برای اعمال فیلتر روی Query را فراهم می‌کند
  • labelResolver مقدار فیلتر را به یک برچسب قابل نمایش تبدیل می‌کند که در meta پاسخ API قرار می‌گیرد

وقتی از اپراتور IN استفاده می‌شود، مقدار فیلتر به صورت آرایه ارسال می‌شود، نه یک مقدار تکی. در نتیجه labelResolver باید بتواند هر دو حالت را مدیریت کند: مقدار تکی و آرایه‌ای.

در حالت مقدار تکی، تبدیل مقدار به برچسب به سادگی انجام می‌شود:

IsActive::from($value)->label()

اما زمانی که اپراتور IN استفاده شده باشد، مقدار به شکل آرایه‌ای از مقادیر خواهد بود (مثلاً [1,0]). در این حالت باید برای هر مقدار داخل آرایه برچسب متناظر تولید شود. برای این کار از array_map استفاده می‌کنیم تا تابع تبدیل روی تمام مقادیر اعمال شود.

به همین دلیل در labelResolver ابتدا بررسی می‌شود که مقدار آرایه است یا نه:

->labelResolver(fn($value) => is_array($value)
? array_map(fn($val) => IsActive::from($val)->label(), $value)
: IsActive::from($value)->label()
)

در این پیاده‌سازی:

اگر مقدار تکی باشد → مستقیماً به label تبدیل می‌شود. اگر مقدار آرایه‌ای باشد (مانند اپراتور IN) → با array_map هر مقدار به label متناظر تبدیل می‌شود. به این ترتیب خروجی meta همیشه شامل برچسب‌های قابل نمایش برای تمام مقادیر فیلتر خواهد بود.

در مثال بالا که برای labelResolver استفاده شد، منظور از IsActive همین enum زیر است:

enum IsActive: int
{
use EnumTools;

case YES = 1;
case NO = 0;

/**
* @return string
*/
public function label(): string
{
return match ($this) {
self::YES => __('validation.bool.yes'),
self::NO => __('validation.bool.no'),
default => __('validation.enum', ['attribute' => 'is_active']),
};
}
}

اپراتورهای فیلتر

کلاس Enum برای تعیین اپراتورها:

use Dornica\APIKit\Dorapi\Enums\FilterOperator;

این کلاس شامل اپراتورهای زیر است:

case EQUAL = '$eq';
case NOT_EQUAL = '$ne';
case LESS_THAN = '$lt';
case LESS_THAN_EQUAL = '$lte';
case GREATER_THAN = '$gt';
case GREATER_THAN_EQUAL = '$gte';
case LIKE = '$contains';
case IN = '$in';
case NOT_IN = '$notIn';
case NULL = '$null';
case NOT_NULL = '$notNull';

اپراتورهای پشتیبانی‌شده

Operatorتوضیح
$eq
برابر
$ne
نابرابر
$lt / $lte
کمتر از
$gt / $gte
بیشتر از
$in / $notIn
در لیست
$contains
جستجوی متنی
$null / $notNull
بررسی null
$between
بازه

فیلتر پیش‌فرض

با استفاده از متد default می‌توان مقدار پیش‌فرض برای فیلتر تعریف کرد. اگر کاربر در Request فیلتر مربوطه را ارسال نکند، این مقدار به صورت خودکار اعمال می‌شود.

DorapiProperty::filter()
->make('status')
->allowedOperators(FilterOperator::EQUAL)
->default(1)

پارامترها

مقدار پیش‌فرض فیلتر است. اگر کاربر در Request این فیلتر را ارسال نکند، این مقدار اعمال می‌شود. نوع مقدار می‌تواند باشد:

  • int
  • string
  • bool
  • array (برای اپراتورهایی مثل $in)
  • null
  • callable (برای مقدار داینامیک)

اپراتوری که برای مقدار پیش‌فرض استفاده می‌شود و باید از FilterOperator باشد.


مثال ساده

DorapiProperty::filter()
->make('status')
->allowedOperators([
FilterOperator::EQUAL,
FilterOperator::IN,
])
->default(1, FilterOperator::EQUAL);

در این حالت اگر کاربر هیچ فیلتری ارسال نکند، این شرط اعمال می‌شود:

status = 1

مثال با IN

->default([1,2,3], FilterOperator::IN)

معادل:

status IN (1,2,3)

مثال با مقدار داینامیک (callable)

گاهی مقدار پیش‌فرض باید در زمان اجرا محاسبه شود:

->default(fn() => auth()->id(), FilterOperator::EQUAL)

در این حالت فیلتر به شکل زیر اعمال می‌شود:

user_id = current_user_id

نکته مهم

default فقط زمانی اعمال می‌شود که کاربر آن فیلتر را در Request ارسال نکرده باشد.

اگر کاربر فیلتر را ارسال کند، مقدار پیش‌فرض کاملاً نادیده گرفته می‌شود.

Custom Query در فیلتر

در برخی موارد فیلتر کردن مستقیم روی ستون جدول ممکن نیست (مثلاً هنگام استفاده از relation یا subquery). در این شرایط می‌توان از customQuery استفاده کرد.

DorapiProperty::filter()
->make('province_name')
->allowedOperators(FilterOperator::EQUAL)
->customQuery(function ($query, $value, $operator) {
$query->whereHas('province', function ($query) use ($value) {
$query->where('name', $value);
});
})

در این مثال فیلتر روی نام استان اعمال می‌شود، در حالی که ستون province_name مستقیماً در جدول وجود ندارد.


برچسب‌های قابل نمایش

با استفاده از این قابلیت، می‌توانید مقادیر فنی را به برچسب‌های قابل نمایش تبدیل کنید. این برچسب‌ها در متا دیتای پاسخ API قرار می‌گیرند.

DorapiProperty::filter()
->make('is_active')
->allowedOperators([
FilterOperator::EQUAL,
FilterOperator::IN,
])
->labelResolver(fn($value) => is_array($value) ?
array_map(fn($val) => IsActive::from($val)->label(), $value) :
IsActive::from($value)->label()),

خروجی در meta:

"meta": {
"code": 1,
"message": null,
"data": [...],
"filters": [
{
"key": "is_active",
"value": "1",
"label": "فعال",
"operator": "$eq"
},
{
"key": "is_active",
"value": "1",
"label": "فعال",
"operator": "$in"
},
{
"key": "is_active",
"value": "0",
"label": "غیر فعال",
"operator": "$in"
}
]
}

مثال

GET /api/products?filters[price][$gte]=100000&filters[price][$lte]=500000

مرتب‌سازی (Sort)

تعریف Sort

use Dornica\APIKit\Dorapi\Enums\SortDirection;

public function sorts(): Sort
{
return DorapiProperty::sort()
->add('created_at')
->addDefault('price')
->addDefault('price', SortDirection::DESC);
}

متد های مرتب‌سازی

مرتب‌سازی پیش‌فرض
addDefault(string $column, SortDirection $direction = SortDirection::ASC): static
مرتب‌سازی معمولی
add(string $column, bool $isDefault = false, SortDirection $direction = SortDirection::ASC): static

مرتب‌سازی روی فیلدهای محاسباتی (Closure Fields)

فیلدهایی که با Closure در بخش fields تعریف می‌شوند نیز می‌توانند در مرتب‌سازی استفاده شوند. از آنجا که DorAPI این فیلدها را به صورت SubQuery در بخش SELECT اضافه می‌کند، در صورت داشتن alias می‌توان آن‌ها را مانند یک ستون معمولی در sort استفاده کرد.

مثال تعریف فیلد محاسباتی:

public function fields(): Field
{
return DorapiProperty::field()
->addDefault('title')
->add(
column: fn() => Comment::query()
->whereColumn('comments.user_id', 'users.id')
->selectRaw('count(*)'),
alias: 'user_comment_count'
);
}

در این مثال فیلد user_comment_count به خروجی اضافه می‌شود.

اکنون می‌توان آن را در sorts نیز تعریف کرد:

public function sorts(): Sort
{
return DorapiProperty::sort()
->add('user_comment_count')
->add('created_at');
}

استفاده

GET /api/products?sort[0]=created_at:desc&sort[1]=user_comment_count:desc

Relation-based Sorting

Dorapi از مرتب‌سازی روی فیلدهای relation هم پشتیبانی می‌کند. برای این کار کافی است نام relation و ستون مورد نظر را با فرمت زیر داخل sorts() تعریف کنید:

relation_name.column_name

مثال:

public function sorts(): Sort
{
return DorapiProperty::sort()
->addDefault('province.name');
}

در مثال بالا:

->addDefault('province.name')

به Dorapi اعلام می‌کند که امکان مرتب‌سازی کردن City ها بر اساس ستون name از relation province وجود دارد.

نحوه استفاده در Request

کاربر می‌تواند به شکل زیر درخواست مرتب‌سازی ارسال کند:

GET /cities?sort[0]=province.name:asc

یا:

GET /cities?sort[0]=province.name:desc

پیش‌نیازها

برای استفاده از relation sort باید:

  1. Relation داخل model وجود داشته باشد
public function province(): BelongsTo
{
return $this->belongsTo(Province::class);
}
  1. Relation داخل relations() تعریف شده باشد
public function relations(): Relation
{
return DorapiProperty::relation()
->addDefault('province');
}
  1. فیلد sortable داخل sorts() ثبت شده باشد
public function sorts(): Sort
{
return DorapiProperty::sort()
->addDefault('province.name');
}

قواعد

  • اگر جهت مشخص نشود، asc پیش‌فرض است
  • مرتب‌سازی روی فیلد تعریف‌نشده → نادیده گرفته می‌شود (بدون خطا)
  • اگر sort ارسال نشود، فقط موارد تعریف‌شده به عنوان پیش فرض (Default) مرتب‌سازی می‌شوند
  • در حال حاضر relation sort فقط برای relation های BelongsTo و HasOne پشتیبانی می‌شود

صفحه‌بندی (Pagination)

Page-based (پیش‌فرض)

pagination[page]=1
pagination[pageSize]=15

Offset-based

pagination[start]=0
pagination[limit]=20

قوانین

  • عدم امکان استفاده همزمان page و start
  • page از 1 شروع می‌شود
  • start از 0 شروع می‌شود
نکته

صفحه بندی به صورت پیش فرض فعال است و نیازی به تنظیم در کلاس Dorapi نیست.


Anti-Patterns (موارد ممنوع)

  • استفاده از DorAPI بدون تعریف whitelist
  • فعال‌سازی populate برای روابط سنگین بدون limit
  • ارسال queryهای پیچیده با GET در داشبوردهای سنگین

جمع‌بندی

DorAPI یک ابزار Query سطح بالا است، نه یک ORM جدید. اگر قوانین آن رعایت شود:

  • API قابل نگهداری‌تر می‌شود
  • Frontend مستقل‌تر عمل می‌کند
  • امنیت و performance کنترل‌شده باقی می‌ماند