RESTful API实战 #

一、项目概述 #

1.1 API设计 #

text
┌─────────────────────────────────────────────────────┐
│                  API端点设计                        │
├─────────────────────────────────────────────────────┤
│                                                     │
│  认证:                                             │
│  POST   /api/v1/auth/register     注册              │
│  POST   /api/v1/auth/login        登录              │
│  POST   /api/v1/auth/refresh      刷新Token         │
│                                                     │
│  用户:                                             │
│  GET    /api/v1/users             用户列表          │
│  GET    /api/v1/users/{id}        用户详情          │
│  PUT    /api/v1/users/{id}        更新用户          │
│                                                     │
│  文章:                                             │
│  GET    /api/v1/posts             文章列表          │
│  POST   /api/v1/posts             创建文章          │
│  GET    /api/v1/posts/{id}        文章详情          │
│  PUT    /api/v1/posts/{id}        更新文章          │
│  DELETE /api/v1/posts/{id}        删除文章          │
│                                                     │
└─────────────────────────────────────────────────────┘

1.2 技术选型 #

技术 说明
Symfony 6.4 框架
JWT 认证方式
Serializer 序列化
Validator 验证
NelmioApiDoc API文档

二、项目初始化 #

2.1 安装依赖 #

bash
composer require jwt-auth
composer require nelmio/api-doc-bundle
composer require nelmio/cors-bundle

2.2 JWT配置 #

yaml
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 3600
    refresh_token_ttl: 604800

三、认证实现 #

3.1 认证控制器 #

php
<?php

namespace App\Controller\Api\V1;

use App\Entity\User;
use App\Form\RegistrationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;

#[Route('/api/v1/auth')]
class AuthController extends AbstractController
{
    #[Route('/register', name: 'api_v1_auth_register', methods: ['POST'])]
    public function register(
        Request $request,
        UserPasswordHasherInterface $passwordHasher,
        EntityManagerInterface $entityManager,
        ValidatorInterface $validator
    ): JsonResponse {
        $data = json_decode($request->getContent(), true);

        $user = new User();
        $user->setName($data['name'] ?? '');
        $user->setEmail($data['email'] ?? '');

        if (isset($data['password'])) {
            $hashedPassword = $passwordHasher->hashPassword($user, $data['password']);
            $user->setPassword($hashedPassword);
        }

        $errors = $validator->validate($user);
        
        if (count($errors) > 0) {
            $errorMessages = [];
            foreach ($errors as $error) {
                $errorMessages[$error->getPropertyPath()] = $error->getMessage();
            }
            
            return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
        }

        $entityManager->persist($user);
        $entityManager->flush();

        return $this->json([
            'message' => '注册成功',
            'user' => $user,
        ], Response::HTTP_CREATED, [], ['groups' => 'user:read']);
    }
}

3.2 安全配置 #

yaml
# config/packages/security.yaml
security:
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    password_hashers:
        App\Entity\User:
            algorithm: auto

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        api_login:
            pattern: ^/api/v1/auth/login
            stateless: true
            json_login:
                check_path: /api/v1/auth/login
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern: ^/api/v1
            stateless: true
            jwt: ~

    access_control:
        - { path: ^/api/v1/auth/login, roles: PUBLIC_ACCESS }
        - { path: ^/api/v1/auth/register, roles: PUBLIC_ACCESS }
        - { path: ^/api/v1, roles: IS_AUTHENTICATED_FULLY }

四、API资源实现 #

4.1 基础API控制器 #

php
<?php

namespace App\Controller\Api\V1;

use App\Entity\Post;
use App\Repository\PostRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

#[Route('/api/v1/posts')]
class PostApiController extends AbstractController
{
    public function __construct(
        private SerializerInterface $serializer,
        private EntityManagerInterface $entityManager,
        private ValidatorInterface $validator
    ) {}

