موضع نص الإشعار اتصل بنا تفضل الآن!

تجربتي في تحويل Laravel app إلى RESTful API لموبايل Flutter

George Bahgat

تجربتي في تحويل Laravel app إلى RESTful API لموبايل Flutter

كنت أعمل على تطبيق ويب لإدارة المخزون مبني بـ Laravel لمدة سنتين، وكان يعمل بشكل ممتاز. لكن مع تزايد طلب العملاء على تطبيق موبايل، وجدت نفسي أمام خيارين: إما بناء تطبيق موبايل منفصل بالكامل، أو تحويل التطبيق الحالي إلى API يعمل مع Flutter. اخترت الخيار الثاني، وكانت رحلة مليئة بالتحديات والدروس القيمة.

في البداية، كنت متخوفًا من تعقيدات العملية. كيف سأحول نظامًا كاملاً يعتمد على جلسات المستخدمين إلى نظام يعمل بـ Tokens؟ كيف سأتعامل مع رفع الملفات؟ وماذا عن الأمان؟ كل هذه الأسئلة كانت تدور في ذهني عندما بدأت المشروع.

لماذا التحول إلى API أساسًا؟

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

لاحظت أن فصل الطبقة الخلفية عن الواجهة الأمامية يوفر مرونة لا تصدق. بمجرد أن يكون لديك API قوي، يمكنك بناء أي واجهة تريدها ــ تطبيق Flutter، موقع ويب جديد، حتى تطبيقات سطح المكتب ــ كلها تتصل بنفس المصدر المركزي للبيانات.

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

المشروع التجريبي: نظام إدارة المنتجات

قررت بناء نظام بسيط لإدارة المنتجات كتجربة أولى. النظام يشمل:

  • تسجيل المستخدمين وتسجيل الدخول
  • إدارة المنتجات (إضافة، تعديل، حذف، عرض)
  • رفع صور للمنتجات
  • تصنيف المنتجات

البداية: التخطيط والهيكلة

قبل كتابة أي كود، قضيت أسبوعًا كاملاً في التخطيط. رسمت مخططًا للوظائف وحددت لكل وظيفة الطريقة HTTP المناسبة لها حسب مبادئ RESTful API.

هذه كانت الخريطة التي اتبعتها:

  • POST /api/register → تسجيل مستخدم جديد
  • POST /api/login → تسجيل الدخول
  • GET /api/products → عرض جميع المنتجات
  • POST /api/products → إضافة منتج جديد
  • GET /api/products/{id} → عرض منتج محدد
  • PUT /api/products/{id} → تعديل منتج
  • DELETE /api/products/{id} → حذف منتج
  • POST /api/products/{id}/images → رفع صورة للمنتج

إنشاء مسارات API منفصلة

في Laravel، من المهم فصل مسارات API عن مسارات الويب العادية. بدأت بملف routes/api.php:

Route::prefix('v1')->group(function () {

    // Authentication routes

    Route::post('/register', [AuthController::class, 'register']);

    Route::post('/login', [AuthController::class, 'login']);

    

    // Protected routes

    Route::middleware('auth:sanctum')->group(function () {

        Route::apiResource('products', ProductController::class);

        Route::post('/products/{product}/images', [ProductController::class, 'uploadImage']);

        Route::post('/logout', [AuthController::class, 'logout']);

    });

});

    

استخدمت الـ prefix 'v1' لإصدار API، هذه ممارسة جيدة تسمح لك بتطوير API بدون كسر التطبيقات القديمة.

المصادقة: Sanctum vs Passport

واجهتني معضلة اختيار نظام المصادقة. بعد تجربة الاثنين، وجدت أن Sanctum هو الأنسب لتطبيقات الموبايل. السبب بسيط: Sanctum مصمم خصيصًا للتطبيقات التي لا تحتاج إلى تعقيدات OAuth2 الكاملة.

Sanctum يعمل بـ API Tokens بسيطة، كل token يمثل جلسة مستخدم على جهاز معين. هذا بالضبط ما تحتاجه تطبيقات الموبايل.

إعداد Laravel Sanctum

الخطوات كانت واضحة ومباشرة:

// Install Sanctum

composer require laravel/sanctum

// Publish configuration

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

// Run migrations

php artisan migrate

    

ثم في ملف app/Http/Kernel.php، تأكدت من إضافة middleware Sanctum:

'api' => [

    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,

    'throttle:api',

    \Illuminate\Routing\Middleware\SubstituteBindings::class,

],

    

بناء نظام التسجيل والدخول

في AuthController، بدأت بتطوير دوال التسجيل والدخول:

public function register(Request $request)

