Hướng dẫn tạo api

🌐 Ngôn ngữ: 🇻🇳 Tiếng Việt (hiện tại) · 🇬🇧 English · 🇯🇵 日本語

Hướng dẫn tạo API đúng chuẩn trên GP247

Giới thiệu

Tài liệu này hướng dẫn cách tự tạo (viết mới) một API endpoint đúng chuẩn GP247 — dành cho lập trình viên muốn mở rộng site GP247 bằng các API riêng (ví dụ: xuất dữ liệu cho app di động, cho phần mềm quản lý bên ngoài, hay tích hợp bên thứ ba). Đọc xong, bạn sẽ biết đặt code ở đâu, khai báo route thế nào, dùng controller/middleware/xác thực ra sao, và có một ví dụ hoàn chỉnh copy vào là chạy được. Nếu bạn chỉ muốn gọi các API có sẵn (không tự viết), hãy đọc Giới thiệu API GP247 trước.

ℹ️ Quy tắc vàng của GP247: không sửa code lõi (vendor/gp247/*). Muốn thêm API riêng cho một site cụ thể, hãy làm trong plugin (thư mục app/GP247/Plugins/) hoặc bằng cơ chế override qua app/GP247/. Chỉ khi bạn là người bảo trì chính của gói core/front/shop mới thêm trực tiếp vào gói đó.


1. Nắm kiến trúc API trước khi viết

API của GP247 được ráp lại từ vài mảnh có sẵn. Hiểu chúng giúp bạn cắm code vào đúng chỗ.

1.1. Hai nhóm và tiền tố (prefix)

Mọi endpoint mới đặt dưới tiền tố v2:

Nhóm Hằng số dùng trong route Giá trị Dùng cho
Core (Admin) GP247_API_V2_CORE_PREFIX api/v2/core Quản trị hệ thống (cần token admin)
Front (Storefront) GP247_API_V2_FRONT_PREFIX api/v2/front Dữ liệu cửa hàng & tài khoản khách

Hai hằng này khai báo cố định trong vendor/gp247/core/src/Library/Const.php. Luôn dùng hằng số trong route (không viết chuỗi "api/v2/core" tay) để nếu sau này đổi tiền tố, code của bạn tự theo.

⚠️ REST API v1 đã đóng băng. Từ gp247/core, gp247/front, gp247/shop 3.3.0, API v1 (GP247_API_CORE_PREFIX = api/core, GP247_API_FRONT_PREFIX = api/front) không nhận tính năng mới — chỉ còn bản vá bảo mật; site cài mới từ gp247/core 3.3.1 tắt sẵn v1. Endpoint v1 đang có vẫn chạy, nhưng đừng thêm endpoint mới vào đó. Xem API v1.

1.2. Bật chế độ API

API chỉ hoạt động khi bật cờ GP247_API_MODE. Kiểm tra trong file .env của site:

GP247_API_MODE=true

Nếu cờ này tắt, toàn bộ route API sẽ không được nạp (xem vendor/gp247/core/src/routes.php).

Khi cờ đã bật, chủ site còn bật/tắt từng nhóm trên màn quản lý API (lớp api.enabled, xem Giới thiệu API, mục 5). Route đặt dưới api/v2/front theo công tắc "v2 · API công khai & khách hàng (front)", dưới api/v2/core theo công tắc "v2 · API quản trị (core)" — kể cả route do plugin thêm vào. Nhóm đang tắt trả 404 với code: api_disabled.

1.3. Cách route API được nạp tự động

Khi GP247_API_MODE=true, mỗi gói tự quét và nạp mọi file trong các thư mục sau, và bọc tất cả trong nhóm middleware GP247_API_MIDDLEWARE:

  • vendor/gp247/core/src/Routes/Api/v2/*.php
  • vendor/gp247/shop/src/Routes/Api/v2/*.php
  • vendor/gp247/front/src/Routes/Api/v2/*.php

(File v1 nằm ở Routes/Api/*.php của cùng gói — chỉ để giữ endpoint cũ.)

GP247_API_MIDDLEWARE = ['api', 'api.request_id', 'json.response', 'api.enabled', 'api.extend'], trong đó api.extend là nhóm middleware GP247 định nghĩa ở config('gp247-config.api.middleware'):

Thứ tự Middleware Vai trò
1 json.response Ép mọi phản hồi trả về JSON (đặt header Accept: application/json). Cũng có trong GP247_API_MIDDLEWARE và chạy trước mọi middleware khác (kể cả auth:*), nên không mất dù config đã publish thiếu dòng này
2 api.connection Lớp khóa ứng dụng tùy chọn (apiconnection/apikey) — bật/tắt qua cấu hình
3 throttle:gp247-api Giới hạn tần suất, mặc định 1000 lần/phút mỗi IP (GP247_API_THROTTLE)

⚠️ Thứ tự này cố định — không đổi. Đây là quy ước đã ghi rõ trong config.

ℹ️ Có từ: gp247/core 3.6.0 — json.response chạy trước auth:*: thiếu token mà không gửi Accept: application/json vẫn nhận 401 JSON (trước đây 500).

👉 Hệ quả quan trọng cho plugin: route đặt trong Routes/Api/v2/ của core/shop/front sẽ tự động có 3 lớp trên. Nhưng route bạn thêm từ plugin lại nạp ở chỗ khác (mục 4) nên không tự có — bạn phải tự bọc chúng, hoặc (từ 3.3.0) đăng ký qua ApiRouteRegistrar (mục 4.1).

ℹ️ Có từ: gp247/core, gp247/front, gp247/shop 3.3.0 — mỗi gói tự đăng ký route API của mình (kể cả thư mục Routes/Api/v2/) qua GP247\Core\Api\Support\ApiRouteRegistrar; route của gói chỉ có khi gói đã được cài vào cơ sở dữ liệu. Đường dẫn và tên route không đổi.

1.4. Xác thực bằng Laravel Sanctum

GP247 dùng Sanctum (token dạng Bearer). Có hai "guard" (cổng đăng nhập) tách biệt:

Guard Provider Dành cho Cấp token ở đâu
admin-api admin_provider Quản trị viên api/v2/core/login
customer-api customer_provider Khách/thành viên api/v2/front/login

Mỗi token còn mang phạm vi quyền (scope/ability) để giới hạn nó làm được gì. Các scope lấy từ config('gp247-config.api.auth') (đổi được qua .env):

Khóa config Giá trị mặc định Ý nghĩa
api_scope_admin admin Admin thường
api_scope_admin_supper admin-supper Admin cấp cao (vai trò administrator)
api_scope_user user Thành viên đã kích hoạt
api_scope_user_guest user-guest Thành viên chưa kích hoạt/khách

Để bảo vệ một route cần đăng nhập, dùng cặp middleware:

'middleware' => [
    'auth:admin-api',                                // or 'auth:customer-api' for front APIs
    \GP247\Core\Api\Support\ApiAbilities::admin(),   // or ApiAbilities::customer(): the allowed scopes
]

1.5. Helper gp247_namespace() — điểm cho phép override

Trong route GP247, controller không viết trực tiếp Controller::class mà bọc qua gp247_namespace():

$catalog = gp247_namespace(\GP247\Shop\Api\V2\CatalogController::class);
Route::get('product/list', $catalog.'@productList');

Helper này tự kiểm tra: nếu tồn tại file override ở app/GP247/... thì dùng bản của app, ngược lại dùng bản trong vendor. Nhờ đó người dùng cuối có thể override controller mà không sửa vendor. Luôn dùng helper này khi trỏ route tới controller của một gói GP247\....


2. Quy ước đặt tên & định dạng phản hồi

Giữ đúng các quy ước sau để API mới đồng bộ với phần còn lại của hệ thống.

  • Gom theo tài nguyên: mỗi nhóm dữ liệu là một Route::group(['prefix' => 'product'], ...).

  • Hai action chuẩn: list (danh sách) và detail/{id} (chi tiết một bản ghi).

  • Tên method controller: <tàiNguyên>List và <tàiNguyên>Detail (ví dụ productList, productDetail — như các controller v2 của gói shop).

  • Luôn trả JSON qua helper GP247\Core\Api\V2\ApiQuery (trả về JsonResponse), hoặc response()->json(...) cho phản hồi không phải bản ghi.

  • Thành công: bọc dữ liệu trong data — {"data": …} cho một bản ghi (ApiQuery::one()), và {"data": [...], "meta": {...}, "links": {...}} cho danh sách (ApiQuery::page()).

  • Thất bại/không tìm thấy: dùng ApiErrorResponder để có mã HTTP đúng nghĩa và thân lỗi chung {"error": 1, "msg": "…", "code": "…"} (bảng mã ở Giới thiệu API, mục 6). ApiQuery::one() tự trả 404 khi không có bản ghi:

    use GP247\Core\Api\Support\ApiErrorResponder;
    
    if (!$product) {
        return ApiErrorResponder::notFound();          // 404, code: not_found
    }
    

    ℹ️ Có từ: gp247/core 3.2.3 — lỗi xác thực, phân quyền, validate và giới hạn tần suất dưới api/* được tự đổi sang dạng này; bạn chỉ cần tự trả notFound()/forbidden() khi controller tự quyết định. Mẫu cũ response()->json([...], 200) cho lỗi không còn dùng.

  • Chỉ trả trường cần thiết: hàm $toArray truyền vào ApiQuery khai báo đúng các trường trả về, thay vì trả nguyên model — cột mới thêm vào bảng sẽ không tự lộ ra API. Với dữ liệu công khai, lấy truy vấn gốc qua GP247\Core\Api\Support\ApiVisibility::visible(...) để API chỉ trả đúng những bản ghi website đang hiển thị.

  • Phân trang, lọc, sắp xếp: dùng ApiQuery::page($query, $request, $toArray, $filters, $sorts, $search) thay cho ->get() khi trả danh sách. Client điều khiển bằng page[number], page[size] (mặc định 20, tối đa 100), filter[<tên>], sort=<tên> / sort=-<tên> và q (tìm kiếm). Chỉ những tên lọc/sắp xếp bạn khai báo mới được nhận — tên lạ trả 422 (validation_failed), dữ liệu client không bao giờ thành tên cột trong truy vấn. Nội dung đa ngôn ngữ: lấy ngôn ngữ bằng ApiQuery::lang($request) và các trường mô tả bằng ApiQuery::described(...).


3. Ví dụ A — Thêm endpoint vào một gói bạn bảo trì (core/front/shop)

Áp dụng khi bạn là maintainer của gói (sửa trực tiếp trong vendor/gp247/<gói>), ví dụ thêm API front công khai api/v2/front/review/list. Làm theo đúng thứ tự:

  1. Tạo controller tại vendor/gp247/<gói>/src/Api/V2/.... Với dữ liệu front, kế thừa RootFrontController; với dữ liệu admin, kế thừa RootAdminController.

    Tạo file src/Api/V2/ReviewController.php:

    <?php
    
    namespace GP247\Shop\Api\V2;
    
    use GP247\Core\Api\V2\ApiQuery;
    use GP247\Front\Controllers\RootFrontController;
    use GP247\Shop\Models\ShopProductReview;
    use Illuminate\Database\Eloquent\Builder;
    use Illuminate\Http\Request;
    
    class ReviewController extends RootFrontController
    {
        /**
         * Get the public review list.
         *
         * @return \Illuminate\Http\JsonResponse
         */
        public function reviewList(Request $request)
        {
            $query = ShopProductReview::query()->where('status', 1);
    
            return ApiQuery::page($query, $request, fn ($r) => self::review($r),
                ['product_id' => fn (Builder $q, string $v) => $q->where('product_id', $v)],   // filter[product_id]
                ['created_at' => 'created_at']);                                                // sort=created_at | -created_at
        }
    
        /**
         * Get one public review (404 when it does not exist).
         *
         * @return \Illuminate\Http\JsonResponse
         */
        public function reviewDetail(Request $request, $id)
        {
            $review = ShopProductReview::query()->where('status', 1)->where('id', $id)->first();
    
            return ApiQuery::one($review, fn ($r) => self::review($r));
        }
    
        /**
         * Declared shape of a review: only these fields reach the API.
         */
        private static function review($r): array
        {
            return [
                'id'         => $r->id,
                'product_id' => $r->product_id,
                'point'      => (int) $r->point,
                'comment'    => $r->comment,
                'created_at' => optional($r->created_at)->toIso8601String(),
            ];
        }
    }
    
  2. Tạo file route tại src/Routes/Api/v2/review.php (chỉ cần đặt vào thư mục Routes/Api/v2/ là gói tự nạp — không phải khai báo ở đâu khác):

    <?php
    
    use GP247\Shop\Api\V2\ReviewController;
    use Illuminate\Support\Facades\Route;
    
    Route::group([
        'prefix' => GP247_API_V2_FRONT_PREFIX,   // => api/v2/front
    ], function () {
        $reviewController = gp247_namespace(ReviewController::class);
    
        Route::group(['prefix' => 'review'], function () use ($reviewController) {
            Route::get('list', $reviewController.'@reviewList');
            Route::get('detail/{id}', $reviewController.'@reviewDetail');
        });
    });
    
  3. Kiểm tra route đã nạp bằng lệnh Artisan (chạy ở thư mục gốc site):

    php artisan route:list --path=api/v2/front/review
    

    Nếu thành công, bạn thấy dòng GET | api/v2/front/review/list. Nếu không thấy, kiểm tra lại GP247_API_MODE=true trong .env và xóa cache route: php artisan route:clear.

  4. Gọi thử (endpoint công khai nên không cần token; -g để curl giữ nguyên dấu [ ]):

    curl -g -X GET "https://shop.example.com/api/v2/front/review/list?page[size]=10&sort=-created_at" -H "Accept: application/json"
    

    Kết quả mong đợi:

    {
      "data": [ { "id": 12, "product_id": 5, "point": 5, "comment": "…", "created_at": "2026-10-30T10:00:00+00:00" } ],
      "meta": { "current_page": 1, "per_page": 10, "total": 1, "last_page": 1 },
      "links": { "first": "…", "last": "…", "prev": null, "next": null }
    }
    