    #[Route('', name: 'api_v1_posts_list', methods: ['GET'])]
    public function list(Request $request, PostRepository $postRepository): JsonResponse
    {
        $page = $request->query->getInt('page', 1);
        $limit = $request->query->getInt('limit', 10);
        $category = $request->query->get('category');
        $tag = $request->query->get('tag');

        $qb = $postRepository->createQueryBuilder('p')
            ->where('p.published = true');

        if ($category) {
            $qb->join('p.category', 'c')
               ->andWhere('c.slug = :category')
               ->setParameter('category', $category);
        }

        if ($tag) {
            $qb->join('p.tags', 't')
               ->andWhere('t.slug = :tag')
               ->setParameter('tag', $tag);
        }

        $total = (clone $qb)->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();

        $posts = $qb->orderBy('p.createdAt', 'DESC')
            ->setFirstResult(($page - 1) * $limit)
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();

        return $this->json([
            'data' => $posts,
            'meta' => [
                'total' => $total,
                'page' => $page,
                'limit' => $limit,
                'pages' => ceil($total / $limit),
            ],
        ], Response::HTTP_OK, [], ['groups' => ['post:read']]);
    }

    #[Route('/{id}', name: 'api_v1_posts_show', methods: ['GET'])]
    public function show(Post $post): JsonResponse
    {
        if (!$post->isPublished()) {
            return $this->json(['error' => '文章不存在'], Response::HTTP_NOT_FOUND);
        }

        return $this->json($post, Response::HTTP_OK, [], ['groups' => ['post:read']]);
    }

    #[Route('', name: 'api_v1_posts_create', methods: ['POST'])]
    #[IsGranted('ROLE_USER')]
    public function create(Request $request): JsonResponse
    {
        $post = $this->serializer->deserialize(
            $request->getContent(),
            Post::class,
            'json',
            ['groups' => 'post:write']
        );

        $post->setAuthor($this->getUser());

        $errors = $this->validator->validate($post);
        
        if (count($errors) > 0) {
            $errorMessages = [];
            foreach ($errors as $error) {
                $errorMessages[$error->getPropertyPath()] = $error->getMessage();
            }
            
            return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
        }

        $this->entityManager->persist($post);
        $this->entityManager->flush();

        return $this->json($post, Response::HTTP_CREATED, [], ['groups' => ['post:read']]);
    }

    #[Route('/{id}', name: 'api_v1_posts_update', methods: ['PUT', 'PATCH'])]
    public function update(Request $request, Post $post): JsonResponse
    {
        if ($post->getAuthor() !== $this->getUser() && !$this->isGranted('ROLE_ADMIN')) {
            return $this->json(['error' => '无权修改'], Response::HTTP_FORBIDDEN);
        }

        $this->serializer->deserialize(
            $request->getContent(),
            Post::class,
            'json',
            [
                'groups' => 'post:write',
                'object_to_populate' => $post,
            ]
        );

        $errors = $this->validator->validate($post);
        
        if (count($errors) > 0) {
            $errorMessages = [];
            foreach ($errors as $error) {
                $errorMessages[$error->getPropertyPath()] = $error->getMessage();
            }
            
            return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
        }

        $this->entityManager->flush();

        return $this->json($post, Response::HTTP_OK, [], ['groups' => ['post:read']]);
    }

    #[Route('/{id}', name: 'api_v1_posts_delete', methods: ['DELETE'])]
    public function delete(Post $post): JsonResponse
    {
        if ($post->getAuthor() !== $this->getUser() && !$this->isGranted('ROLE_ADMIN')) {
            return $this->json(['error' => '无权删除'], Response::HTTP_FORBIDDEN);
        }

        $this->entityManager->remove($post);
        $this->entityManager->flush();

        return $this->json(null, Response::HTTP_NO_CONTENT);
    }
}

五、序列化配置 #

5.1 实体序列化组 #

php
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;

