بناء API بإستخدام Java وSpring Boot

بناء API بإستخدام Java وSpring Boot

حين تبدأ في بناء API حقيقي باستخدام Java وSpring Boot، فأنت لا تكتب فقط بعض الـ endpoints وتربطها بقاعدة بيانات. أنت في الحقيقة تبني “عصب” التطبيق كله: الطبقة التي ستتحدث معها الواجهة الأمامية، وتتعامل معها تطبيقات الهاتف، وربما تتصل بها خدمات خارجية لاحقًا. لذلك، كل قرار تتخذه في البداية ينعكس لاحقًا على سهولة التطوير، وسهولة الاختبار، وسهولة الصيانة، وحتى على أداء النظام عندما يكبر المشروع ويصبح فيه فريق كامل يعمل على نفس الكود.

الكثير من المطورين يبدأون بحماس كبير، ثم يقعون في نفس الفخ: controller ضخم، منطق أعمال داخل endpoint، استعلامات قاعدة بيانات مباشرة في كل مكان، ورسائل خطأ مبعثرة بطريقة تجعل المشروع صعب الفهم بعد أسبوعين فقط. في البداية يبدو الأمر سريعًا وبسيطًا، لكن مع مرور الوقت يتحول هذا “التبسيط” إلى عبء كبير. لهذا السبب، من الأفضل أن تبدأ من اليوم الأول بطريقة منظمة، حتى لو كان المشروع صغيرًا. Spring Boot يمنحك الأدوات المناسبة لهذا الأسلوب المنظم، وJava تمنحك الصلابة والاستقرار، ومع بعض العادات الجيدة ستستطيع بناء API نظيف، واضح، وقابل للتوسع.

في هذا المقال الطويل سنبني معًا تصورًا عمليًا لبناء API باستخدام Java وSpring Boot. لن نكتفي بالكلام النظري، بل سنمر عبر هيكلة المشروع، إعداد الاعتمادات، إنشاء الكيانات، الـ DTOs، الـ repositories، الـ services، الـ controllers، التحقق من البيانات، معالجة الأخطاء، التوثيق، الأمان، والاختبار. وسنحاول أن نجعل الصورة واقعية جدًا، كأننا نبني مشروعًا حقيقيًا وليس مجرد مثال تعليمي صغير. الفكرة ليست أن تحفظ الأكواد كما هي، بل أن تفهم كيف يفكر Spring Boot، وكيف تنظم كودك لكي يرتاح معك لاحقًا بدل أن يرهقك.

لماذا Spring Boot مناسب لبناء API؟

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

أحد أهم الأسباب التي تجعل Spring Boot مناسبًا جدًا لبناء API هو أنه يتعامل بسلاسة مع REST، ويمنحك دعمًا ممتازًا لـ JSON، والتحقق من المدخلات، والربط مع قواعد البيانات عبر Spring Data JPA، وإدارة الأخطاء، وتوثيق الـ API، وحتى الأمن عبر Spring Security. بمعنى آخر، لديك بيئة متكاملة لا تجبرك على تركيب أجزاء كثيرة من مكتبات مختلفة بشكل عشوائي.

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

كيف نفكر في API جيد قبل كتابة أي سطر؟

قبل أن نكتب الكود، يجب أن نسأل سؤالًا مهمًا: ما هو الـ API الجيد أصلًا؟

الـ API الجيد ليس فقط الذي “يعمل”، بل الذي يقرأه مطور آخر فيفهمه بسرعة. هو الـ API الذي يعطي أسماء واضحة للموارد، ويرجع رموز HTTP مناسبة، ويستخدم DTOs بدل تمرير الـ entities مباشرة، ويتعامل مع الأخطاء بطريقة مفهومة، ويُختبر بسهولة، ويُوثق بشكل جيد. هذا النوع من الـ API يساعدك أنت أولًا، لأنك بعد شهرين أو ستة أشهر ستعود إلى المشروع وتحتاج أن تتذكر كيف يعمل، ولن ترغب في فتح ملف controller فتجد فيه كل شيء داخل method واحدة طويلة.

