Kiedy aplikacja nie udostępnia czytelnego API

Cześć, ostatnio pracując nad nowym projektem w PHP zderzyłem się z koniecznością integracji mojej aplikacji z zewnętrzną usługą. Niestety owa usługa nie udostępnia żadnego sensownego interfejsu ala REST. Dlatego też musiałem się trochę napocić by otrzymać interesujące mnie dane. Dzisiejszy wpis będzie o tych zmaganiach i wykorzystaniu w tym celu bibliotek CURL (dla połączenia), fabpot/goutte (do swobodnego przechodzenia po drzewie DOM) i kegi/netscape-cookie-file-handler (do manipulacją plikami cookie lokalnie).

Zanim zaczniemy, uwaga na CSRF

By mógł otrzymywać interesujące mnie informacje, muszę się wcześniej zalogować do aplikacji. Pierwszym wymaganym krokiem jest otrzymanie tokenu ze zdalnej witryny, który służy zabezpieczeniu przed atakiem CSRF. Przykładowy kod wykorzystujący bibliotekę fabpot/goutte prezentuje poniżej.

private function getCsrfToken(): string
    {
        $crawler = $this->client->request('GET', self::SERVICE_LOGIN_URL);
        $token = $crawler->filter('input[name=csrftoken]')->attr('value');
        return $token;
    }

gdzie SERVICE_LOGIN_URL zawiera adres URL do zdalnej witryny, która z kolei zwraca swój token w inpucie o nazwie csrftoken. Tak przechwycony token będziemy musieli później odsyłać do tej witryny (zgodnie z jej protokołem).

Uwierzytelnienie

Mając pobrany token, możemy przystąpić do logowania. Oczywiście będziemy musieli posiadać login i hasło. Sposób logowania zależy od konkretnej witryny, w moim przypadku wygląda to tak.

private function auth(string $token): string
    {
        $postdata = http_build_query(
            array(
                'csrftoken' => $token,
                'username' => $this->login,
                'password' => $this->password,
            )
        );

        $headerArray = [
            'Content-Type: application/x-www-form-urlencoded',
            'referer: http://' . self::SERVICE_LOGIN_URL,
            'Accept: */*',
            "Cookie: csrftoken=$token;",
        ];

        $ckfile = tempnam(self::COOKIE_DIR, "CURLCOOKIE");
        try {
            $ch = curl_init(self::SERVICE_LOGIN_URL);
            curl_setopt($ch, CURLOPT_COOKIEJAR, $ckfile);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_POST, 1);
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headerArray);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $postdata);
            curl_setopt($ch, CURLOPT_COOKIEFILE, $ckfile);
            curl_setopt($ch, CURLOPT_VERBOSE, true);
            $output = curl_exec($ch);

            if ($output === false) {
                throw new \Exception(sprintf('No connection with service, msg: %s', curl_error($ch)));
            }
            $filenameArray = explode(DIRECTORY_SEPARATOR, $ckfile);
            return end($filenameArray);

        } finally {
            curl_close($ch);
        }
    }

Zatem token, login i hasło zostaną wysłane metodą POST pod URL ze stałej SERVICE_LOGIN_URL (opcja CURLOPT_POST z danymi z klucza CURLOPT_POSTFIELDS ). Do poprawnego obsłużenia żądania konieczne jest wysyłanie dodatkowo kilku nagłówków w tym cookie zawierającego token (CURLOPT_HTTPHEADER). Opcjonalne pozostaje użycie CURLOPT_RETURNTRANSFER i CURLOPT_VERBOSE – ułatwiają śledzenie błędów.

Serwer w przypadku poprawnego uwierzytelnienia wyśle dane (identyfikator sesji) do pliku cookie. Stąd konieczne jest jego wcześniejsze utworzenie przy użyciu funkcji tempnam() generującej losowo nazwany plik. Następnie odczyt tego pliku ułatwia biblioteka kegi/netscape-cookie-file-handler, jak poniżej.

 private function saveSessionId(string $ckfile): string
    {
        $configuration = (new Configuration())->setCookieDir(self::COOKIE_DIR);
        $cookieJar = (new CookieFileHandler($configuration))->parseFile($ckfile);
        $sessionIdObject = $cookieJar->get('sessionid');
        $sessionId = $sessionIdObject->getValue();
        $expire = $sessionIdObject->getExpire();
...

W dalszym kroku identyfikator sesji zostanie zapisany ze zmiennej $sessionId do bazy danych, tak by mógł zostać wykorzystywany dla późniejszych requestów.

Przykładowy request

Wreszcie przyszedł czas na request, który pozwoli nam na wykonanie jakiejś akcji po stronie zewnętrznej witryny. Może to wyglądać na przykład tak jak poniżej (pobranie danych do zmiennej $table zawartych w klasie .table drzewa DOM)

$crawler = $this->client->request('GET', self::SERVICE_SOME_TABLE_URL);
$table = $crawler->filter('.table')->first();

ufff….mimo to, jednak wciąż wolę REST’a 😉