Với endpoint admin (cần đăng nhập), đặt route trong src/Routes/Api/v2/ của gói và bọc thêm nhóm middleware auth:admin-api + ApiAbilities::admin() với prefix => GP247_API_V2_CORE_PREFIX, mỗi route khai báo api.admin:<tên route admin> (mục 5.1; xem mẫu ở vendor/gp247/shop/src/Routes/Api/v2/admin.php).


4. Ví dụ B — Thêm API từ một plugin (khuyến nghị cho site riêng)

Đây là cách đúng chuẩn khi bạn thêm API cho một site cụ thể mà không được sửa vendor. Plugin đặt tại app/GP247/Plugins/<TênPlugin>/, và file Route.php của nó được core tự nạp.

⚠️ Khác biệt then chốt: route của plugin nạp ngoài nhóm middleware API (mục 1.3), nên bạn phải tự bọc GP247_API_MIDDLEWARE và tự đặt prefix. Nếu quên, API của bạn sẽ không trả JSON ép buộc, không qua lớp api.connection, không theo công tắc bật/tắt API, và không có giới hạn tần suất.

  1. Tạo controller trong plugin, ví dụ app/GP247/Plugins/MyApi/Api/Front/StatusController.php:

    <?php
    
    namespace App\GP247\Plugins\MyApi\Api\Front;
    
    use GP247\Front\Controllers\RootFrontController;
    use Illuminate\Http\Request;
    
    class StatusController extends RootFrontController
    {
        /**
         * Return a simple health-check payload for external monitors.
         *
         * @return \Illuminate\Http\JsonResponse
         */
        public function status(Request $request)
        {
            return response()->json(['data' => [
                'status' => 'ok',
                'time'   => gp247_time_now(),
            ]]);
        }
    }
    
  2. Khai báo route trong app/GP247/Plugins/MyApi/Route.php, tự bọc nhóm middleware API:

    <?php
    
    use App\GP247\Plugins\MyApi\Api\Front\StatusController;
    use Illuminate\Support\Facades\Route;
    
    // Only register when the API mode is on, matching core behaviour.
    if (config('gp247-config.env.GP247_API_MODE')) {
        Route::group([
            'middleware' => GP247_API_MIDDLEWARE,       // required: plugin routes load outside the API group
            'prefix'     => GP247_API_V2_FRONT_PREFIX,  // => api/v2/front
        ], function () {
            $statusController = gp247_namespace(StatusController::class);
    
            Route::group(['prefix' => 'myapi'], function () use ($statusController) {
                Route::get('status', $statusController.'@status');
            });
        });
    }
    
  3. Xóa cache và kiểm tra route:

    php artisan route:clear
    php artisan route:list --path=api/v2/front/myapi
    

    Nếu thành công, bạn thấy GET | api/v2/front/myapi/status.

  4. Gọi thử:

    curl -X GET "https://shop.example.com/api/v2/front/myapi/status" -H "Accept: application/json"
    

    Kết quả mong đợi:

    { "data": { "status": "ok", "time": "2026-07-30 10:00:00" } }
    