تصميم الـ API يبدأ من الموارد. مثلًا، بدل التفكير: “أريد endpoint لإدخال بيانات المستخدم”، فكر هكذا: “عندي resource اسمه user، وأحتاج عمليات إنشاء، قراءة، تعديل، وحذف، وربما عمليات إضافية مثل البحث وتغيير كلمة المرور وتفعيل الحساب”. هذه النظرة تجعل التصميم أكثر وضوحًا، وتساعدك في اختيار أسماء endpoints بشكل منطقي.

على سبيل المثال، قد يكون التصميم بهذه الطريقة:

GET /api/v1/users
GET /api/v1/users/{id}
POST /api/v1/users
PUT /api/v1/users/{id}
DELETE /api/v1/users/{id}

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

إنشاء مشروع Spring Boot

عادةً تبدأ من Spring Initializr أو من أداة إنشاء المشروع في IDE الذي تستخدمه. ما نحتاجه في البداية هو الاعتمادات الأساسية فقط. على الأقل سنحتاج:

  • Spring Web

  • Spring Data JPA

  • Validation

  • Lombok

  • قاعدة بيانات مثل H2 للتجربة أو MySQL/PostgreSQL للمشروع الحقيقي

  • Spring Boot DevTools بشكل اختياري لتسهيل التطوير

  • Spring Security إذا كان المشروع يحتاج تسجيل دخول وصلاحيات

  • Springdoc OpenAPI إذا أردنا توثيق API تلقائيًا

مثال على ملف pom.xml في Maven:

<project xmlns="http://maven.apache.org/POM/4.0.0" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>demo-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo-api</name>
    <description>Demo API with Spring Boot</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.0</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.6.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

هذا الملف ليس مجرد إعدادات، بل هو نقطة الانطلاق لكل شيء. لاحظ أننا أضفنا spring-boot-starter-validation لأن التحقق من البيانات مهم جدًا في أي API محترم. وأضفنا Springdoc لأن التوثيق الجيد يختصر على الفريق كله الكثير من الوقت. وأضفنا H2 كي يكون المثال سهل التشغيل بدون تعقيد قاعدة بيانات خارجية. في مشروع حقيقي، قد تستبدل H2 بـ MySQL أو PostgreSQL حسب الحاجة.

بنية المشروع التي تمنحك راحة طويلة الأمد

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

com.example.demoapi
├── controller
├── service
├── service.impl
├── repository
├── entity
├── dto
├── exception
├── mapper
├── config
└── DemoApiApplication.java

هذا التقسيم ليس مقدسًا، لكنه عملي جدًا. الهدف منه أن يعرف كل جزء من المشروع مكانه الطبيعي. الـ controller يستقبل الطلبات ويرسل الردود. الـ service يحتوي منطق الأعمال. الـ repository يتحدث مع قاعدة البيانات. الـ entity تمثل الجداول. الـ dto تنقل البيانات بطريقة أنظف. الـ exception يعالج الأخطاء بشكل موحد. ومع الوقت ستلاحظ أن هذا التنظيم يوفر عليك الكثير من التشتت.

مثال عملي: بناء API لإدارة المستخدمين

لنبنِ مثالًا بسيطًا لكنه واقعي. سنفترض أننا نريد API لإدارة المستخدمين. كل مستخدم لديه:

  • id

  • fullName

  • email

  • password

  • createdAt

في البداية نكتب الـ entity.

package com.example.demoapi.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String fullName;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;
}

هنا استخدمنا @Entity لتعريف الكيان، و@Table لتحديد اسم الجدول. ووضعنا unique = true على البريد الإلكتروني لأننا لا نريد تكرارًا. لكن هناك نقطة مهمة جدًا: لا ينبغي عادة أن نرسل password كما هو في أي response. بل يجب أن يتم تشفيره قبل الحفظ، وأن يبقى بعيدًا عن الاستجابات.

الآن نكتب DTOs. وهنا تبدأ النظافة الحقيقية في التصميم. لا نرسل الـ entity مباشرة إلى الخارج.

