تطوير واجهات REST API

بناء واجهة برمجة تطبيقات RESTful كاملة - الجزء 2

18 دقيقة الدرس 34 من 35

بناء واجهة برمجة تطبيقات RESTful كاملة - الجزء 2

في الجزء 2، سننفذ المصادقة باستخدام JWT، وننشئ المستودعات والإجراءات، ونبني نقاط نهاية CRUD للمنتجات والفئات، ونتعامل مع تحميلات الملفات لصور المنتجات.

إعداد مصادقة JWT

أولاً، قم بتثبيت حزمة JWT:

تثبيت tymon/jwt-auth:
composer require tymon/jwt-auth

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

php artisan jwt:secret
config/auth.php - تكوين JWT Guard:
<?php

return [
    'defaults' => [
        'guard' => 'api',
        'passwords' => 'users',
    ],

    'guards' => [
        'api' => [
            'driver' => 'jwt',
            'provider' => 'users',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
    ],
];
app/Models/User.php - تنفيذ JWTSubject:
<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
    protected $fillable = [
        'name',
        'email',
        'password',
        'phone',
        'role',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

    /**
     * الحصول على معرف JWT
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    /**
     * الحصول على مطالبات JWT المخصصة
     */
    public function getJWTCustomClaims()
    {
        return [
            'role' => $this->role,
        ];
    }

    /**
     * التحقق من كون المستخدم مسؤولاً
     */
    public function isAdmin(): bool
    {
        return $this->role === 'admin';
    }
}

وحدة تحكم المصادقة

app/Http/Controllers/Api/AuthController.php:
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\RegisterRequest;
use App\Http\Requests\LoginRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;

class AuthController extends Controller
{
    /**
     * تسجيل مستخدم جديد
     */
    public function register(RegisterRequest $request): JsonResponse
    {
        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'phone' => $request->phone,
        ]);

        $token = auth()->login($user);

        return $this->respondWithToken($token, $user, 201);
    }

    /**
     * تسجيل دخول المستخدم
     */
    public function login(LoginRequest $request): JsonResponse
    {
        $credentials = $request->only(['email', 'password']);

        if (!$token = auth()->attempt($credentials)) {
            return response()->json([
                'message' => 'بيانات اعتماد غير صالحة',
            ], 401);
        }

        return $this->respondWithToken($token, auth()->user());
    }

    /**
     * الحصول على المستخدم المصادق عليه
     */
    public function me(): UserResource
    {
        return new UserResource(auth()->user());
    }

    /**
     * تسجيل خروج المستخدم
     */
    public function logout(): JsonResponse
    {
        auth()->logout();

        return response()->json([
            'message' => 'تم تسجيل الخروج بنجاح',
        ]);
    }

    /**
     * تحديث الرمز
     */
    public function refresh(): JsonResponse
    {
        $token = auth()->refresh();

        return $this->respondWithToken($token, auth()->user());
    }

    /**
     * الرد بالرمز
     */
    protected function respondWithToken(string $token, User $user, int $status = 200): JsonResponse
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth()->factory()->getTTL() * 60,
            'user' => new UserResource($user),
        ], $status);
    }
}

طلبات النموذج للتحقق

app/Http/Requests/RegisterRequest.php:
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class RegisterRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
            'phone' => ['nullable', 'string', 'max:20'],
        ];
    }
}
app/Http/Requests/StoreProductRequest.php:
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return auth()->user()?->isAdmin();
    }

    public function rules(): array
    {
        return [
            'category_id' => ['required', 'exists:categories,id'],
            'name' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'max:255', 'unique:products'],
            'description' => ['nullable', 'string'],
            'price' => ['required', 'numeric', 'min:0'],
            'sale_price' => ['nullable', 'numeric', 'min:0', 'lt:price'],
            'sku' => ['required', 'string', 'unique:products'],
            'stock_quantity' => ['required', 'integer', 'min:0'],
            'is_active' => ['boolean'],
            'is_featured' => ['boolean'],
            'images' => ['nullable', 'array', 'max:5'],
            'images.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
        ];
    }
}

مستودع المنتجات

app/Repositories/ProductRepository.php:
<?php

namespace App\Repositories;

use App\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;

