Khi có được Access Token là chúng ta đã có thể sử dụng Zalo API. Tuy nhiên do Zalo API chưa 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.
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.
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 /** * 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', ); $res_hdr = array(); $url = $api_url . "?access_token=" . $access_token; if ( !empty($params) ) $url .= '&'. http_build_query($params); $curl = curl_init(); $curl_params = array( CURLOPT_URL => $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, ); 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 ); } ); $output = curl_exec($curl); curl_close($curl); return $output; } /** * 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', ); $res_hdr = array(); $url = $api_url . "?access_token=" . $access_token; $curl = curl_init(); $curl_params = array( CURLOPT_URL => $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, ); 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 ); } ); $output = curl_exec($curl); curl_close($curl); return $output; } /** * 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', ); $res_hdr = array(); $url = $api_url . "?access_token=" . $access_token; $type = mime_content_type($path); // MIME type $size = filesize($path); $name = basename($path); $curl = curl_init(); $curl_params = array( CURLOPT_URL => $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, ); 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 ); } ); $output = curl_exec($curl); curl_close($curl); return $output; } ?>
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, lưu ý là access token được lấy từ cookie có được từ bước lấy access token.
zalo.php
<?php if( !is_callable('curl_init') ) die("CURL extension is not enabled"); if ( !isset($_GET["zalo_api_url"]) ) { http_response_code(400); die("Bad configuration"); } if ( !isset($_COOKIE["zalo_access_token"]) ) { http_response_code(403); die("Unauthorized access"); } require_once __DIR__ . '/zalo-utils.php'; $api_url = $_GET["zalo_api_url"]; $access_token = $_COOKIE["zalo_access_token"]; 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 ); } } // Forward some useful response headers from Zalo to the client foreach ( array( 'Content-Type', 'X-RateLimit-Limit', 'X-RateLimit-Remain' ) as $hdr_name ) if ( isset($res_hdr[$hdr_name]) ) header( $header . ": " . $res_hdr[$hdr_name] ); echo $response; ?>
Để 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?zapi_uri=https://openapi.zalo.me/$1 [NC,L,QSA] RewriteRule ^graph/([a-zA-Z0-9-\._\/]+)/?$ zalo.php?zapi_uri=https://graph.zalo.me/$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 | Gọi qua proxy |
---|---|
https://graph.zalo.me/___ | https://zalo.example.com/graph/___ |
https://openapi.zalo.me/___ | 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?access_token=XXXXXX&data={"user_id":"251245252362541251"}
Gọi qua proxy (không cần access token)
https://zalo.example.com/openapi/v2.0/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/v2.0/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 jQuery
Để 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 ở những nơi không cần thiết, đặc biệt là các ID. Khi dùng BigInt thì các hàm xử lý JSON dù của PHP hay JavaScript cũng trả về kết quả sai.
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.
// GET and POST wrapper to handle BigInt problem const zCaller = { get: function(api, data) { let jqXHR = $.get(api, data, null, 'text'); let jqXHR = $.ajax({ type: 'GET', url: api, data: data, // Workaround: Force response as text to handle Big Integers dataType: 'text' }); return new Promise(function (resolve, reject) { // Workaround: Force response as text to handle Big Integers jqXHR.done(function( json, textStatus, jqXHR ) { // Workaround: Convert Big Integers to strings json = json.replace(/([\[:])?(\d{16,})([,\}\]])/g, "$1\"$2\"$3"); json = JSON.parse(json); if (json.error != 0) { reject('Error ' + json.error + ': ' + json.message); } resolve(json.data); }) .fail(reject); }); }, post: function(api, data, isJson = true) { let jqXHR = $.ajax({ type: 'POST', url: api, data: JSON.stringify(data), contentType: "application/json; charset=UTF-8", // Workaround: Force response as text to handle Big Integers dataType: 'text' }); return new Promise(function (resolve, reject) { // Workaround: Force response as text to handle Big Integers jqXHR.done(function( json, textStatus, jqXHR ) { // Workaround: Convert Big Integers to strings json = json.replace(/([\[:])?(\d{16,})([,\}\]])/g, "$1\"$2\"$3"); json = JSON.parse(json); if (json.error != 0) { let err = Number(json.error).toString(); reject('Error ' + json.error + ': ' + json.message); } resolve(json.data); }) .fail(reject); }); } }; const zGraphApiBaseUrl = 'graph/v2.0/'; const zOpenApiBaseUrl = 'openapi/v2.0/'; const Zalo = { ///// Official Account API ///// oa: { sendMessage: function(message) { return zCaller.post(zOpenApiBaseUrl + 'oa/message', message); }, updateFollowerInfo: function(info) { return zCaller.post(zOpenApiBaseUrl + 'oa/updatefollowerinfo', { data: JSON.stringify(info) }); }, getProfile: function(userId) { return zCaller.get(zOpenApiBaseUrl + 'oa/getprofile', { data: JSON.stringify({user_id: userId}) }); }, getInfo: function() { return zCaller.get(zOpenApiBaseUrl + 'oa/getoa'); }, getFollowers: function(offset = 0, count = 5) { return Caller.get(zOpenApiBaseUrl + 'oa/getfollowers', { data: JSON.stringify({ offset: offset, count: count }) }); }, getRecentChats: function(offset = 0, count = 5) { return zCaller.get(zOpenApiBaseUrl + 'oa/listrecentchat', { data: JSON.stringify({ offset: offset, count: count }) }); }, getConversation: function(userId, offset = 0, count = 5) { return zCaller.get(zOpenApiBaseUrl + 'oa/conversation', { // Workaround: Avoid big integer problem (userId) data: '{user_id:'+userId+',offset:'+offset+',count:'+count+'}' }); }, getTags: function() { return zCaller.get(zOpenApiBaseUrl + 'oa/tag/gettagsofoa'); }, assignTag: function(userId, tag) { return zCaller.post(zOpenApiBaseUrl + 'oa/tag/tagfollower', { user_id: userId, tag_name: tag }); }, unassignTag: function(userId, tag) { return zCaller.post(zOpenApiBaseUrl + 'oa/tag/rmfollowerfromtag', { user_id: userId, tag_name: tag }); }, removeTag: function(tag) { return zCaller.post(zOpenApiBaseUrl + 'oa/tag/rmtag', { tag_name: tag }); } } ///// Article API ///// ///// Shop API ///// ///// Food API ///// ///// Social API ///// };
Ví dụ sử dụng API bằng JavaScript
Đoạn mã JavaScript sau trong index.php
sẽ 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.
index.php
// ... (async function($) { let oaInfo = await Zalo.oa.getInfo(); console.log("Name: " + oaInfo.name); console.log("Description: " + oaInfo.description); })(jQuery); // ...