4.1. Cách gọn hơn: đăng ký qua ApiRouteRegistrar

ℹ️ Có từ: gp247/core 3.3.0

Thay vì tự bọc nhóm middleware trong Route.php, đặt route API vào một file riêng của plugin và đăng ký nó từ Provider.php của plugin:

// app/GP247/Plugins/MyApi/Provider.php
\GP247\Core\Api\Support\ApiRouteRegistrar::register(__DIR__ . '/Routes/api.php');
// app/GP247/Plugins/MyApi/Routes/api.php — declare only the prefix and the routes
use Illuminate\Support\Facades\Route;

Route::group(['prefix' => GP247_API_V2_FRONT_PREFIX], function () {
    $statusController = gp247_namespace(\App\GP247\Plugins\MyApi\Api\Front\StatusController::class);
    Route::get('myapi/status', $statusController.'@status');
});

ApiRouteRegistrar tự bọc GP247_API_MIDDLEWARE (JSON, API Connection, giới hạn tần suất), không đăng ký gì khi GP247_API_MODE tắt, và không đăng ký lại khi site đã route:cache. Hợp đồng lỗi {error, msg, code} áp dụng cho mọi đường dẫn dưới api/core, api/front, api/v2/core, api/v2/front; route đặt dưới tiền tố riêng (ngoài bốn tiền tố này) giữ thân lỗi mặc định của Laravel và chỉ theo công tắc tổng của màn API.


