[Zalo API+PHP+JS] Tạo giao diện trung gian để gọi Zalo API từ trình duyệt (deprecated 2020)

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.

[Zalo API+PHP+JS] Tạo giao diện trung gian để gọi Zalo API từ trình duyệt (deprecated 2020)
Hướng dẫn cách tránh CORS khi gọi Zalo API từ trình duyệt

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.

Mô hình sử dụng Web Service trung gian để gọi Zalo API
Sử dụng Web Service trung gian để gọi Zalo API

Để 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ếpGọ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.getjQuery.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);
// ...

Phản hồi về bài viết

Cùng thảo luận chút nhỉ!

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.