{

    $validated = $request->validate([

        'name' => 'required|string|max:255',

        'email' => 'required|string|email|max:255|unique:users',

        'password' => 'required|string|min:8|confirmed',

    ]);

    $user = User::create([

        'name' => $validated['name'],

        'email' => $validated['email'],

        'password' => Hash::make($validated['password']),

    ]);

    $token = $user->createToken('auth_token')->plainTextToken;

    return response()->json([

        'user' => $user,

        'access_token' => $token,

        'token_type' => 'Bearer',

    ]);

}

public function login(Request $request)

{

    $credentials = $request->validate([

        'email' => 'required|email',

        'password' => 'required',

    ]);

    if (!Auth::attempt($credentials)) {

        return response()->json([

            'message' => 'Invalid credentials'

        ], 401);

    }

    $user = User::where('email', $request->email)->first();

    $token = $user->createToken('auth_token')->plainTextToken;

    return response()->json([

        'user' => $user,

        'access_token' => $token,

        'token_type' => 'Bearer',

    ]);

}

    
ملاحظة أمنية: في Sanctum، التوكنز تخزن كنص عادي في قاعدة البيانات. تأكد دائمًا من استخدام HTTPS في بيئة الإنتاج.

نموذج المنتج والتحكم فيه

أنشأت نموذج Product بخصائص أساسية:

// Migration

Schema::create('products', function (Blueprint $table) {

    $table->id();

    $table->string('name');

    $table->text('description')->nullable();

    $table->decimal('price', 8, 2);

    $table->integer('stock_quantity')->default(0);

    $table->string('image_path')->nullable();

    $table->foreignId('user_id')->constrained()->onDelete('cascade');

    $table->timestamps();

});

// Product Model

class Product extends Model

{

    protected $fillable = [

        'name',

        'description', 

        'price',

        'stock_quantity',

        'image_path',

        'user_id'

    ];

    public function user()

    {

        return $this->belongsTo(User::class);

    }

}

    

Product Controller الكامل

هذا الكود يوضح كيفية بناء الـ controller مع جميع العمليات CRUD:

class ProductController extends Controller

{

    public function index()

    {

        $products = Product::where('user_id', auth()->id())->get();

        return ProductResource::collection($products);

    }

    public function store(Request $request)

    {

        $validated = $request->validate([

            'name' => 'required|string|max:255',

            'description' => 'nullable|string',

            'price' => 'required|numeric|min:0',

            'stock_quantity' => 'required|integer|min:0',

        ]);

        $product = Product::create(array_merge($validated, [

            'user_id' => auth()->id()

        ]));

        return new ProductResource($product);

    }

    public function show(Product $product)

    {

        $this->authorize('view', $product);

        return new ProductResource($product);

    }

    public function update(Request $request, Product $product)

    {

        $this->authorize('update', $product);

        $validated = $request->validate([

            'name' => 'sometimes|string|max:255',

            'description' => 'nullable|string',

            'price' => 'sometimes|numeric|min:0',

            'stock_quantity' => 'sometimes|integer|min:0',

        ]);

        $product->update($validated);

        return new ProductResource($product);

    }

    public function destroy(Product $product)

    {

        $this->authorize('delete', $product);

        $product->delete();

        return response()->json([

            'message' => 'Product deleted successfully'

        ]);

    }

    public function uploadImage(Request $request, Product $product)

    {

        $this->authorize('update', $product);

        $request->validate([

            'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',

        ]);

        if ($request->hasFile('image')) {

            // Delete old image if exists

            if ($product->image_path) {

                Storage::disk('public')->delete($product->image_path);

            }

            $path = $request->file('image')->store('product-images', 'public');

            $product->update(['image_path' => $path]);

            return response()->json([

                'message' => 'Image uploaded successfully',

                'image_url' => asset("storage/{$path}")

            ]);

        }

        return response()->json([

            'message' => 'No image provided'

        ], 400);

    }

}

    

API Resources: سر النجاح

اكتشفت أن API Resources هي من أقوى ميزات Laravel لهذا النوع من المشاريع. بدلاً من إرجاع النماذج مباشرة، يمكنك تخصيص شكل البيانات المرتجعة.

إنشاء Product Resource

class ProductResource extends JsonResource

{

    public function toArray($request)

    {

        return [

            'id' => $this->id,

            'name' => $this->name,

            'description' => $this->description,

            'price' => (float) $this->price,

            'stock_quantity' => $this->stock_quantity,

            'image_url' => $this->image_path ? asset("storage/{$this->image_path}") : null,

            'created_at' => $this->created_at->toISOString(),

            'updated_at' => $this->updated_at->toISOString(),

            'links' => [

                'self' => route('products.show', $this->id),

                'image_upload' => route('products.images.store', $this->id),

            ]

        ];

    }

}

    