package com.example.demoapi.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateUserRequest(
        @NotBlank(message = "الاسم الكامل مطلوب")
        @Size(min = 3, max = 100, message = "الاسم يجب أن يكون بين 3 و100 حرف")
        String fullName,

        @NotBlank(message = "البريد الإلكتروني مطلوب")
        @Email(message = "البريد الإلكتروني غير صالح")
        String email,

        @NotBlank(message = "كلمة المرور مطلوبة")
        @Size(min = 8, message = "كلمة المرور يجب ألا تقل عن 8 أحرف")
        String password
) {}

واستجابة المستخدم:

package com.example.demoapi.dto;

import java.time.LocalDateTime;

public record UserResponse(
        Long id,
        String fullName,
        String email,
        LocalDateTime createdAt
) {}

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

repository: طبقة الوصول إلى البيانات

الـ repository في Spring Data JPA يختصر الكثير من الشغل. بدل أن تكتب طبقة DAO كاملة، يمكنك إنشاء interface بسيط يرث من JpaRepository.

package com.example.demoapi.repository;

import com.example.demoapi.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<UserEntity, Long> {
    Optional<UserEntity> findByEmail(String email);
    boolean existsByEmail(String email);
}

هذا وحده يعطيك عمليات أساسية كثيرة مثل save وfindById وfindAll وdeleteById. وأضفنا findByEmail وexistsByEmail لأنهما مفيدان جدًا في التحقق من عدم تكرار البريد الإلكتروني.

service: مكان منطق الأعمال الحقيقي

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

package com.example.demoapi.service;

import com.example.demoapi.dto.CreateUserRequest;
import com.example.demoapi.dto.UserResponse;

import java.util.List;

public interface UserService {
    UserResponse createUser(CreateUserRequest request);
    UserResponse getUserById(Long id);
    List<UserResponse> getAllUsers();
    UserResponse updateUser(Long id, CreateUserRequest request);
    void deleteUser(Long id);
}

ثم نكتب التنفيذ:

package com.example.demoapi.service.impl;

import com.example.demoapi.dto.CreateUserRequest;
import com.example.demoapi.dto.UserResponse;
import com.example.demoapi.entity.UserEntity;
import com.example.demoapi.exception.ResourceNotFoundException;
import com.example.demoapi.exception.DuplicateResourceException;
import com.example.demoapi.repository.UserRepository;
import com.example.demoapi.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserResponse createUser(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new DuplicateResourceException("البريد الإلكتروني مستخدم بالفعل");
        }

        UserEntity user = UserEntity.builder()
                .fullName(request.fullName())
                .email(request.email())
                .password(passwordEncoder.encode(request.password()))
                .createdAt(LocalDateTime.now())
                .build();

        UserEntity savedUser = userRepository.save(user);

        return toResponse(savedUser);
    }

    @Override
    public UserResponse getUserById(Long id) {
        UserEntity user = userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("المستخدم غير موجود"));
        return toResponse(user);
    }

    @Override
    public List<UserResponse> getAllUsers() {
        return userRepository.findAll()
                .stream()
                .map(this::toResponse)
                .toList();
    }

    @Override
    public UserResponse updateUser(Long id, CreateUserRequest request) {
        UserEntity user = userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("المستخدم غير موجود"));

        if (!user.getEmail().equals(request.email()) && userRepository.existsByEmail(request.email())) {
            throw new DuplicateResourceException("البريد الإلكتروني مستخدم بالفعل");
        }

        user.setFullName(request.fullName());
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password()));

        UserEntity updatedUser = userRepository.save(user);
        return toResponse(updatedUser);
    }

    @Override
    public void deleteUser(Long id) {
        UserEntity user = userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("المستخدم غير موجود"));
        userRepository.delete(user);
    }

    private UserResponse toResponse(UserEntity user) {
        return new UserResponse(
                user.getId(),
                user.getFullName(),
                user.getEmail(),
                user.getCreatedAt()
        );
    }
}

