Ticker

6/recent/ticker-posts

Laravel API Documentation với OpenAPI Specification 3.0/Swagger UI

Trong bài viết này có vài gạch đầu dòng như sau: 

1. Tìm hiểu qua về OpenAPI Specification/Swagger UI là gì 
2. Triển khai trên Server API sử dụng Laravel 8.x và Swagger package 

    Bài viết này chỉ đơn thuần tìm hiểu về Open API và cách sử dụng nó với Laravel 8. Vệc đánh giá về khả năng sử dụng và nên sử dụng nó vào trường hợp dự án thế nào? xin trình bày trong một bài viết khác. 

1. Tìm hiểu qua về OpenAPI Specification/Swagger UI

    OpenAPI Specification là gì?  

     Nó là một format - chuẩn chung để có thể viết tài liệu cho API. Khi phát triển các Server API hay khi phải làm dự án nào liên quan tới API, chúng ta đều mong muốn có một tài liệu chuẩn chỉ, được cập nhật thường xuyên. Tránh mấy việc bên Front-End và Back-End bàn tới bàn lui, hỏi đi hỏi lại; hoặc khi bàn giao cho khách hàng, cũng cần những tài liệu mô tả một cách chuẩn chỉ. Không chỉ là những templates sử dụng theo format của một công ty nào đó. Đây có thể là một trong những chuẩn mô tả API chúng ta có thể sử dụng. 

    Bạn có thể tự đọc chi tiết hơn về nó ở đây và trang chủ của Swagger Specification
    
    Swagger UI là gì? 
    Nó là một cái giao diện web đẹp mắt, tiện dụng như thế này đây?
    Ở trên cái Swagger UI này chúng ta có thể xem các API: method của nó là gì? các gọi thế nào? request header/body ra làm sao, response header/body thế nào. Thậm chí có thể tương tác luôn với Server API từ UI này luôn, xịn hơn nữa là nó có thể xác thực đăng nhập để tạo token nếu sử dụng Oauth2/Passport hay apiKey; hay xác thực Basic Auth cho an toàn được luôn. 


2. Triển khai trên Server API sử dụng Laravel 8.x và Swagger package 

Trong ví dụ này sử dụng Laravel 8.x và Swagger package là L5-Swagger 

Server API ở đây sử dụng một sample đơn giản lấy từ ZeroBug 8. Hoặc có thể build nhanh một Server API sử dụng Laravel 8.x khác tuỳ bạn.

Cài đặt Swagger UI thì có vài bước như sau: 

Cài đặt package:

 composer require darkaonline/l5-swagger

 php artisan vendor:publish --provider "L5Swagger\L5SwaggerServiceProvider"

Tiếp theo cần chỉnh sửa một chút trong config/l5-swagger.php 

Có một vài chỗ bạn cần để ý như sau. Phần cấu hình route mà sẽ truy cập vào:

'routes' => [
    /*
        * Route for accessing api documentation interface
    */
    'api' => 'api/documentation',
]

Dưới đây là chỗ cần cấu hình để có thể tương tác với Server API ngay trên Swagger UI. Vì Server API này sử dụng bearer token của Laravel Passport, nên để có thể gọi được API phải có thêm cấu hình để Swaggercó thể lấy được cái token cho các request.

'passport' => [ // Unique name of security
    'type' => 'oauth2', // The type of the security scheme. Valid values are "basic", "apiKey" or "oauth2".
    'description' => 'Laravel passport oauth2 security.',
    'in' => 'header',
    //'scheme' => 'https',
    'scheme' => 'http',
    'flows' => [
        "password" => [
            "authorizationUrl" => config('app.url') . '/oauth/authorize',
            "tokenUrl" => config('app.url') . '/oauth/token',
            "refreshUrl" => config('app.url') . '/token/refresh',
            "scopes" => []
        ],
    ],
],
Tiếp theo là phần viết các comments để Swagger Package generate ra OpenAPI Specification, có cái OAS (OpenAPI Sepcification) thì Swagger UI mới hoạt động được. Cũng nói thêm OAS này cũng có thể import thẳng vào PostMan để chúng ta tương tác một cách dễ dàng hơn, viết các Unit Test ... Swagger UI chỉ là một trong những thứ sử dụng lại cái OAS này thôi.

Thêm comment vào app/Http/Controllers/Api/Controller.php - đây là cái Controller base sử dụng cho các API controllers.


/**
 * @OA\Info(
 *      version="0.8.1",
 *      title="Zerobug OpenApi Demo Documentation",
 *      description="Swagger OpenApi description",
 *      @OA\Contact(
 *          email="admin@zeroblog.net"
 *      ),
 *      @OA\License(
 *          name="ZeroBlog",
 *          url="https://www.zeroblog.net"
 *      )
 * )
 *
 * @OA\Server(
 *      url=L5_SWAGGER_CONST_HOST,
 *      description="Zerobug API Server"
 * )
 *
 * @OA\Tag(
 *     name="Zerobug",
 *     description="API Endpoints of Projects"
 * )
 * */