5. Tạo endpoint cần đăng nhập (bảo vệ bằng token)

Nếu API của bạn trả dữ liệu riêng tư (đơn hàng của khách, dữ liệu admin…), phải bọc bằng guard Sanctum tương ứng. Trong controller, lấy người dùng đang đăng nhập qua $request->user().

Ví dụ route khách hàng cần đăng nhập (api.customer.active của gói shop từ chối token của tài khoản đã bị khoá sau khi cấp token):

use GP247\Core\Api\Support\ApiAbilities;

Route::group([
    'prefix'     => GP247_API_V2_FRONT_PREFIX,   // => api/v2/front
    'middleware' => ['auth:customer-api', ApiAbilities::customer(), 'api.customer.active'],
], function () {
    $ctrl = gp247_namespace(\App\GP247\Plugins\MyApi\Api\Front\WishlistController::class);
    Route::get('member/wishlist/list', $ctrl.'@wishlistList');
});

Với route quản trị, xem mục 5.1.

Client phải gửi token đã lấy từ login trong header:

Authorization: Bearer <access_token>

Việc cấp token (createToken) và thu hồi token (logout) đã có sẵn trong các controller đăng nhập của core/shop. Bạn chỉ cần tái dùng endpoint api/v2/core/login / api/v2/front/login để lấy token (v2 có thêm with_refresh để nhận refresh token, login/challenge cho bước xác thực đăng nhập và refresh — xem Giới thiệu API, mục 2), không cần tự viết lại phần đăng nhập trừ khi có nhu cầu đặc biệt.

5.1. Endpoint quản trị: khai báo màn admin và giới hạn theo cửa hàng

ℹ️ Có từ: gp247/core 3.2.3

Token admin không tự cho phép xem mọi dữ liệu. Mỗi route dưới GP247_API_V2_CORE_PREFIX phải khai báo màn hình admin tương ứng bằng middleware api.admin:<tên route admin>; GP247 chỉ cho gọi khi tài khoản có quyền mở màn hình đó (cùng phân quyền với trang quản trị), áp rào chặn của plugin đa cửa hàng và xác định cửa hàng của tài khoản:

Route::group([
    'prefix'     => GP247_API_V2_CORE_PREFIX,   // => api/v2/core
    'middleware' => [
        'auth:admin-api',
        \GP247\Core\Api\Support\ApiAbilities::admin(),   // since 3.3.0
    ],
], function () {
    $ctrl = gp247_namespace(\App\GP247\Plugins\MyApi\Api\Admin\ReportController::class);
    Route::get('report/sales', $ctrl.'@salesList')
        ->middleware('api.admin:admin_report.sales');   // route name of the matching admin screen
});

Trong controller, luôn lọc truy vấn theo cửa hàng của tài khoản:

use GP247\Core\Api\Support\ApiStoreScope;
use GP247\Core\Api\V2\ApiQuery;

return ApiQuery::page(ApiStoreScope::apply(ShopOrder::query()), $request,
    fn ($o) => ['id' => $o->id, 'total' => $o->total, 'status' => (int) $o->status]);

Gọi ApiStoreScope trên route thiếu api.admin sẽ báo lỗi (an toàn mặc định) — đây là cách GP247 nhắc bạn đã quên khai báo quyền.

5.2. Endpoint ghi quản trị, danh sách đồng bộ và sự kiện

ℹ️ Có từ: gp247/core 3.7.0

Phần này dành cho plugin muốn ERP hay công cụ đồng bộ ghi dữ liệu của plugin, đọc thay đổi theo updated_since và nhận sự kiện của plugin — cùng cơ chế các endpoint có sẵn đang dùng (phía ứng dụng gọi thế nào: Giới thiệu API — mục 6, 7).

a) Endpoint ghi quản trị. Khai báo api.admin:<tên route màn admin>,write: chỉ cho gọi khi tài khoản được thay đổi dữ liệu trên màn đó — kiểm tra y như các thao tác của chính màn hình; tài khoản chỉ được xem nhận 403. Thêm hai middleware:

Middleware Tác dụng
throttle:gp247-api-write Giới hạn ghi theo từng tài khoản quản trị, mặc định 300 yêu cầu / phút (biến môi trường GP247_API_WRITE_THROTTLE, dạng số-yêu-cầu,số-phút); vượt → 429
api.idempotent Bắt buộc header Idempotency-Key (thiếu → 400 idempotency_key_required). Gửi lại cùng khoá, cùng thân → trả lại kết quả lần đầu kèm header Idempotent-Replayed: true, không chạy lại; cùng khoá, thân khác → 409 idempotency_key_reused
api.idempotent:optional Nhận khoá nếu có, không bắt buộc. Dùng api.idempotent cho lệnh mà chạy hai lần sẽ sai (ví dụ cộng/trừ tồn kho)
Route::group([
    'prefix'     => GP247_API_V2_CORE_PREFIX,   // => api/v2/core
    'middleware' => [
        'auth:admin-api',
        \GP247\Core\Api\Support\ApiAbilities::admin(),
        'api.admin:admin_report.sales,write',   // a write on the matching admin screen
        'throttle:gp247-api-write',
        'api.idempotent:optional',
    ],
], function () {
    $ctrl = gp247_namespace(\App\GP247\Plugins\MyApi\Api\Admin\ReportWriteController::class);
    Route::post('report/{id}/status', $ctrl.'@status');
});

Trong controller: tìm bản ghi qua ApiStoreScope (ngoài cửa hàng của tài khoản → 404), chỉ nhận các trường trong danh sách cho phép (từ chối khoá lạ, không lặng lẽ bỏ qua), và đổi trạng thái qua model / method nghiệp vụ — không ghi SQL trần:

use GP247\Core\Api\Support\ApiErrorResponder;
use GP247\Core\Api\Support\ApiStoreScope;
use GP247\Core\Api\V2\ApiQuery;

/**
 * `POST report/{id}/status` — change the status of one report.
 */
public function status(Request $request, $id)
{
    $report = ApiStoreScope::apply(MyReport::query())->where('id', (string) $id)->first();
    if ($report === null) {
        return ApiErrorResponder::notFound();                 // 404: not in the account's store
    }
    $rules = ['status' => 'required|integer|in:0,1'];
    foreach (array_diff(array_keys($request->all()), array_keys($rules)) as $unknown) {
        $rules[$unknown] = 'prohibited';                       // unknown keys → 422, never silently dropped
    }
    $report->changeStatus((int) $request->validate($rules)['status']);   // business method, not raw SQL

    return ApiQuery::one($report->fresh(), fn ($r) => ['id' => $r->id, 'status' => (int) $r->status]);
}

b) Danh sách sẵn sàng cho đồng bộ. Truyền changedSince: true cho ApiQuery::page() — danh sách nhận thêm updated_since và phân trang bằng con trỏ page[after] (khi đó sort và page[number] bị từ chối bằng 422):

return ApiQuery::page(ApiStoreScope::apply(MyReport::query()), $request,
    fn ($r) => ['id' => $r->id, 'status' => (int) $r->status],
    ['status' => fn (Builder $q, string $v) => $q->where('status', $v)],   // filter[status]
    ['created_at' => 'created_at'],                                          // sort (normal mode only)
    changedSince: true);
  • Bảng phải có cột updated_at (nên có chỉ mục trên cột này).
  • Mọi lệnh ghi phải làm updated_at mới lên — lưu qua Eloquent (save() / update()) thì tự có; cập nhật bằng SQL trần hay query builder thì bản ghi không hiện ra trong danh sách updated_since.

c) Loại sự kiện của plugin. Khai báo trong config gp247-config.api.events, dạng '<tiền tố>.<tài nguyên>.<hành động>' => ['fields' => [...], 'permission' => '<tên route màn admin>']. Gộp vào mảng đang có, không bao giờ ghi đè (core và gói shop cũng đăng ký loại của mình ở đây):

// In the plugin's service provider (boot)
config(['gp247-config.api.events' => array_merge((array) config('gp247-config.api.events', []), [
    'myapi.report.status_changed' => [
        'fields'     => ['from', 'to'],               // the only keys ever kept in data
        'permission' => 'admin_report.sales',         // admins allowed to open this screen may read the event
    ],
])]);
  • Tên viết thường, các đoạn cách nhau bằng dấu chấm, tối đa 64 ký tự; plugin đặt tiền tố là khoá của plugin để không trùng với loại khác. Tên sai dạng bị bỏ qua.

  • Ghi sự kiện bằng \GP247\Core\Api\Sync\ApiEvents::record($type, $subjectId, $storeId, $data):

    \GP247\Core\Api\Sync\ApiEvents::record('myapi.report.status_changed', (string) $report->id, (string) $report->store_id,
        ['from' => $old, 'to' => (int) $report->status]);
    
  • Chỉ các trường đã khai báo trong fields được giữ; không bao giờ đưa dữ liệu cá nhân vào data (ERP cần thêm thì đọc lại bản ghi qua endpoint chi tiết). source và actor do core tự điền.

  • Sự kiện được ghi sau khi giao dịch bao quanh commit — giao dịch rollback thì không có sự kiện. Chủ site đang tắt nhật ký sự kiện (hoặc site chưa chạy php artisan gp247:update) thì không có gì được ghi. Lỗi khi ghi chỉ được báo vào log, không bao giờ ném ra — thay đổi của plugin không bị hỏng vì nhật ký.

  • Sự kiện đã ghi cũng được gửi tới các webhook theo dõi loại đó; loại mới tự hiện trong ô chọn loại sự kiện của webhook.