هنا نرى عدة نقاط مهمة. أولًا، نحن نستخدم PasswordEncoder حتى لا نخزن كلمة المرور كما هي. ثانيًا، نتحقق من وجود البريد مسبقًا قبل الحفظ. ثالثًا، نحول الـ entity إلى response واضح. هذه الخطوات تجعل الخدمة منضبطة وآمنة أكثر من مجرد save مباشر هنا وهناك.

كيف نجهز التشفير بكلمة المرور؟

إذا كنت ستتعامل مع كلمات مرور، فلا تحفظها أبدًا بصيغة plain text. هذا أمر لا نقاش فيه. نحتاج إلى bean خاص بالتشفير.

package com.example.demoapi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

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

controller: استقبال الطلبات وإرجاع الردود

الـ controller يجب أن يكون بسيطًا بقدر الإمكان. وظيفته أن يستقبل الطلب، يمرره إلى service، ثم يرجع الرد المناسب.

package com.example.demoapi.controller;

import com.example.demoapi.dto.CreateUserRequest;
import com.example.demoapi.dto.UserResponse;
import com.example.demoapi.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping
    public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
        UserResponse response = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserById(id));
    }

    @GetMapping
    public ResponseEntity<List<UserResponse>> getAllUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }

    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> updateUser(
            @PathVariable Long id,
            @Valid @RequestBody CreateUserRequest request
    ) {
        return ResponseEntity.ok(userService.updateUser(id, request));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}

هنا استخدمنا @Valid حتى يعمل التحقق من الـ DTO تلقائيًا. كما اخترنا رموز HTTP مناسبة: 201 Created عند الإنشاء، 200 OK عند القراءة والتعديل، و204 No Content عند الحذف. هذه التفاصيل الصغيرة تصنع فرقًا كبيرًا في جودة الـ API.

معالجة الأخطاء بشكل احترافي

من أكثر الأمور التي تجعل API احترافيًا هو طريقة التعامل مع الأخطاء. لا نريد أن يرى المستهلك رسالة غامضة أو Stack Trace. نريد ردًا واضحًا ومنظمًا.

نبدأ بإنشاء الاستثناءات المخصصة:

package com.example.demoapi.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.example.demoapi.exception;

public class DuplicateResourceException extends RuntimeException {
    public DuplicateResourceException(String message) {
        super(message);
    }
}

ثم نكتب بنية عامة للخطأ:

package com.example.demoapi.exception;

import java.time.LocalDateTime;

public record ErrorResponse(
        LocalDateTime timestamp,
        int status,
        String error,
        String message,
        String path
) {}

بعد ذلك نكتب handler عام:

package com.example.demoapi.exception;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request
    ) {
        ErrorResponse response = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.NOT_FOUND.value(),
                "Not Found",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }

    @ExceptionHandler(DuplicateResourceException.class)
    public ResponseEntity<ErrorResponse> handleDuplicate(
            DuplicateResourceException ex,
            HttpServletRequest request
    ) {
        ErrorResponse response = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.CONFLICT.value(),
                "Conflict",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException ex
    ) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(
            Exception ex,
            HttpServletRequest request
    ) {
        ErrorResponse response = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                "حدث خطأ غير متوقع",
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

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

التحقق من البيانات ليس رفاهية

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

مثال آخر على DTO:

package com.example.demoapi.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record LoginRequest(
        @NotBlank(message = "البريد الإلكتروني مطلوب")
        @Email(message = "البريد الإلكتروني غير صالح")
        String email,

        @NotBlank(message = "كلمة المرور مطلوبة")
        @Size(min = 8, message = "كلمة المرور يجب أن تكون 8 أحرف على الأقل")
        String password
) {}

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

إضافة البحث والفلترة والـ pagination

عندما يكبر عدد السجلات، لا يعود من المنطقي أن ترجع كل شيء دفعة واحدة. هنا يأتي دور pagination. وهي من أكثر الأشياء المفيدة في APIs الحقيقية.

Spring Data JPA يوفر لك Pageable بسهولة:

@GetMapping("/paged")
public ResponseEntity<?> getUsersPaged(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size
) {
    // مثال توضيحي فقط، التنفيذ الكامل يكون في الخدمة
    return ResponseEntity.ok("paging example");
}

لكن في الواقع الأفضل أن تكون لديك service method خاصة بذلك:

public Page<UserResponse> getUsersPaged(Pageable pageable) {
    return userRepository.findAll(pageable)
            .map(this::toResponse);
}

وفي الـ controller:

@GetMapping("/paged")
public ResponseEntity<Page<UserResponse>> getUsersPaged(Pageable pageable) {
    return ResponseEntity.ok(userService.getUsersPaged(pageable));
}

الـ pagination مهم لأنك لا تريد أن ترهق قاعدة البيانات ولا الشبكة ولا العميل. عندما ترجع 10 أو 20 عنصرًا في كل مرة، يصبح التصفح أسرع والتعامل مع النتائج أسهل.

أما الفلترة، فغالبًا تنفذها عبر استعلامات repository مخصصة أو Specification أو Query Methods، بحسب تعقيد المشروع. مثلًا:

List<UserEntity> findByFullNameContainingIgnoreCase(String fullName);

هذا النوع من الدوال مفيد عندما تريد بحثًا سريعًا وبسيطًا بدون كتابة Query طويلة.

توثيق API باستخدام Swagger / OpenAPI

إذا أردت أن يكون الـ API سهل الاستخدام من قبل الفريق أو العميل أو حتى من قبلك بعد مدة، فالتوثيق مهم جدًا. OpenAPI وSwagger يساعدان في إنشاء واجهة تفاعلية تعرض الـ endpoints وتوضح المدخلات والمخرجات.

مع springdoc، غالبًا يكفي إعداد بسيط. ثم يمكنك إضافة تعليقات للـ controller أو DTOs لتحسين الوضوح.

مثال:

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
    // ...
}

ثم عندما تشغل التطبيق، يمكنك الدخول إلى واجهة Swagger UI ومشاهدة الـ endpoints وتجربتها مباشرة. هذا مفيد جدًا خاصة في مرحلة التطوير والاختبار اليدوي.

التوثيق ليس مجرد كماليات. في المشاريع الحقيقية، التوثيق الجيد يقلل الأسئلة المتكررة بين المطورين، ويجعل دمج الـ API مع الواجهة الأمامية أسهل بكثير. وهذا ينعكس على سرعة التسليم وجودة العمل.

كيف نضيف طبقة الأمان بشكل مبسط؟

الأمان موضوع واسع، لكن حتى أبسط API يحتاج إلى أساسيات. قد تحتاج إلى تسجيل دخول، JWT، أدوار، وصلاحيات. Spring Security هو الإطار الأساسي لهذا المجال في عالم Spring.

في هذا المقال، لن ندخل في بناء نظام JWT كامل من الصفر، لكن من المهم أن تعرف كيف تفكر: لا تجعل الـ API مفتوحًا بالكامل إذا كانت البيانات حساسة. طبّق authentication، واستخدم authorization على الموارد التي تحتاج حماية.

مثال بسيط لإعداد أمان مبدئي في Spring Security الحديثة قد يبدو هكذا:

package com.example.demoapi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/v1/users/**").permitAll()
                        .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                        .anyRequest().authenticated()
                )
                .httpBasic(Customizer.withDefaults())
                .build();
    }
}

هذا مجرد مثال تعليمي. في المشاريع العملية، ستحتاج غالبًا إلى JWT أو session-based auth أو OAuth2 حسب نوع التطبيق. لكن الرسالة الأهم هنا هي أن الأمان يجب أن يدخل في التصميم من البداية، لا أن يُضاف في آخر لحظة بشكل ارتجالي.

ملف application.yml وتنظيم الإعدادات

من الأفضل غالبًا استخدام application.yml بدلًا من application.properties في المشاريع التي تبدأ في التوسع، لأن YAML أكثر وضوحًا عند تزايد الإعدادات.

server:
  port: 8080

spring:
  datasource:
    url: jdbc:h2:mem:demoapi
    driver-class-name: org.h2.Driver
    username: sa
    password: password
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true

springdoc:
  swagger-ui:
    path: /swagger

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

العلاقات بين الجداول: خطوة ستحتاجها في أغلب المشاريع

المشروع لا يبقى دائمًا بسيطًا. بعد فترة ستجد أن لديك علاقات مثل User لديه Orders، أو Product لديه Category، أو BlogPost لديه Comments. هنا يجب أن تعرف كيف تتعامل مع العلاقات في JPA.

مثال: مستخدم لديه عدة طلبات.

@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OrderEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String orderNumber;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private UserEntity user;
}

وإذا أردنا داخل المستخدم الاحتفاظ بقائمة الطلبات:

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderEntity> orders;

لكن هنا يجب أن تكون حذرًا. العلاقات قد تسبب مشاكل في التسلسل إلى JSON، أو في الأداء، أو في التكرار اللانهائي. لذلك من الحكمة جدًا أن تفصل الـ entity عن response objects، وأن لا تعرض العلاقات الداخلية بشكل عشوائي.

لماذا الـ mapper مهم؟

كلما كبر المشروع، ستجد نفسك تحول البيانات بين entity وDTO كثيرًا. يمكنك فعل ذلك يدويًا كما فعلنا في المثال، وهذا مناسب في البداية. لكن إذا أصبح المشروع أكبر، قد تفكر في استخدام mapper مثل MapStruct أو كتابة طبقة تحويل خاصة.

وجود mapper يساعد على تقليل التكرار. مثلًا:

package com.example.demoapi.mapper;

import com.example.demoapi.dto.UserResponse;
import com.example.demoapi.entity.UserEntity;

public class UserMapper {

    public static UserResponse toResponse(UserEntity user) {
        return new UserResponse(
                user.getId(),
                user.getFullName(),
                user.getEmail(),
                user.getCreatedAt()
        );
    }
}

ثم تستخدمه في الخدمة. هذا الأسلوب يجعل التحويلات أكثر وضوحًا. وفي المشاريع الكبيرة جدًا، يصبح هذا التفصيل مفيدًا للغاية.

الاختبار: لا تنتظر حتى النهاية

الاختبار ليس مرحلة ترفيهية أو خطوة مؤجلة. كلما بدأت به مبكرًا، كان مشروعك أكثر ثباتًا. يمكنك اختبار الـ service والـ controller باستخدام JUnit وMockito وSpring Boot Test.

مثال بسيط لاختبار service:

package com.example.demoapi.service.impl;

import com.example.demoapi.dto.CreateUserRequest;
import com.example.demoapi.entity.UserEntity;
import com.example.demoapi.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.security.crypto.password.PasswordEncoder;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

import java.time.LocalDateTime;
import java.util.Optional;

@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private UserServiceImpl userService;

    @Test
    void shouldCreateUserSuccessfully() {
        CreateUserRequest request = new CreateUserRequest("Ahmed Ali", "ahmed@test.com", "password123");

        when(userRepository.existsByEmail(request.email())).thenReturn(false);
        when(passwordEncoder.encode(request.password())).thenReturn("encoded-password");
        when(userRepository.save(any(UserEntity.class))).thenAnswer(invocation -> {
            UserEntity user = invocation.getArgument(0);
            user.setId(1L);
            return user;
        });

        var response = userService.createUser(request);

        assertNotNull(response);
        assertEquals("Ahmed Ali", response.fullName());
        assertEquals("ahmed@test.com", response.email());
        assertEquals(1L, response.id());
    }
}

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

أخطاء شائعة يقع فيها كثير من المطورين

هناك مجموعة أخطاء تتكرر كثيرًا عند بناء API باستخدام Spring Boot، ويمكن تلخيصها ذهنيًا كالتالي: من أكثرها شيوعًا وضع المنطق داخل controller، واستخدام entity مباشرة في request أو response، وإهمال validation، وعدم توحيد رسائل الخطأ، وتجاهل التشفير، وعدم استخدام pagination عند الحاجة، وعدم كتابة tests، وترك أسماء الحقول غامضة.

لنأخذ مثالًا صغيرًا: عندما يرى فريقك method اسمها processData() داخل controller، فهذا ليس اسمًا جيدًا. الأفضل أن يكون الاسم واضحًا: createUser, updateUser, getUserById. كذلك عندما تستخدم data1, data2, temp, object كأسماء، فأنت تجعل القراءة أصعب على نفسك أولًا. الكود الجميل ليس فقط الذي يعمل، بل الذي يقرأ بسهولة.

أيضًا، لا تخلط بين responsibility of layers. repository لا ينبغي أن يحتوي منطق أعمال. service لا ينبغي أن يعرف تفاصيل HTTP. controller لا ينبغي أن يتعامل مباشرة مع entity المعقدة. كل طبقة لها دورها، وكلما احترمت الحدود بينها، صار المشروع أكثر اتزانًا.

كيف تجعل الـ API جاهزًا للنمو؟

في البداية يكون المشروع صغيرًا، ثم فجأة تكبر المتطلبات. لهذا السبب، من المفيد أن تبني منذ البداية بعقلية النمو. استخدم أسماء موارد واضحة، واعتمد versioning مثل /api/v1/. افصل الـ DTOs عن الـ entities. اجعل معالجة الأخطاء موحدة. أضف logging بشكل معقول. واحرص على أن تكون الإعدادات قابلة للتغيير بين بيئات التطوير والإنتاج.

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

مثال نهائي على استدعاء الـ API

بعد بناء كل شيء، قد يرسل العميل هذا الطلب:

POST /api/v1/users
Content-Type: application/json

{
  "fullName": "Sara Ahmed",
  "email": "sara@example.com",
  "password": "secret1234"
}

وقد تكون الاستجابة:

{
  "id": 1,
  "fullName": "Sara Ahmed",
  "email": "sara@example.com",
  "createdAt": "2026-05-11T20:15:00"
}

وعند الخطأ، مثل تكرار البريد الإلكتروني، قد تكون الاستجابة:

{
  "timestamp": "2026-05-11T20:16:20",
  "status": 409,
  "error": "Conflict",
  "message": "البريد الإلكتروني مستخدم بالفعل",
  "path": "/api/v1/users"
}

هذا النوع من الردود يعطي انطباعًا احترافيًا جدًا. العميل يفهم ما حدث، والمطور يستطيع تتبع المشكلة بسهولة، والمشروع كله يبدو منظمًا.

الخلاصة

بناء API باستخدام Java وSpring Boot ليس مجرد مهمة تقنية، بل هو ممارسة في التنظيم والتفكير الواضح. عندما تبدأ بمشروعك القادم، حاول أن تنظر إليه على أنه نظام يعيش ويتطور، لا مجرد كود مؤقت. استخدم controller نظيفًا، service واضحًا، repository بسيطًا، DTOs مستقلة، validation صارمًا، error handling موحدًا، وتوثيقًا جيدًا. هذه العناصر قد تبدو كثيرة في البداية، لكنها هي التي تصنع الفرق الحقيقي بين مشروع سريع الفوضى ومشروع محترم يمكن الاعتماد عليه.

إذا تعلمت شيئًا واحدًا من هذا المقال، فليكن هذا: لا تجعل السرعة تسرق منك الجودة. أحيانًا أفضل قرار هو أن تتباطأ قليلًا في البداية لكي ترتاح كثيرًا لاحقًا. Spring Boot يعطيك أدوات قوية، لكن جمال النتيجة يأتي من طريقة استخدامك لها. وحين تجمع بين قوة Java وبساطة Spring Boot وتفكيرك المنظم، ستبني API لا يعمل فقط، بل يظل مريحًا وقابلاً للتطوير لوقت طويل.

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

#Java #Spring Boot #Java Spring Boot API #بناء API باستخدام Java #Spring Boot REST API #تعلم Spring Boot #Java RESTful API #Spring Data JPA