الفائدة من استخدام Resources أنها تمنحك تحكمًا كاملاً في شكل الاستجابة بدون الحاجة لتعديل النموذج الأساسي.

رفع الملفات: تحدي حقيقي

واجهت تحديات في رفع الملفات من Flutter إلى Laravel. المشكلة كانت في كيفية إرسال الصور مع البيانات الأخرى.

الحل النهائي لرفع الصور

// In Laravel - Storage setup

php artisan storage:link

// In Flutter - Upload function

Future uploadProductImage(String productId, File imageFile) async {

  var uri = Uri.parse('https://yourapi.com/api/v1/products/$productId/images');

  var request = http.MultipartRequest('POST', uri);

  

  // Add authorization header

  request.headers['Authorization'] = 'Bearer $accessToken';

  

  // Add image file

  var stream = http.ByteStream(imageFile.openRead());

  var length = await imageFile.length();

  var multipartFile = http.MultipartFile(

    'image',

    stream,

    length,

    filename: imageFile.path.split('/').last,

  );

  

  request.files.add(multipartFile);

  

  var response = await request.send();

  

  if (response.statusCode == 200) {

    print('Image uploaded successfully');

  } else {

    print('Upload failed: ${response.statusCode}');

  }

}

    
ملاحظة تقنية: تأكد من إعداد سياسة CORS بشكل صحيح لتمكين رفع الملفات من تطبيق Flutter.

التحقق من الصحة ومعالجة الأخطاء

في تطبيقات API، معالجة الأخطاء مهمة جدًا لتجربة مستخدم سلسة. طورت نظامًا متكاملًا للتحقق من الصحة ومعالجة الأخطاء.

معالجة الاستثناءات

// In app/Exceptions/Handler.php

public function render($request, Throwable $exception)

{

    if ($exception instanceof ModelNotFoundException) {

        return response()->json([

            'message' => 'Resource not found',

            'error' => 'The requested resource does not exist'

        ], 404);

    }

    if ($exception instanceof ValidationException) {

        return response()->json([

            'message' => 'Validation failed',

            'errors' => $exception->errors()

        ], 422);

    }

    if ($exception instanceof AuthenticationException) {

        return response()->json([

            'message' => 'Unauthenticated',

            'error' => 'You must be logged in to access this resource'

        ], 401);

    }

    return parent::render($request, $exception);

}

    

الاختبار: مرحلة لا غنى عنها

قبل الانتقال إلى Flutter، اختبرت كل endpoint باستخدام Postman. هذه كانت بعض السيناريوهات التي اختبرتها:

  • التسجيل بمستخدم جديد
  • تسجيل الدخول والحصول على token
  • محاولة الوصول إلى موارد محمية بدون token
  • إضافة منتجات جديدة
  • رفع صور للمنتجات
  • اختبار التحقق من الصحة مع بيانات غير صالحة

أمثلة على اختبارات Postman

// Test registration

POST /api/v1/register

{

    "name": "Test User",

    "email": "test@example.com",

    "password": "password123",

    "password_confirmation": "password123"

}

// Test product creation

POST /api/v1/products

Headers: Authorization: Bearer {token}

{

    "name": "New Product",

    "description": "Product description",

    "price": 29.99,

    "stock_quantity": 100

}

    

التكامل مع Flutter

بعد التأكد من عمل API بشكل صحيح، بدأت في بناء تطبيق Flutter. استخدمت حزمة http للاتصال بالـ API.

خدمة API في Flutter

class ApiService {

  static const String baseUrl = 'https://yourapi.com/api/v1';

  static String? accessToken;

  static Future> get headers async {

    return {

      'Content-Type': 'application/json',

      'Accept': 'application/json',

      if (accessToken != null) 'Authorization': 'Bearer $accessToken',

    };

  }

  static Future login(String email, String password) async {

    final response = await http.post(

      Uri.parse('$baseUrl/login'),

      headers: await headers,

      body: jsonEncode({

        'email': email,

        'password': password,

      }),

    );

    if (response.statusCode == 200) {

      final data = jsonDecode(response.body);

      accessToken = data['access_token'];

      return data;

    } else {

      throw Exception('Login failed');

    }

  }

  static Future> getProducts() async {

    final response = await http.get(

      Uri.parse('$baseUrl/products'),

      headers: await headers,

    );

    if (response.statusCode == 200) {

      final data = jsonDecode(response.body);

      return (data as List).map((item) => Product.fromJson(item)).toList();

    } else {

      throw Exception('Failed to load products');

    }

  }