class Controller extends BaseController
{
    use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}


Tiếp theo là các API controllers.

class NewsApiController extends Controller
{
    use MediaUploadingTrait;

    /**
     * @OA\Get(
     *      path="/api/v1/news",
     *      operationId="getNewsList",
     *      tags={"News"},
     *      summary="Get list of news",
     *      description="Returns list of news",
     *      @OA\Response(
     *          response=200,
     *          description="Successful operation",
     *          @OA\JsonContent(ref="#/components/schemas/NewsResource")
     *       ),
     *      @OA\Response(
     *          response=401,
     *          description="Unauthenticated",
     *      ),
     *      @OA\Response(
     *          response=403,
     *          description="Forbidden"
     *      ),
     *      security={ 
     *          {"passport": {}}, 
     *      }, 
     *     )
     */
    public function index()
    {
        abort_if(Gate::denies('news_access'), Response::HTTP_FORBIDDEN, '403 Forbidden');

        return new NewsResource(News::all());
    }

    /**
     * @OA\Post(
     *      path="/api/v1/news",
     *      operationId="storeNews",
     *      tags={"News"},
     *      summary="Store news",
     *      description="Returns news data",
     *      @OA\RequestBody(
     *          required=true,
     *          @OA\JsonContent(ref="#/components/schemas/StoreNewsRequest")
     *      ),
     *      @OA\Response(
     *          response=201,
     *          description="Successful operation",
     *          @OA\JsonContent(ref="#/components/schemas/News")
     *       ),
     *      @OA\Response(
     *          response=400,
     *          description="Bad Request"
     *      ),
     *      @OA\Response(
     *          response=401,
     *          description="Unauthenticated",
     *      ),
     *      @OA\Response(
     *          response=403,
     *          description="Forbidden"
     *      ),
     *      security={ 
     *          {"passport": {}}, 
     *      },
     * )
     */
    public function store(StoreNewsRequest $request)
    {
        $news = News::create($request->all());

        return (new NewsResource($news))
            ->response()
            ->setStatusCode(Response::HTTP_CREATED);
    }

    /**
     * @OA\Get(
     *      path="/api/v1/news/{id}",
     *      operationId="getNewsById",
     *      tags={"News"},
     *      summary="Get news information",
     *      description="Returns news data",
     *      @OA\Parameter(
     *          name="id",
     *          description="news id",
     *          required=true,
     *          in="path",
     *          @OA\Schema(
     *              type="integer"
     *          )
     *      ),
     *      @OA\Response(
     *          response=200,
     *          description="Successful operation",
     *          @OA\JsonContent(ref="#/components/schemas/News")
     *       ),
     *      @OA\Response(
     *          response=400,
     *          description="Bad Request"
     *      ),
     *      @OA\Response(
     *          response=401,
     *          description="Unauthenticated",
     *      ),
     *      @OA\Response(
     *          response=403,
     *          description="Forbidden"
     *      ),
     *      security={ 
     *          {"passport": {}}, 
     *      },
     * )
     */
    public function show(News $news)
    {
        abort_if(Gate::denies('news_show'), Response::HTTP_FORBIDDEN, '403 Forbidden');

        return new NewsResource($news);
    }

    /**
     * @OA\Put(
     *      path="/api/v1/news/{id}",
     *      operationId="updateNews",
     *      tags={"News"},
     *      summary="Update existing news",
     *      description="Returns updated news data",
     *      @OA\Parameter(
     *          name="id",
     *          description="News id",
     *          required=true,
     *          in="path",
     *          @OA\Schema(
     *              type="integer"
     *          )
     *      ),
     *      @OA\RequestBody(
     *          required=true,
     *          @OA\JsonContent(ref="#/components/schemas/UpdateNewsRequest")
     *      ),
     *      @OA\Response(
     *          response=202,
     *          description="Successful operation",
     *          @OA\JsonContent(ref="#/components/schemas/News")
     *       ),
     *      @OA\Response(
     *          response=400,
     *          description="Bad Request",
     *      ),
     *      @OA\Response(
     *          response=401,
     *          description="Unauthenticated",
     *      ),
     *      @OA\Response(
     *          response=403,
     *          description="Forbidden"
     *      ),
     *      @OA\Response(
     *          response=404,
     *          description="Resource Not Found"
     *      ),
     *      security={ 
     *          {"passport": {}}, 
     *      },
     * )
     */
    public function update(UpdateNewsRequest $request, News $news)
    {
        $news->update($request->all());

        return (new NewsResource($news))
            ->response()
            ->setStatusCode(Response::HTTP_ACCEPTED);
    }

