بناء واجهات REST مع Spring Boot

مشروع: واجهة برمجية REST متكاملة

18 دقيقة الدرس 10 من 13

مشروع: واجهة برمجية REST متكاملة

يتجمّع في هذا الدرس الختامي كل ما تعلّمته في هذه الوحدة الدراسية: مبادئ REST، ربط الطلبات، متغيرات المسار، أجسام الطلبات، ResponseEntity، دلالات HTTP، تصميم CRUD، تسلسل Jackson، والإصدارات. ستبني واجهة برمجية لكتالوج الكتب من الصفر: تطبيق Spring Boot 3 مكتفٍ بذاته، يعتمد بنية متعددة الطبقات، ومعالجة أخطاء صحيحة، وسطح موارد موثَّق وذو إصدارات. اعمل عليه من أوله لآخره وستمتلك قالبًا جاهزًا لكل خدمة REST تبنيها من بعده.

نظرة عامة على المشروع

تُدير الخدمة كتالوجًا للكتب. يمكن للعملاء إنشاء الكتب واسترجاعها وتحديثها وحذفها، وسرد جميع الكتب مع تصفية اختيارية، والبحث بالرقم الدولي ISBN. تُصدر الواجهة العامة تحت المسار /api/v1. جميع الاستجابات بصيغة JSON؛ وتتبع الأخطاء معيار RFC 7807.

  • النطاق: Book (id, title, author, isbn, publishedYear, available)
  • التخزين: ConcurrentHashMap في الذاكرة (استبدلها بـ JPA دون تغيير أي وحدة تحكّم)
  • الطبقات: Controller ← Service ← Repository
  • معالجة الأخطاء: معالج عام @ControllerAdvice
  • الإصدارات: استراتيجية مسار URL (/api/v1/books)

الخطوة 1 — إعداد المشروع (pom.xml)

ابدأ باختيارات Spring Initializr: Spring Web وValidation. التبعيات الأساسية في Maven:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.0</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>

الخطوة 2 — نموذج النطاق وكائنات نقل البيانات (DTOs)

افصل بين الكيان (ما يُخزَّن) وكائن نقل البيانات (ما يعبر الشبكة). يتيح لك ذلك تطوير النموذج الداخلي دون كسر عقد العملاء.

// domain/Book.java package com.example.catalog.domain; public class Book { private Long id; private String title; private String author; private String isbn; private int publishedYear; private boolean available; // constructors, getters, setters omitted for brevity }
// dto/BookRequest.java — ما يرسله العميل في POST/PUT package com.example.catalog.dto; import jakarta.validation.constraints.*; public record BookRequest( @NotBlank String title, @NotBlank String author, @NotBlank @Pattern(regexp = "\\d{13}") String isbn, @Min(1450) @Max(2100) int publishedYear, boolean available ) {}
// dto/BookResponse.java — ما تُعيده الواجهة البرمجية دائمًا package com.example.catalog.dto; public record BookResponse( Long id, String title, String author, String isbn, int publishedYear, boolean available ) {}
السجلات (Records) بوصفها كائنات نقل بيانات: تمنحك سجلات Java 16+ حوامل بيانات غير قابلة للتغيير مع توليد تلقائي للبنّاءات والوصولات وequals وhashCode وtoString. إنها مثالية لأشكال الطلب/الاستجابة — لا يمكنك تغييرها عن طريق الخطأ في منتصف الطلب.

الخطوة 3 — طبقة المستودع (Repository)

تُخفي طبقة المستودع تفاصيل التخزين. هنا تستخدم خريطة في الذاكرة؛ إصدار JPA سيُنفّذ الواجهة ذاتها دون تغيير أي طبقة أخرى.

// repository/BookRepository.java package com.example.catalog.repository; import com.example.catalog.domain.Book; import org.springframework.stereotype.Repository; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @Repository public class BookRepository { private final Map<Long, Book> store = new ConcurrentHashMap<>(); private final AtomicLong idSequence = new AtomicLong(1); public Book save(Book book) { if (book.getId() == null) { book.setId(idSequence.getAndIncrement()); } store.put(book.getId(), book); return book; } public Optional<Book> findById(Long id) { return Optional.ofNullable(store.get(id)); } public Optional<Book> findByIsbn(String isbn) { return store.values().stream() .filter(b -> b.getIsbn().equals(isbn)) .findFirst(); } public List<Book> findAll() { return List.copyOf(store.values()); } public boolean delete(Long id) { return store.remove(id) != null; } }

