Khi có được Access Token là chúng ta đã có thể sử dụng Zalo API. Tuy nhiên do Zalo API không hỗ trợ CORS nên để gọi được từ trình duyệt, chúng ta phải tạo ra một “proxy” trung gian giữa Client và Zalo.
Tôi đoán có vẻ phía Zalo muốn chúng ta request API từ server hơn là từ client để kiểm soát rate limit và bảo vệ server của họ trước các cuộc tấn công DDoS.
Bạn có thể tham khảo thêm về ý tưởng thiết kế một Proxy Web Service tại đây.
Lý do tôi thích hướng thiết kế này là vì tôi không muốn Server phải xử lý dữ liệu nhiều mà ảnh hưởng hiệu năng (Do các Server cá nhân của tôi hầu như là các thiết bị nhúng). Việc xử lý dữ liệu sẽ tập trung ở máy client.
Zalo Platform đã cập nhật và có sự thay đổi ở cách access token được truyền từ client lên server. Theo đó, thay vì truyền Access Token qua query string thì giờ để ở HTTP header tên là access_token. Để tìm hiểu phương pháp cũ, bạn có thể tham khảo bài viết này.
Mã nguồn của Zalo API proxy
Mã nguồn zalo.php dưới đây là một proxy cho Zalo API, hỗ trợ cả trao đổi thông tin và upload file. Yêu cầu PHP phải hỗ trợ cURL extension.
Để thuận tiện cho việc ứng dụng sau này như Webhook chẳng hạn, tôi tạo ra 3 hàm tiện ích để làm việc với Zalo API từ PHP server như sau:
zalo-utils.php
<?php if( !is_callable('curl_init') ) { die("cURL extension is not enabled"); } /** * Perform a GET request to Zalo */ function zalo_get( $api_url, $access_token, $params = array(), &$res_hdr = null ) { $cli_hdr = getallheaders(); $req_hdr = array( 'Accept: application/json', 'Content-Type: application/json', 'access_token: ' . $access_token ); $res_hdr = array(); $api_url .= empty($params) ? "" : "?" . http_build_query($params); $curl = curl_init(); $curl_params = array( CURLOPT_URL => $api_url, CURLOPT_CUSTOMREQUEST => "GET", CURLOPT_HTTPHEADER => $req_hdr, CURLOPT_RETURNTRANSFER => true, CURLOPT_MAXREDIRS => 10, CURLOPT_TIMEOUT => 30, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_FAILONERROR => true, ); curl_setopt_array($curl, $curl_params); curl_setopt( $curl, CURLOPT_HEADERFUNCTION, function( $curl, $header ) use(&$res_hdr) { $parts = explode( ": ", $header, 2 ); if ( count($parts) > 1 ) $res_hdr[$parts[0]] = $parts[1]; return strlen( $header ); } ); $response = curl_exec($curl); $err_no = curl_errno($curl); $err_msg = curl_error($curl); curl_close($curl); if ( $err_no ) { throw new ErrorException( "cURL error {$err_no}: {$err_msg}" ); } return $response; } /** * Perform a POST request to Zalo */ function zalo_post( $api_url, $access_token, $data, &$res_hdr = null ) { $cli_hdr = getallheaders(); $req_hdr = array( 'Accept: application/json', 'Content-Type: application/json', 'access_token: ' . $access_token ); $res_hdr = array(); $curl = curl_init(); $curl_params = array( CURLOPT_URL => $api_url, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_HTTPHEADER => $req_hdr, CURLOPT_RETURNTRANSFER => true, CURLOPT_MAXREDIRS => 10, CURLOPT_TIMEOUT => 30, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_POSTFIELDS => $data, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_FAILONERROR => true, ); curl_setopt_array($curl, $curl_params); curl_setopt( $curl, CURLOPT_HEADERFUNCTION, function( $curl, $header ) use(&$res_hdr) { $parts = explode( ": ", $header, 2 ); if ( count($parts) > 1 ) $res_hdr[$parts[0]] = $parts[1]; return strlen( $header ); } ); $response = curl_exec($curl); $err_no = curl_errno($curl); $err_msg = curl_error($curl); curl_close($curl); if ( $err_no ) { throw new ErrorException( "cURL error {$err_no}: {$err_msg}" ); } return $response; } /** * Upload a file to Zalo */ function zalo_upload( $api_url, $access_token, $path, &$res_hdr = null ) { $cli_hdr = getallheaders(); $req_hdr = array( 'Accept: application/json', 'Content-Type: multipart/form-data', 'access_token: ' . $access_token ); $res_hdr = array(); $type = mime_content_type($path); // MIME type $size = filesize($path); $name = basename($path); $curl = curl_init(); $curl_params = array( CURLOPT_URL => $api_url, CURLOPT_HTTPHEADER => $req_hdr, CURLOPT_POST => 1, CURLOPT_POSTFIELDS => array( 'file' => new \CurlFile($path, $type, $name) ), CURLOPT_INFILESIZE => $size, CURLOPT_RETURNTRANSFER => true, CURLOPT_MAXREDIRS => 10, CURLOPT_TIMEOUT => 30, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_FAILONERROR => true, ); curl_setopt_array($curl, $curl_params); curl_setopt( $curl, CURLOPT_HEADERFUNCTION, function( $curl, $header ) use(&$res_hdr) { $parts = explode( ": ", $header, 2 ); if ( count($parts) > 1 ) $res_hdr[$parts[0]] = $parts[1]; return strlen( $header ); } ); $response = curl_exec($curl); $err_no = curl_errno($curl); $err_msg = curl_error($curl); curl_close($curl); if ( $err_no ) { throw new ErrorException( "cURL error {$err_no}: {$err_msg}" ); } return $response; } ?>
Giao diện REST API trung gian với client sử dụng các hàm tiện ích phía trên như sau.
Ghi chú: Access Token sẽ được Client gửi kèm request lên server thông qua một header do tôi tự định nghĩa là
X-Access-Token
. Điều này sẽ được đề cập đến ở đoạn mã JavaScript phía sau.
zalo.php
<?php if ( !isset($_GET["api_path"]) || !isset($_GET["api_type"])) { http_response_code(400); die("Bad configuration"); } $headers = getallheaders(); if ( !isset($headers["X-Access-Token"]) ) { http_response_code(403); die("Unauthorized access"); } require_once __DIR__ . "/zalo-utils.php"; try { $access_token = $headers["X-Access-Token"]; switch ($_GET["api_type"]) { case "openapi": $api_url = "https://openapi.zalo.me/v2.0/"; break; case "graph": $api_url = "https://graph.zalo.me/v2.0/"; break; default: throw new ErrorException("Invalid type `{$_GET["api_type"]}`"); } $api_url .= $_GET["api_path"]; unset($_GET["api_path"]); unset($_GET["api_type"]); if ( $_SERVER["REQUEST_METHOD"] === "GET" ) { $response = zalo_get( $api_url, $access_token, $_GET, $res_hdr ); } else if ( $_SERVER["REQUEST_METHOD"] === "POST" ) { if ( isset($_FILES["media"]) ) { // Assume the file <input> name is "media" $response = zalo_upload( $api_url, $access_token, $_FILES["media"]["tmp_name"], $res_hdr ); } else { $data = file_get_contents("php://input"); $response = zalo_post( $api_url, $access_token, $data, $res_hdr ); } } else { throw new ErrorException("Invalid request method `{$_SERVER["REQUEST_METHOD"]}`"); } // Forward important response headers to the client foreach ( [ "Content-Type", "X-RateLimit-Limit", "X-RateLimit-Remain" ] as $header ) if ( isset($res_hdr[$header]) ) header( "{$header}: {$res_hdr[$header]}" ); echo $response; } catch (Exception $e) { http_response_code(400); die($e->getMessage()); } ?>
Để URL của proxy trông gọn gàng và dễ liên hệ hơn, tôi sử dụng mod_rewrite
để rút ngắn URL của proxy như sau:
.htaccess
<IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^openapi/([a-zA-Z0-9-\._\/]+)/?$ zalo.php?api_type=openapi&api_path=$1 [NC,L,QSA] RewriteRule ^graph/([a-zA-Z0-9-\._\/]+)/?$ zalo.php?api_type=graph&api_path=$1 [NC,L,QSA] </IfModule>
Như vậy thay vì gọi trực tiếp Zalo API thì ta gọi thông qua proxy như sau:
Gọi trực tiếp (bị CORS nếu gọi từ Browser) | Gọi qua proxy |
---|---|
https://graph.zalo.me/v2.0/___ | https://zalo.example.com/graph/___ |
https://openapi.zalo.me/v2.0/___ | https://zalo.example.com/openapi/___ |
Tham số truyền vào proxy API hoàn toàn không khác gì Zalo API. Ví dụ để lấy thông tin một người quan tâm trên Official Account:
Gọi trực tiếp
https://openapi.zalo.me/v2.0/oa/getprofile?data={"user_id":"251245252362541251"}
Gọi qua proxy
https://zalo.example.com/openapi/oa/getprofile?data={"user_id":"251245252362541251"}
Để upload file, mã nguồn zalo.php yêu cầu tên của input file upload trong <form>
phải là media
. Như ví dụ sau là form upload ảnh sử dụng Official Account API:
<form action="openapi/oa/upload/image" enctype="multipart/form-data" method="POST"> <input type="file" name="media" accept="image/gif, image/jpeg, image/png, .pdf" /> <input type="submit" name="submit" value="Upload Media File" /> </form>
Gọi API proxy từ JavaScript với Ajax
Để gọi REST API thông thường người ta dùng jQuery.get
và jQuery.post
, tuy nhiên với data của Zalo thì không đơn giản như vậy.
Trong thiết kế của Zalo API có vấn đề lớn về kiểu dữ liệu đó là sử dụng Big Integer ở các ID (mà lại chỗ dùng, chỗ không?!), theo như Document thì đó là những trường dữ liệu kiểu Long
. Khi dùng BigInt thì các hàm JSON encode/decode dù của PHP hay JavaScript cũng đều xử lý sai cả.
Vì vậy tôi phải sử dụng các Workaround cả ở PHP và JavaScript để tìm cách biến các giá trị Big Intergers thành chuỗi ký tự trước khi chuyển thành Object.
Dưới đây tôi tạo ra một thư viện JavaScript cho các API của Zalo Official Account sử dụng API proxy ở trên. Những chỗ có Workaround là dành cho việc xử lý BigInt.
Ngoài ra tôi truyền access token từ client lên server thông qua header tự định nghĩa là X-Access-Token
. Như ví dụ trong bài Lấy access token để có quyền gọi Zalo API, access token đầu tiên được tôi lưu trong một hằng số là initialToken
.
Lưu ý là access token sẽ bị hết hạn sau 25 giờ. Sau đó bạn cần lấy cái mới bằng refresh token khi request gặp lỗi -216
hoặc làm lại quá trình lấy access token từ đầu. Đoạn mã sau tôi sẽ sử dụng thư viện axios để xử lý request queue và refresh token.
Thư viện axios:
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/1.3.4/axios.min.js"></script>
Mã nguồn gọi API của Zalo từ client, sử dụng refresh token khi cần thiết:
const Zalo = ((axios) => { // Store the access token in a local variable var currentToken = initialToken.access_token; const parseJSON = (json) => { // Workaround: Convert Big Integers to strings json = json.replace(/([\[:])?(\d{16,})([,\}\]])/g, "$1\"$2\"$3"); return JSON.parse(json); }; const instance = axios.create({ baseURL: "/", timeout: 5000, headers: { "Content-Type": "application/json", "X-Access-Token": currentToken }, responseType: 'text', // Force response as text to handle Big Integers forcedJSONParsing: false, // Prevent JSON to Object conversion transformResponse: [data => data], }); // Alias to perform token refresh const refreshToken = () => instance.post("refresh.php"); let isRefreshing = false, // A flag to prevent double refreshing requests = []; // Request queue // Intercept returned data instance.interceptors.response.use(response => { const result = parseJSON(response.data); if (result.error == -216) { // Error : token expired const config = response.config; if (!isRefreshing) { isRefreshing = true; // Using the refresh token to acquire a new access token return refreshToken().then(response => { // Store the new access token instance.defaults.headers["X-Access-Token"] = currentToken = response.data.access_token; // Retry requests from queue after receiving a new access token requests.forEach(cb => cb(token)); requests = []; // Clear the queue config.headers["X-Access-Token"] = currentToken; config.baseURL = ""; return instance(config); }).catch(error => { isRefreshing = false Promise.reject(error); }).finally(() => { isRefreshing = false }); } else { return new Promise(resolve => { // Put resolve in the queue, save it in a function form, and execute it directly after token refreshes requests.push((accessToken) => { config.headers["X-Access-Token"] = accessToken config.baseURL = "" resolve(instance(config)) }) }) } } // Next, we will do the logical processing of token expiration here. return result; }, error => { return Promise.reject(error); }); // GET and POST wrapper to handle BigInt problem const zaloGet = async (api, args) => { try { let url = api; if ('undefined' != typeof args) { url += "?data=" + encodeURIComponent(JSON.stringify(args)); } let response = await instance.get(url); return response.data; } catch (error) { return Promise.reject(error); } } const zaloPost = async (api, data) => { try { let response = await instance.post(api, data); return response.data; } catch (error) { return Promise.reject(error); } } return { ///// Official Account API ///// oa: { getMessageQuota: (message_id) => zaloPost('openapi/oa/quota/message', {message_id}), sendMessage: (message) => zaloPost('openapi/oa/message', message), updateFollowerInfo: (info) => zaloPost('openapi/oa/updatefollowerinfo', info), getProfile: (user_id) => zaloGet('openapi/oa/getprofile', {user_id}), getInfo: () => zaloGet('openapi/oa/getoa'), getFollowers: (tag_name, offset = 0, count = 50) => zaloGet('openapi/oa/getfollowers', {offset, count, tag_name}), getRecentChats: (offset = 0, count = 10) => zaloGet('openapi/oa/listrecentchat', {offset, count}), getConversation: (user_id, offset = 0, count = 10) => zaloGet('openapi/oa/conversation', {user_id, offset, count}), getTags: () => zaloGet('openapi/oa/tag/gettagsofoa'), assignTag: (user_id, tag_name) => zaloPost('openapi/oa/tag/tagfollower', {user_id, tag_name}), unassignTag: (user_id, tag_name) => zaloPost('openapi/oa/tag/rmfollowerfromtag', {user_id, tag_name}), removeTag: (tag_name) => zaloPost('openapi/oa/tag/rmtag', {tag_name}) } ///////////////////////////////////////// // Please implement Other API yourself // ///////////////////////////////////////// }; })(axios);
Đoạn mã JavaScript sau sử dụng Zalo object ở trên để lấy thông tin của Official Account đang liên kết. Access Token được lấy từ session của PHP sau quá trình xác thực.
// ... the 'Zalo' object has been acquired above ... (async () => { let oaInfo = await Zalo.oa.getInfo(); console.log("Name: " + oaInfo.name); console.log("Description: " + oaInfo.description); })();