    /**
     * @OA\Delete(
     *      path="/api/v1/news/{id}",
     *      operationId="deleteNews",
     *      tags={"News"},
     *      summary="Delete existing news",
     *      description="Deletes a record and returns no content",
     *      @OA\Parameter(
     *          name="id",
     *          description="News id",
     *          required=true,
     *          in="path",
     *          @OA\Schema(
     *              type="integer"
     *          )
     *      ),
     *      @OA\Response(
     *          response=204,
     *          description="Successful operation",
     *          @OA\JsonContent()
     *       ),
     *      @OA\Response(
     *          response=401,
     *          description="Unauthenticated",
     *      ),
     *      @OA\Response(
     *          response=403,
     *          description="Forbidden"
     *      ),
     *      @OA\Response(
     *          response=404,
     *          description="Resource Not Found"
     *      ),
     *      security={ 
     *          {"passport": {}}, 
     *      },
     * )
     */
    public function destroy(News $news)
    {
        abort_if(Gate::denies('news_delete'), Response::HTTP_FORBIDDEN, '403 Forbidden');

        $news->delete();

        return response(null, Response::HTTP_NO_CONTENT);
    }
}
Do sử dụng Resource, nên cần viết comments trong các Resources và Models nữa.

Resource:
/**
 * @OA\Schema(
 *     title="NewsResource",
 *     description="News resource",
 *     @OA\Xml(
 *         name="NewsResource"
 *     )
 * )
 */
class NewsResource extends JsonResource
{
    /**
     * @OA\Property(
     *     title="Data",
     *     description="Data wrapper"
     * )
     *
     * @var \App\Models\News[]
     */
    private $data;

    public function toArray($request)
    {
        return parent::toArray($request);
    }
}
Và Model:

/**
 * @OA\Schema(
 *     title="News",
 *     description="News model",
 *     @OA\Xml(
 *         name="News"
 *     )
 * )
 */

class News extends Model implements HasMedia
{
    use SoftDeletes, InteractsWithMedia, HasFactory;

    public $table = 'news';

    protected $dates = [
        'created_at',
        'updated_at',
        'deleted_at',
    ];

    protected $fillable = [
        'title',
        'desc',
        'meta',
        'content',
        'created_at',
        'updated_at',
        'deleted_at',
    ];

    /**
     * @OA\Property(
     *     title="ID",
     *     description="ID",
     *     format="int64",
     *     example=1
     * )
     *
     * @var integer
     */
    private $id;

    /**
     * @OA\Property(
     *      title="Title",
     *      description="Title of the news",
     *      example="A nice article"
     * )
     *
     * @var string
     */
    public $title;

    /**
     * @OA\Property(
     *      title="Description",
     *      description="Description of the news",
     *      example="This is news' description"
     * )
     *
     * @var string
     */
    public $desc;

    /**
     * @OA\Property(
     *      title="Meta",
     *      description="Meta of the news",
     *      example="This is news' meta"
     * )
     *
     * @var string
     */
    public $meta;

    /**
     * @OA\Property(
     *      title="Content",
     *      description="Content of the news",
     *      example="This is news' content"
     * )
     *
     * @var string
     */
    public $content;

    /**
     * @OA\Property(
     *     title="Created at",
     *     description="Created at",
     *     example="2021-07-14 17:50:45",
     *     format="datetime",
     *     type="string"
     * )
     *
     * @var \DateTime
     */
    private $created_at;

    /**
     * @OA\Property(
     *     title="Updated at",
     *     description="Updated at",
     *     example="2021-07-14 17:50:45",
     *     format="datetime",
     *     type="string"
     * )
     *
     * @var \DateTime
     */
    private $updated_at;

    /**
     * @OA\Property(
     *     title="Deleted at",
     *     description="Deleted at",
     *     example="2021-07-14 17:50:45",
     *     format="datetime",
     *     type="string"
     * )
     *
     * @var \DateTime
     */
    private $deleted_at;

    protected function serializeDate(DateTimeInterface $date)
    {
        return $date->format('Y-m-d H:i:s');
    }

    public function registerMediaConversions(Media $media = null): void
    {
        $this->addMediaConversion('thumb')->fit('crop', 50, 50);
        $this->addMediaConversion('preview')->fit('crop', 120, 120);
    }
}
Giờ thì generate OAS:

php artisan l5-swagger:generate
Để lần sau chúng ta không phải chạy lại lệnh này mỗi lần thay đổi comments nữa, thì quay lại chỉnh sửa một chút trong config/l5-swagger.php file:
/*
    * Set this to `true` in development mode so that docs would be regenerated on each request
    * Set this to `false` to disable swagger generation on production
*/
'generate_always' => env('L5_SWAGGER_GENERATE_ALWAYS', false),

Okie. Giờ truy cập vào uri: api/documentation để xem Swagger UI này thế nào nhá. Việc thao tác thì khá là đơn giản.  Đầu tiên thì xác thực để lấy Bearer Token 

login passport


logged in


Giờ có thể gọi luôn một API trên Swagger UI này. Chọn vào "Try it out":
try it out

Data của Request Body có thể sửa đổi được.

request api


Rồi, giờ là kết quả

response 1

Response 2

Tất cả các APIs khác đều có thể xem tham số đầu vào, format output, call luôn tại Swagger UI này. Khách hàng không biết technical nhiều mà có tài liệu này đúng là rất trực quan đúng không ;)

Enjoy your day!

Post a Comment

0 Comments