الخطوة 4 — طبقة الخدمة (Service)

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

// service/BookService.java package com.example.catalog.service; import com.example.catalog.domain.Book; import com.example.catalog.dto.*; import com.example.catalog.exception.BookNotFoundException; import com.example.catalog.exception.DuplicateIsbnException; import com.example.catalog.repository.BookRepository; import org.springframework.stereotype.Service; import java.util.List; @Service public class BookService { private final BookRepository repo; public BookService(BookRepository repo) { this.repo = repo; } public BookResponse create(BookRequest req) { repo.findByIsbn(req.isbn()).ifPresent(b -> { throw new DuplicateIsbnException(req.isbn()); }); Book book = toEntity(req); return toResponse(repo.save(book)); } public BookResponse findById(Long id) { return toResponse(repo.findById(id) .orElseThrow(() -> new BookNotFoundException(id))); } public List<BookResponse> findAll() { return repo.findAll().stream().map(this::toResponse).toList(); } public BookResponse update(Long id, BookRequest req) { Book existing = repo.findById(id) .orElseThrow(() -> new BookNotFoundException(id)); existing.setTitle(req.title()); existing.setAuthor(req.author()); existing.setIsbn(req.isbn()); existing.setPublishedYear(req.publishedYear()); existing.setAvailable(req.available()); return toResponse(repo.save(existing)); } public void delete(Long id) { if (!repo.delete(id)) throw new BookNotFoundException(id); } private Book toEntity(BookRequest r) { Book b = new Book(); b.setTitle(r.title()); b.setAuthor(r.author()); b.setIsbn(r.isbn()); b.setPublishedYear(r.publishedYear()); b.setAvailable(r.available()); return b; } private BookResponse toResponse(Book b) { return new BookResponse(b.getId(), b.getTitle(), b.getAuthor(), b.getIsbn(), b.getPublishedYear(), b.isAvailable()); } }

الخطوة 5 — وحدة تحكم REST

مهمة وحدة التحكم الوحيدة هي HTTP: تحليل الطلب واستدعاء الخدمة وتشكيل الاستجابة. لا يوجد في هذا الملف أي قرار عملي.

// controller/BookController.java package com.example.catalog.controller; import com.example.catalog.dto.*; import com.example.catalog.service.BookService; import jakarta.validation.Valid; import org.springframework.http.*; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.net.URI; import java.util.List; @RestController @RequestMapping("/api/v1/books") public class BookController { private final BookService service; public BookController(BookService service) { this.service = service; } @PostMapping public ResponseEntity<BookResponse> create(@Valid @RequestBody BookRequest req) { BookResponse created = service.create(req); URI location = ServletUriComponentsBuilder.fromCurrentRequest() .path("/{id}") .buildAndExpand(created.id()) .toUri(); return ResponseEntity.created(location).body(created); } @GetMapping public ResponseEntity<List<BookResponse>> listAll() { return ResponseEntity.ok(service.findAll()); } @GetMapping("/{id}") public ResponseEntity<BookResponse> getOne(@PathVariable Long id) { return ResponseEntity.ok(service.findById(id)); } @PutMapping("/{id}") public ResponseEntity<BookResponse> update( @PathVariable Long id, @Valid @RequestBody BookRequest req) { return ResponseEntity.ok(service.update(id, req)); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { service.delete(id); return ResponseEntity.noContent().build(); } }
أعد ترويسة Location عند استجابة POST 201: يبني ServletUriComponentsBuilder.fromCurrentRequest() رابط المورد الجديد من سياق الطلب الحالي. هذا هو عقد REST القياسي — يجب ألا يحتاج العملاء إلى بناء الروابط بأنفسهم.

الخطوة 6 — معالج الأخطاء العام

مركز استجابات الأخطاء في @ControllerAdvice واحد. يُعيد كل معالج جسمًا بأسلوب RFC 7807 حتى يحصل العملاء دائمًا على شكل خطأ متسق.