d) Để thay đổi của plugin có sự kiện của cửa hàng (đơn hàng, tồn kho, sản phẩm, khách hàng): lưu qua model của gói shop (Eloquent save() / update() / delete()) hoặc các method của gói shop. Thay đổi ghi bằng SQL trần hay update() của query builder không được nhận ra — ERP sẽ không thấy.

5.3. Chọn trường, lấy kèm, lưu tạm và ngừng hỗ trợ

ℹ️ Có từ: gp247/core 3.8.0

Endpoint của bạn hưởng sẵn ETag / 304 cho mọi GET v2 thành công qua GP247_API_MIDDLEWARE — không cần làm gì. Ba việc còn lại tuỳ endpoint:

  • fields / include: khai báo ApiShape (tên loại, các trường, quan hệ được lấy kèm) rồi truyền lựa chọn vào ApiQuery::page() / ApiQuery::one(). Trường gắn với quan hệ nào thì $shape->with() bỏ nạp quan hệ đó khi trường không được chọn — hàm $toArray không được đọc quan hệ của trường đã bị bỏ (dùng ApiShapeSelection::build() với closure cho giá trị nặng). Quan hệ lấy kèm phải đi qua cùng luật hiển thị như khi đọc riêng.
  • api.cache.public: chỉ gắn cho route công khai không bao giờ chứa dữ liệu riêng của một người (danh mục, nội dung). Không gắn cho giỏ hàng, tài khoản, đơn hàng hay API quản trị.
  • api.deprecated:<ngày ngừng>,<ngày dừng>,<neo tài liệu>: gắn cho route sắp gỡ; báo trước ít nhất 12 tháng và 2 bản minor.
use GP247\Core\Api\V2\ApiQuery;
use GP247\Core\Api\V2\ApiShape;

// Declare the fields of the resource; a field mapped to relations lets ApiShape skip loading them.
$shape = (new ApiShape('myapi_item', ['name' => ['descriptions'], 'price', 'status'], [
    'brand' => [
        'with'   => ['brand' => fn ($q) => $q->where('status', 1)],
        'render' => fn ($item) => $item->brand ? ['id' => $item->brand->id, 'name' => $item->brand->name] : null,
        'type'   => 'brand',
        'fields' => ['id', 'name'],
    ],
]))->resolve($request);   // 422 for an unknown type, field or include

$query = MyItem::query()->with($shape->with(['descriptions']));

return ApiQuery::page($query, $request, fn ($item) => [
    'id' => $item->id, 'name' => $item->descriptions->first()?->name, 'price' => (float) $item->price, 'status' => (int) $item->status,
], [], [], null, false, $shape);
// Routes/api.php — a public route without anyone's own data may be cached by browsers / a CDN
Route::get('myapi/items', $controller.'@index')->middleware('api.cache.public');

// A route you plan to remove: Deprecation + Sunset + Link headers on every answer
Route::get('myapi/old-items', $controller.'@old')->middleware('api.deprecated:2026-11-01,2027-12-31,myapi-items');