class ProductRepository
{
    /**
     * الحصول على المنتجات المقسمة بالفلاتر
     */
    public function paginate(array $filters = [], int $perPage = 15): LengthAwarePaginator
    {
        $query = Product::with(['category', 'images']);

        // تصفية حسب الفئة
        if (!empty($filters['category_id'])) {
            $query->where('category_id', $filters['category_id']);
        }

        // تصفية حسب الحالة النشطة
        if (!empty($filters['is_active'])) {
            $query->active();
        }

        // تصفية حسب المميز
        if (!empty($filters['is_featured'])) {
            $query->featured();
        }

        // البحث بالاسم أو الوصف
        if (!empty($filters['search'])) {
            $search = $filters['search'];
            $query->where(function($q) use ($search) {
                $q->where('name', 'LIKE', "%{$search}%")
                  ->orWhere('description', 'LIKE', "%{$search}%");
            });
        }

        // نطاق السعر
        if (!empty($filters['min_price'])) {
            $query->where('price', '>=', $filters['min_price']);
        }
        if (!empty($filters['max_price'])) {
            $query->where('price', '<=', $filters['max_price']);
        }

        // الترتيب
        $sortBy = $filters['sort_by'] ?? 'created_at';
        $sortOrder = $filters['sort_order'] ?? 'desc';
        $query->orderBy($sortBy, $sortOrder);

        return $query->paginate($perPage);
    }

    /**
     * العثور على المنتج مع العلاقات
     */
    public function findWithRelations(int $id): ?Product
    {
        return Product::with(['category', 'images', 'reviews.user'])
            ->find($id);
    }

    /**
     * إنشاء منتج
     */
    public function create(array $data): Product
    {
        return Product::create($data);
    }

    /**
     * تحديث منتج
     */
    public function update(Product $product, array $data): bool
    {
        return $product->update($data);
    }

    /**
     * حذف منتج
     */
    public function delete(Product $product): bool
    {
        return $product->delete();
    }

    /**
     * الحصول على المنتجات المميزة
     */
    public function getFeatured(int $limit = 10): Collection
    {
        return Product::with(['category', 'images'])
            ->active()
            ->featured()
            ->inStock()
            ->limit($limit)
            ->get();
    }
}

خدمة تحميل الصور

app/Services/ImageUploadService.php:
<?php

namespace App\Services;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;

class ImageUploadService
{
    /**
     * تحميل وتغيير حجم الصورة
     */
    public function upload(
        UploadedFile $file,
        string $directory = 'products',
        int $width = 800,
        int $height = 800
    ): string {
        // إنشاء اسم ملف فريد
        $filename = time() . '_' . uniqid() . '.' . $file->extension();
        $path = "{$directory}/{$filename}";

        // تغيير الحجم وتحسين الصورة
        $image = Image::make($file)
            ->fit($width, $height, function ($constraint) {
                $constraint->upsize();
            })
            ->encode($file->extension(), 80);

        // تخزين الصورة
        Storage::disk('public')->put($path, $image);

        return $path;
    }

    /**
     * تحميل صور متعددة
     */
    public function uploadMultiple(
        array $files,
        string $directory = 'products'
    ): array {
        $paths = [];

        foreach ($files as $file) {
            $paths[] = $this->upload($file, $directory);
        }

        return $paths;
    }

    /**
     * حذف صورة
     */
    public function delete(string $path): bool
    {
        if (Storage::disk('public')->exists($path)) {
            return Storage::disk('public')->delete($path);
        }

        return false;
    }

    /**
     * حذف صور متعددة
     */
    public function deleteMultiple(array $paths): void
    {
        foreach ($paths as $path) {
            $this->delete($path);
        }
    }
}

نقاط نهاية CRUD للمنتجات

app/Http/Controllers/Api/ProductController.php:
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use App\Repositories\ProductRepository;
use App\Services\ImageUploadService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class ProductController extends Controller
{
    public function __construct(
        private ProductRepository $productRepository,
        private ImageUploadService $imageUploadService
    ) {
        $this->middleware('auth:api')->except(['index', 'show']);
        $this->middleware('admin')->only(['store', 'update', 'destroy']);
    }

    /**
     * عرض قائمة المنتجات
     */
    public function index(Request $request): AnonymousResourceCollection
    {
        $filters = $request->only([
            'category_id',
            'is_active',
            'is_featured',
            'search',
            'min_price',
            'max_price',
            'sort_by',
            'sort_order',
        ]);

        $perPage = $request->input('per_page', 15);
        $products = $this->productRepository->paginate($filters, $perPage);

        return ProductResource::collection($products);
    }

    /**
     * عرض المنتج المحدد
     */
    public function show(int $id): ProductResource|JsonResponse
    {
        $product = $this->productRepository->findWithRelations($id);

        if (!$product) {
            return response()->json([
                'message' => 'المنتج غير موجود',
            ], 404);
        }

        return new ProductResource($product);
    }

    /**
     * تخزين منتج جديد
     */
    public function store(StoreProductRequest $request): JsonResponse
    {
        $data = $request->except('images');

        // إنشاء المنتج
        $product = $this->productRepository->create($data);

        // تحميل الصور إذا تم توفيرها
        if ($request->hasFile('images')) {
            $this->uploadProductImages($product, $request->file('images'));
        }

        return (new ProductResource($product->load(['category', 'images'])))
            ->response()
            ->setStatusCode(201);
    }

    /**
     * تحديث المنتج المحدد
     */
    public function update(UpdateProductRequest $request, Product $product): ProductResource
    {
        $data = $request->except('images');

        $this->productRepository->update($product, $data);

        // تحميل صور جديدة إذا تم توفيرها
        if ($request->hasFile('images')) {
            $this->uploadProductImages($product, $request->file('images'));
        }

        return new ProductResource($product->fresh(['category', 'images']));
    }

    /**
     * إزالة المنتج المحدد
     */
    public function destroy(Product $product): JsonResponse
    {
        // حذف صور المنتج
        $imagePaths = $product->images->pluck('image_path')->toArray();
        $this->imageUploadService->deleteMultiple($imagePaths);

        // حذف المنتج
        $this->productRepository->delete($product);

        return response()->json([
            'message' => 'تم حذف المنتج بنجاح',
        ]);
    }

    /**
     * تحميل صور المنتج
     */
    private function uploadProductImages(Product $product, array $images): void
    {
        foreach ($images as $index => $image) {
            $path = $this->imageUploadService->upload($image, 'products');

            $product->images()->create([
                'image_path' => $path,
                'is_primary' => $index === 0, // الصورة الأولى هي الأساسية
                'order' => $index,
            ]);
        }
    }
}