// exception/GlobalExceptionHandler.java package com.example.catalog.exception; import org.springframework.http.*; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.*; import java.time.Instant; import java.util.*; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BookNotFoundException.class) public ResponseEntity<Map<String, Object>> handleNotFound(BookNotFoundException ex) { return buildError(HttpStatus.NOT_FOUND, ex.getMessage()); } @ExceptionHandler(DuplicateIsbnException.class) public ResponseEntity<Map<String, Object>> handleDuplicate(DuplicateIsbnException ex) { return buildError(HttpStatus.CONFLICT, ex.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, Object>> handleValidation( MethodArgumentNotValidException ex) { List<String> errors = ex.getBindingResult().getFieldErrors().stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .toList(); Map<String, Object> body = new LinkedHashMap<>(); body.put("timestamp", Instant.now()); body.put("status", 422); body.put("errors", errors); return ResponseEntity.unprocessableEntity().body(body); } private ResponseEntity<Map<String, Object>> buildError(HttpStatus status, String msg) { Map<String, Object> body = new LinkedHashMap<>(); body.put("timestamp", Instant.now()); body.put("status", status.value()); body.put("error", status.getReasonPhrase()); body.put("message", msg); return ResponseEntity.status(status).body(body); } }
لا تُسرّب تتبّعات المكدس إلى العملاء أبدًا. يمكن لنقطة النهاية الافتراضية /error في Spring Boot تضمين تفاصيل الاستثناء في وضع التطوير. اضبط server.error.include-stacktrace=never وserver.error.include-message=never في application.properties لملفات تعريف الإنتاج لمنع كشف المعلومات.

الخطوة 7 — اختبار الواجهة البرمجية

مع تشغيل التطبيق (mvn spring-boot:run)، اختبر دورة الحياة الكاملة:

# إنشاء كتاب — توقّع 201 Created + ترويسة Location curl -s -X POST http://localhost:8080/api/v1/books \ -H "Content-Type: application/json" \ -d '{"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","publishedYear":2008,"available":true}' \ | jq . # سرد جميع الكتب — توقّع 200 OK curl -s http://localhost:8080/api/v1/books | jq . # جلب بالمعرف — توقّع 200 OK curl -s http://localhost:8080/api/v1/books/1 | jq . # تحديث — توقّع 200 OK curl -s -X PUT http://localhost:8080/api/v1/books/1 \ -H "Content-Type: application/json" \ -d '{"title":"Clean Code","author":"Robert C. Martin","isbn":"9780132350884","publishedYear":2008,"available":false}' \ | jq . # حذف — توقّع 204 No Content curl -s -X DELETE http://localhost:8080/api/v1/books/1 -o /dev/null -w "%{http_code}" # ISBN مكرر — توقّع 409 Conflict # غير موجود — توقّع 404 Not Found # جسم غير صالح (عنوان مفقود) — توقّع 422 Unprocessable Entity

مراجعة البنية وما يمكن بناؤه لاحقًا

تُشكّل الطبقات في هذا المشروع نمطًا قابلًا للتكرار: Controller (HTTP) — Service (منطق + تحويل) — Repository (تخزين). لكل طبقة سبب وحيد للتغيير. لتوسيع هذا المشروع نحو الإنتاج:

  • استبدل المستودع في الذاكرة بـ Spring Data JPA مع H2 أو PostgreSQL — دون أي تغيير في وحدة التحكم أو الخدمة.
  • أضف Spring Security مع مصادقة JWT — تُعترض على مستوى فلتر السيرفلت، وغير مرئية لمنطق العمل.
  • ادعم الصفحات: غيّر findAll() لقبول معامل Pageable وإعادة Page<BookResponse>.
  • وثّق الواجهة البرمجية بـ SpringDoc OpenAPI — أضف التبعية، وضع تعليقات @Operation و@ApiResponse، وافتح /swagger-ui.html.
  • أضف اختبارات تكاملية بـ @SpringBootTest وMockMvc لكل نقطة نهاية وكل مسار خطأ.
تهانينا — لقد أتممت وحدة واجهات برمجية REST مع Spring Boot. تمتلك الآن خدمة REST نظيفة ومتعددة الطبقات وذات إصدارات وآمنة من الأخطاء. الأنماط التي طبّقتها هنا — وحدات تحكم رقيقة، تحويل على مستوى الخدمة، معالجة مركزية للاستثناءات، وفصل كائنات نقل البيانات — تتوسّع لتطبيقات المؤسسات بأي حجم.