6. Danh sách kiểm tra trước khi coi là "xong"

  • Endpoint mới nằm dưới v2 — dùng hằng số GP247_API_V2_CORE_PREFIX / GP247_API_V2_FRONT_PREFIX, không hardcode chuỗi, không thêm vào v1.
  • Controller trỏ qua gp247_namespace(...) để cho phép override.
  • Route trong plugin tự bọc GP247_API_MIDDLEWARE + prefix (mục 4) hoặc đăng ký qua ApiRouteRegistrar (mục 4.1).
  • Có $request->validate([...]) cho mọi input; không ghép chuỗi SQL (dùng Eloquent).
  • Danh sách dùng ApiQuery::page() (chỉ khai báo các filter/sort cần thiết); một bản ghi dùng ApiQuery::one().
  • Endpoint riêng tư có auth:admin-api + ApiAbilities::admin() hoặc auth:customer-api + ApiAbilities::customer() + api.customer.active.
  • Endpoint quản trị có api.admin:<tên route admin> và lọc dữ liệu bằng ApiStoreScope::apply() (mục 5.1).
  • Endpoint ghi quản trị có api.admin:<tên route admin>,write + throttle:gp247-api-write + api.idempotent (hoặc api.idempotent:optional); controller tìm bản ghi qua ApiStoreScope, từ chối trường lạ, đổi trạng thái qua model / method nghiệp vụ (mục 5.2).
  • Danh sách cần đồng bộ dùng ApiQuery::page(..., changedSince: true); bảng có cột updated_at (có chỉ mục) và mọi lệnh ghi làm mới cột này (mục 5.2).
  • Loại sự kiện của plugin được gộp vào gp247-config.api.events (tiền tố là khoá plugin, khai báo fields và permission), ghi bằng ApiEvents::record(), không có dữ liệu cá nhân trong data (mục 5.2).
  • Thay đổi dữ liệu cửa hàng lưu qua model / method của gói shop (không SQL trần) để ERP nhận được sự kiện (mục 5.2).
  • Lỗi trả qua ApiErrorResponder (mã HTTP đúng nghĩa); hàm $toArray chỉ trả các trường đã khai báo.
  • Không sửa vendor/gp247/* (trừ khi bạn là maintainer của gói đó).
  • Mỗi method có PHPDoc tiếng Anh.
  • Đã php artisan route:clear và xác nhận route hiện ra trong php artisan route:list.
  • Plugin thêm bước xác thực đăng nhập: cài LoginChallenge, không trả bí mật trong describe() (mục 7).
  • Danh sách/chi tiết khai ApiShape nếu muốn nhận fields / include; quan hệ lấy kèm theo đúng luật hiển thị; chỉ route công khai không có dữ liệu riêng mới gắn api.cache.public; route sắp gỡ gắn api.deprecated (mục 5.3).

7. Thêm bước xác thực đăng nhập (MFA) từ plugin

ℹ️ Có từ: gp247/core 3.6.0

Core không tự làm OTP; plugin của bạn gắn bước xác thực thứ hai vào đăng nhập API (v2 khách và quản trị) bằng interface GP247\Core\Api\Contracts\LoginChallenge:

Method Khi nào được gọi Bạn trả về
required($owner, $request) Ngay sau khi mật khẩu đúng (cả ở v1) true nếu tài khoản này phải qua bước xác thực — không gửi mã ở đây
describe($owner) Một lần, khi cấp vé (v2) Gửi mã SMS/email ở đây nếu cần; trả gợi ý công khai: method, meta — không chứa bí mật
verify($owner, $request) Khi ứng dụng gọi POST login/challenge true nếu câu trả lời (trường do bạn đặt, ví dụ code) đúng
<?php

namespace App\GP247\Plugins\MyOtp;

use GP247\Core\Api\Contracts\LoginChallenge;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;

class OtpChallenge implements LoginChallenge
{
    public function required(Authenticatable $owner, Request $request): bool
    {
        return MyOtpSettings::enabledFor($owner);   // e.g. the user turned 2FA on
    }

    public function describe(Authenticatable $owner): array
    {
        return ['method' => 'totp', 'meta' => ['digits' => 6]];   // public hints only
    }

    public function verify(Authenticatable $owner, Request $request): bool
    {
        return MyOtp::check($owner, (string) $request->input('code'));
    }
}
// In the plugin's service provider (boot)
config(['gp247-config.api.login_challenge.customer' => array_merge(
    config('gp247-config.api.login_challenge.customer', []), [OtpChallenge::class]
)]);

Core lo phần còn lại: vé dùng một lần (5 phút, lưu dạng băm trong cache mặc định — chạy được trên shared host), giới hạn số lần thử (dùng chung bộ đếm đăng nhập; mỗi vé bị huỷ sau 5 lần sai, từ bất kỳ IP nào), cấp token/phiên khi verify() trả true. Plugin ném lỗi hoặc khai báo sai class → đăng nhập bị từ chối (không bao giờ bỏ qua bước xác thực). Đăng ký cho quản trị viên: khoá gp247-config.api.login_challenge.admin. Phía ứng dụng gọi thế nào: Giới thiệu API — mục 9.4.

8. Plugin xử lý đơn hàng: đơn từ ứng dụng đi qua cùng điểm cắm

ℹ️ Có từ: gp247/shop 3.5.0

Đơn đặt qua API (POST api/v2/front/checkout/place) chạy đúng mã checkout của website. Plugin đang xử lý đơn web — tách đơn theo nhà bán, tính hoa hồng, trừ suất khuyến mãi, gửi thông báo… — nhận đơn từ ứng dụng mà không cần viết thêm gì cho API:

Điểm cắm Khi nào chạy Ghi chú
Sự kiện model ShopOrder::created Khi ghi đơn Dòng sản phẩm được ghi sau, trong cùng giao dịch — đọc chúng trong DB::afterCommit()
gp247_listen_order_created($order) Ngay sau khi tạo đơn Hàm helper, site tự định nghĩa lại được
endApp() trong AppConfig của plugin thanh toán / vận chuyển / tổng tiền Khi hoàn tất đơn Mã đơn đọc từ session('orderID') như trên website
gp247_listen_order_success($order) Khi hoàn tất đơn Đơn qua API: chạy ngay khi đặt, kể cả đơn trả qua cổng (chưa có tiền)
gp247_order_process_after_success($orderId) Sau sự kiện thành công Gửi email xác nhận đơn

Muốn thay một hàm helper ở bảng trên, thêm tên hàm vào config/gp247_functions_except.php rồi tự định nghĩa hàm đó (cùng chữ ký).

// In the plugin's service provider (boot)
\GP247\Shop\Models\ShopOrder::created(function ($order) {
    \Illuminate\Support\Facades\DB::afterCommit(function () use ($order) {
        // The order lines exist here, whether the order came from the website or the app.
        MyCommission::book($order->fresh('details'));
    });
});

Ba điều cần nhớ khi plugin chạy trong request API:

  • Nguồn đơn: cột channel của đơn là api cho đơn từ ứng dụng, web cho đơn từ website (đơn tạo trong trang quản trị và đơn cũ để trống). Chỉ dùng khi thật sự cần phân biệt — đa số plugin không cần.
  • Không có phiên trình duyệt: trong request API, session() là phiên riêng của request đó (không cookie). Đọc / ghi khoá checkout như trên website vẫn đúng, nhưng đừng redirect, flash message hay đọc cookie — ứng dụng không thấy chúng.
  • "Đơn thành công" không có nghĩa "đã trả tiền": với đơn qua API, sự kiện chạy ngay khi đặt. Việc chỉ làm sau khi đã trả tiền (giao hàng số, mở khoá dịch vụ…) hãy dựa vào trạng thái thanh toán của đơn.

Phía ứng dụng gọi thế nào: Giới thiệu API — mục 10.


Hỏi & Đáp (Q&A)

Câu 1: Tôi nên thêm API vào đâu — vendor hay plugin?

→ Gần như luôn là plugin (app/GP247/Plugins/) hoặc override qua app/GP247/. Chỉ sửa trực tiếp vendor/gp247/* khi bạn là người bảo trì chính của gói đó, vì mọi thay đổi trong vendor sẽ mất khi composer update.

Câu 2: Tại sao route mới của tôi không xuất hiện?

→ Ba nguyên nhân phổ biến: (1) chưa bật GP247_API_MODE=true trong .env; (2) chưa xóa cache — chạy php artisan route:clear; (3) với plugin, file phải tên đúng Route.php và plugin đang được kích hoạt.

Câu 3: API từ plugin của tôi không trả JSON / không bị giới hạn tần suất?

→ Vì route plugin nạp ngoài nhóm middleware API. Bạn phải tự bọc 'middleware' => GP247_API_MIDDLEWARE như ở mục 4. Đây là lỗi hay gặp nhất khi thêm API từ plugin.

Câu 4: ApiQuery::page() khác gì paginate() của Laravel?

→ ApiQuery::page() dùng paginate() bên dưới nhưng thêm phần chuẩn của API v2: chỉ nhận các filter[...] / sort bạn khai báo (tên lạ trả 422), giới hạn page[size] tối đa 100, luôn sắp thêm theo khoá chính để các trang không trùng/sót bản ghi, và trả đúng dạng {data, meta, links}. Hãy dùng nó cho các endpoint danh sách để đồng bộ với các API v2 sẵn có của GP247.

Câu 5: Làm sao trả về lỗi cho đúng chuẩn?

→ Dùng ApiErrorResponder (notFound(), forbidden(), respond($status, $code, $msg)) — thân lỗi {error, msg, code} với mã HTTP đúng nghĩa. Lỗi xác thực, validate và giới hạn tần suất GP247 tự đổi sang dạng này. Không trả HTML hay chuỗi trần.

Câu 6: Tôi cần scope/quyền riêng cho API của mình?

→ Scope chỉ phân biệt loại tài khoản (admin, admin-supper, user). Quyền xem dữ liệu của endpoint quản trị lấy từ phân quyền màn admin qua api.admin:<tên route admin> (mục 5.1) — người quản trị cấp quyền một lần cho cả trang quản trị lẫn API. Tránh tự phát minh cơ chế quyền song song.

Câu 7: Lớp apiconnection/apikey có bắt buộc cho API mới của tôi không?

→ Không do bạn quyết định trong code — nó bật/tắt toàn cục qua cấu hình api_connection_required trong quản trị. Miễn là route của bạn nằm trong nhóm GP247_API_MIDDLEWARE, lớp này áp dụng tự động khi admin bật.

Câu 8: Tôi có phải tự viết endpoint đăng nhập/cấp token không?

→ Không. Hãy tái dùng api/v2/core/login (admin) hoặc api/v2/front/login (khách) để lấy token, rồi bảo vệ endpoint mới bằng auth:admin-api/auth:customer-api. Chỉ tự viết login khi có luồng xác thực đặc thù.

Câu 9: Controller API nên kế thừa class nào?

→ Dữ liệu admin → kế thừa GP247\Core\Controllers\RootAdminController; dữ liệu front/cửa hàng → kế thừa GP247\Front\Controllers\RootFrontController. Chúng đã thiết lập sẵn ngữ cảnh phù hợp cho từng phía.

Câu 10: Có cần viết test cho API mới không?

→ Nên có — viết feature test (PHPUnit) cho endpoint, đặt trong thư mục test của dự án hoặc plugin của bạn. Ít nhất kiểm tra: mã trạng thái, cấu trúc JSON, và chặn truy cập khi thiếu token với endpoint riêng tư.


Lịch sử thay đổi

Ngày Phiên bản GP247 Thay đổi
2026-11-02 gp247/core 3.8.0 Endpoint v2 có sẵn ETag / 304; plugin khai ApiShape để nhận fields / include, gắn api.cache.public cho route công khai và api.deprecated cho route sắp gỡ (mục 5.3, 6)
2026-11-01 gp247/core 3.7.0, gp247/shop 3.6.0 Endpoint ghi quản trị: api.admin:<route>,write (quyền như thao tác ghi trên màn admin), throttle:gp247-api-write, api.idempotent / api.idempotent:optional; danh sách đồng bộ bằng ApiQuery::page(..., changedSince: true); plugin đăng ký loại sự kiện qua gp247-config.api.events và ghi bằng ApiEvents::record(); lưu qua model của gói shop để có sự kiện của cửa hàng (mục 5.2, 6)
2026-10-31 gp247/core 3.6.0, gp247/shop 3.5.0; API v2: core/front/shop 3.3.0 Endpoint mới đặt dưới tiền tố v2 (GP247_API_V2_CORE_PREFIX, GP247_API_V2_FRONT_PREFIX), dùng ApiQuery cho phản hồi {data, meta, links}; v1 đóng băng (mục 1–6); json.response thuộc GP247_API_MIDDLEWARE và chạy trước auth:* — thiếu Accept không còn ra 500 (mục 1); đơn đặt qua API v2 đi qua cùng điểm cắm với đơn web (ShopOrder::created, endApp(), gp247_listen_order_success, email xác nhận); cột channel của đơn (api / web) (mục 8)
2026-10-28 gp247/core 3.6.0 Plugin thêm bước xác thực đăng nhập API (MFA) qua LoginChallenge (mục 7)
2026-10-26 gp247/core 3.2.3; seam đăng ký route: 3.3.0 Mỗi gói/plugin đăng ký route API qua ApiRouteRegistrar, thêm thư mục Routes/Api/v2/, ApiAbilities::admin() (3.3.0); endpoint quản trị phải khai báo màn admin (api.admin) và lọc theo cửa hàng (ApiStoreScope); lỗi trả qua ApiErrorResponder với mã HTTP đúng nghĩa; lọc trường dữ liệu công khai bằng ApiFieldAllowlist

📅 Cập nhật lần cuối: 2026-11-02 · ✍️ Tác giả (Author): GP247