موارد API لتنسيق الرد

app/Http/Resources/ProductResource.php:
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProductResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'category' => new CategoryResource($this->whenLoaded('category')),
            'name' => $this->name,
            'slug' => $this->slug,
            'description' => $this->description,
            'price' => (float) $this->price,
            'sale_price' => $this->sale_price ? (float) $this->sale_price : null,
            'current_price' => (float) $this->current_price,
            'sku' => $this->sku,
            'stock_quantity' => $this->stock_quantity,
            'in_stock' => $this->in_stock,
            'is_active' => $this->is_active,
            'is_featured' => $this->is_featured,
            'images' => ProductImageResource::collection($this->whenLoaded('images')),
            'reviews_count' => $this->whenCounted('reviews'),
            'average_rating' => $this->when(
                $this->relationLoaded('reviews'),
                fn() => round($this->reviews->avg('rating'), 1)
            ),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}
app/Http/Resources/ProductImageResource.php:
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;

class ProductImageResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'url' => Storage::disk('public')->url($this->image_path),
            'is_primary' => $this->is_primary,
            'order' => $this->order,
        ];
    }
}

مسارات API

routes/api.php:
<?php

use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ProductController;
use App\Http\Controllers\Api\CategoryController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function () {
    // مسارات المصادقة
    Route::prefix('auth')->group(function () {
        Route::post('register', [AuthController::class, 'register']);
        Route::post('login', [AuthController::class, 'login']);
        Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:api');
        Route::post('refresh', [AuthController::class, 'refresh'])->middleware('auth:api');
        Route::get('me', [AuthController::class, 'me'])->middleware('auth:api');
    });

    // مسارات المنتجات
    Route::apiResource('products', ProductController::class);

    // مسارات الفئات
    Route::apiResource('categories', CategoryController::class);
});

Middleware المسؤول

app/Http/Middleware/AdminMiddleware.php:
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class AdminMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        if (!auth()->check() || !auth()->user()->isAdmin()) {
            return response()->json([
                'message' => 'غير مصرح. يتطلب وصول المسؤول.',
            ], 403);
        }

        return $next($request);
    }
}
ملاحظة أمنية: تحقق دائمًا من تحميلات الملفات - تحقق من نوع الملف والحجم وفحص البرامج الضارة في الإنتاج. قم بتخزين الملفات المحملة خارج جذر الويب أو استخدم التخزين السحابي مع ضوابط الوصول المناسبة.
تمرين تطبيقي:
  1. قم بتنفيذ CategoryController مع CRUD الكامل
  2. أنشئ وحدات تحكم Cart و CartItem
  3. ابنِ تدفق الدفع للطلب
  4. أضف نقاط نهاية مراجعة المنتج
  5. قم بتنفيذ الترقيم والتصفية والترتيب لجميع نقاط نهاية القائمة

الملخص

في الجزء 2، قمنا بتنفيذ:

  • مصادقة JWT مع تسجيل الدخول والتسجيل وتحديث الرمز
  • نمط المستودع للوصول إلى البيانات
  • خدمة تحميل الصور مع تغيير الحجم والتحسين
  • عمليات CRUD كاملة للمنتجات
  • موارد API لتنسيق رد متسق
  • التحقق من طلب النموذج
  • Middleware المسؤول للتفويض

في الجزء 3، سنضيف اختبارًا شاملاً، وننشئ توثيق API، وننشر إلى الإنتاج، ونراجع أفضل الممارسات.