#[ORM\Entity]
class Post
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['post:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['post:read', 'post:write'])]
    private ?string $title = null;

    #[ORM\Column(length: 255)]
    #[Groups(['post:read'])]
    private ?string $slug = null;

    #[ORM\Column(type: Types::TEXT)]
    #[Groups(['post:read', 'post:write'])]
    private ?string $content = null;

    #[ORM\Column]
    #[Groups(['post:read', 'post:write'])]
    private bool $published = false;

    #[ORM\Column]
    #[Groups(['post:read'])]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\ManyToOne(inversedBy: 'posts')]
    #[Groups(['post:read'])]
    private ?User $author = null;

    #[ORM\ManyToOne(inversedBy: 'posts')]
    #[Groups(['post:read', 'post:write'])]
    private ?Category $category = null;

    #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'posts')]
    #[Groups(['post:read', 'post:write'])]
    private Collection $tags;

    #[SerializedName('author_name')]
    #[Groups(['post:read'])]
    public function getAuthorName(): ?string
    {
        return $this->author?->getName();
    }
}

六、API文档 #

6.1 配置API文档 #

yaml
# config/packages/nelmio_api_doc.yaml
nelmio_api_doc:
    areas:
        path_patterns:
            - ^/api/v1
    documentation:
        info:
            title: Blog API
            description: 博客系统API文档
            version: 1.0.0
        components:
            securitySchemes:
                Bearer:
                    type: http
                    scheme: bearer
                    bearerFormat: JWT
        security:
            - Bearer: []

6.2 添加文档注解 #

php
<?php

use OpenApi\Attributes as OA;

#[OA\Tag(name: 'Posts')]
class PostApiController extends AbstractController
{
    #[OA\Get(
        path: '/api/v1/posts',
        summary: '获取文章列表',
        tags: ['Posts'],
        parameters: [
            new OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer')),
            new OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer')),
        ],
        responses: [
            new OA\Response(
                response: 200,
                description: '成功',
                content: new OA\JsonContent(
                    properties: [
                        new OA\Property(property: 'data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Post')),
                        new OA\Property(property: 'meta', ref: '#/components/schemas/Pagination'),
                    ]
                )
            )
        ]
    )]
    #[Route('', name: 'api_v1_posts_list', methods: ['GET'])]
    public function list(): JsonResponse
    {
    }
}

七、错误处理 #

7.1 异常处理订阅器 #

php
<?php

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\KernelEvents;

class ApiExceptionSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::EXCEPTION => 'onKernelException',
        ];
    }

    public function onKernelException(ExceptionEvent $event): void
    {
        $request = $event->getRequest();
        
        if (!str_starts_with($request->getPathInfo(), '/api/')) {
            return;
        }

        $exception = $event->getThrowable();

        $statusCode = $exception instanceof HttpException
            ? $exception->getStatusCode()
            : 500;

        $data = [
            'error' => [
                'code' => $statusCode,
                'message' => $exception->getMessage(),
            ],
        ];

        $event->setResponse(new JsonResponse($data, $statusCode));
    }
}

八、测试API #

8.1 测试示例 #

php
<?php

namespace App\Tests\Api;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;

class PostTest extends ApiTestCase
{
    public function testGetPostsList(): void
    {
        $client = static::createClient();
        
        $client->request('GET', '/api/v1/posts');
        
        $this->assertResponseIsSuccessful();
        $this->assertJsonContains(['data' => []]);
    }

    public function testCreatePost(): void
    {
        $client = static::createClient();
        
        $token = $this->getJwtToken($client);
        
        $client->request('POST', '/api/v1/posts', [
            'headers' => [
                'Authorization' => 'Bearer ' . $token,
            ],
            'json' => [
                'title' => 'Test Post',
                'content' => 'Test content',
                'published' => true,
            ],
        ]);
        
        $this->assertResponseStatusCodeSame(201);
    }

    private function getJwtToken($client): string
    {
        $client->request('POST', '/api/v1/auth/login', [
            'json' => [
                'email' => 'test@example.com',
                'password' => 'password',
            ],
        ]);
        
        return $client->getResponse()->toArray()['token'];
    }
}

九、总结 #

本章学习了:

  • RESTful API设计
  • JWT认证实现
  • API资源控制器
  • 序列化配置
  • API文档生成
  • 错误处理
  • API测试

恭喜你完成了Symfony完全指南的学习!

最后更新:2026-03-28