  static Future createProduct(Product product) async {

    final response = await http.post(

      Uri.parse('$baseUrl/products'),

      headers: await headers,

      body: jsonEncode(product.toJson()),

    );

    if (response.statusCode == 201) {

      return Product.fromJson(jsonDecode(response.body));

    } else {

      throw Exception('Failed to create product');

    }

  }

}

    

نموذج Product في Flutter

class Product {

  final int id;

  final String name;

  final String? description;

  final double price;

  final int stockQuantity;

  final String? imageUrl;

  final DateTime createdAt;

  final DateTime updatedAt;

  Product({

    required this.id,

    required this.name,

    this.description,

    required this.price,

    required this.stockQuantity,

    this.imageUrl,

    required this.createdAt,

    required this.updatedAt,

  });

  factory Product.fromJson(Map json) {

    return Product(

      id: json['id'],

      name: json['name'],

      description: json['description'],

      price: double.parse(json['price'].toString()),

      stockQuantity: json['stock_quantity'],

      imageUrl: json['image_url'],

      createdAt: DateTime.parse(json['created_at']),

      updatedAt: DateTime.parse(json['updated_at']),

    );

  }

  Map toJson() {

    return {

      'name': name,

      'description': description,

      'price': price,

      'stock_quantity': stockQuantity,

    };

  }

}

    
نصيحة أدائية: استخدم pagination عند جلب كميات كبيرة من البيانات لتجنب إبطاء التطبيق.

الدروس المستفادة والتحديات

خلال هذه الرحلة، تعلمت دروسًا قيمة:

التحديات التي واجهتها

واجهت عدة تحديات تقنية، أبرزها:

  • إدارة حالة التوكنز في Flutter
  • معالجة الأخطاء بشكل متسق
  • تحسين أداء رفع الملفات
  • ضمان أمان البيانات المنقولة

الحلول التي طورتها

لحل هذه التحديات، اتبعت استراتيجيات مختلفة:

// Token management in Flutter

class AuthService {

  static Future saveToken(String token) async {

    final prefs = await SharedPreferences.getInstance();

    await prefs.setString('access_token', token);

  }

  static Future getToken() async {

    final prefs = await SharedPreferences.getInstance();

    return prefs.getString('access_token');

  }

  static Future logout() async {

    final prefs = await SharedPreferences.getInstance();

    await prefs.remove('access_token');

  }

}

// Error handling wrapper

Future handleApiCall(Future Function() apiCall) async {

  try {

    return await apiCall();

  } on http.ClientException catch (e) {

    throw Exception('Network error: ${e.message}');

  } on FormatException catch (e) {

    throw Exception('Data format error: ${e.message}');

  } catch (e) {

    throw Exception('Unexpected error: $e');

  }

}

    

النتائج والتقييم النهائي

بعد شهرين من العمل، نجحت في تحويل التطبيق بالكامل. النتائج كانت مذهلة:

  • تطبيق Flutter يعمل بسلاسة مع Laravel API
  • أداء محسن مقارنة بالتطبيق الأصلي
  • قابلية للتوسع وإضافة مميزات جديدة
  • تجربة مستخدم متسقة عبر المنصات

الخاتمة: رحلة تستحق العناء

تحويل تطبيق Laravel إلى RESTful API لـ Flutter ليس مجرد عملية تقنية، بل هو تحول في طريقة التفكير. تعلمت أن فصل الطبقات ليس رفاهية، بل ضرورة للتطوير المستدام.

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

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

الخطوات التالية المقترحة:
  • ابدأ بمشروع تجريبي صغير
  • تعلم أساسيات RESTful API
  • جرب Laravel Sanctum في بيئة تطوير
  • ابنِ تطبيق Flutter بسيط للاتصال بالـ API
  • وسع المشروع تدريجيًا حسب الحاجة

إرسال تعليق

الموافقة على ملفات تعريف الارتباط
”نحن نقدم ملفات تعريف الارتباط على هذا الموقع لتحليل حركة المرور وتذكر تفضيلاتك وتحسين تجربتك.“
لا يتوفر اتصال بالإنترنت!
”يبدو أن هناك خطأ ما في اتصالك بالإنترنت ، يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى.“
تم الكشف عن مانع الإعلانات!
”لقد اكتشفنا أنك تستخدم مكونًا إضافيًا لحظر الإعلانات في متصفحك.
تُستخدم العائدات التي نحققها من الإعلانات لإدارة موقع الويب هذا ، ونطلب منك إدراج موقعنا في القائمة البيضاء في المكون الإضافي لحظر الإعلانات.“
Site is Blocked
Sorry! This site is not available in your country.