Skip to content

Chapter 21: API Client

1. Calling External APIs Without Dependencies

Every app calls external services. Weather APIs. Payment gateways. CRM systems. Shipping providers. The default PHP approach is cURL, verbose, error-prone, and full of boilerplate.

Tina4 provides a built-in Api class that wraps cURL with a clean interface. It supports GET, POST, PUT, DELETE, and PATCH with custom headers, basic auth, JSON or form payloads, and timeout control, no Guzzle, no Composer dependencies.


2. Creating an API Client

php
<?php
use Tina4\Api;

// Base URL -- all requests are relative to this
$api = new Api('https://api.example.com');

The Api class stores the base URL and shared configuration. Individual requests specify the path.


3. sendRequest()

All requests go through sendRequest(). It returns an associative array with:

  • status, HTTP status code (integer)
  • body, parsed response body (array if JSON, string otherwise)
  • headers, response headers
  • error, error message string on failure, null on success
php
<?php
use Tina4\Api;

$api = new Api('https://jsonplaceholder.typicode.com');

// GET request
$result = $api->sendRequest('GET', '/posts/1');

echo $result['status'];        // 200
print_r($result['body']);      // ['id' => 1, 'title' => '...', ...]

4. GET Requests

Fetch a resource:

php
<?php
use Tina4\Api;

$api = new Api('https://jsonplaceholder.typicode.com');

$result = $api->sendRequest('GET', '/users/1');

if ($result['status'] === 200) {
    $user = $result['body'];
    echo "Name: {$user['name']}\n";
    echo "Email: {$user['email']}\n";
} else {
    echo "Error {$result['status']}: " . ($result['error'] ?? 'Unknown error');
}

5. POST, PUT, PATCH, DELETE

Pass the payload as the fourth argument to sendRequest().

POST - Create a resource

php
<?php
use Tina4\Api;

$api = new Api('https://jsonplaceholder.typicode.com');

$result = $api->sendRequest('POST', '/posts', [], [
    'title'  => 'My New Post',
    'body'   => 'This is the content of the post.',
    'userId' => 1
]);

echo $result['status'];              // 201
echo $result['body']['id'];          // 101 (new resource ID)

PUT - Full update

php
$result = $api->sendRequest('PUT', '/posts/1', [], [
    'id'     => 1,
    'title'  => 'Updated Title',
    'body'   => 'Updated content.',
    'userId' => 1
]);

echo $result['status'];   // 200

PATCH - Partial update

php
$result = $api->sendRequest('PATCH', '/posts/1', [], [
    'title' => 'Just the title changed'
]);

echo $result['status'];   // 200

DELETE - Remove a resource

php
$result = $api->sendRequest('DELETE', '/posts/1');

echo $result['status'];   // 200 or 204

6. Query Parameters

Pass query parameters as the third argument:

php
<?php
use Tina4\Api;

$api = new Api('https://jsonplaceholder.typicode.com');

$result = $api->sendRequest('GET', '/posts', [
    'userId' => 1,
    '_limit' => 5,
    '_sort'  => 'id',
    '_order' => 'desc'
]);

// Resolves to: GET /posts?userId=1&_limit=5&_sort=id&_order=desc

echo count($result['body']);   // 5

7. Custom Headers

Use addCustomHeaders() to set headers that apply to all subsequent requests from this client instance.

php
<?php
use Tina4\Api;

$api = new Api('https://api.stripe.com/v1');

$api->addCustomHeaders([
    'Authorization' => 'Bearer sk_test_YOUR_STRIPE_KEY_HERE',
    'Stripe-Version' => '2023-10-16',
    'Idempotency-Key' => bin2hex(random_bytes(16))
]);

$result = $api->sendRequest('POST', '/payment_intents', [], [
    'amount'   => 2000,   // $20.00 in cents
    'currency' => 'usd',
    'payment_method_types' => ['card']
]);

echo $result['status'];          // 200
echo $result['body']['id'];      // pi_3OWL...
echo $result['body']['status'];  // requires_payment_method

8. Basic Authentication

Use setUsernamePassword() for HTTP Basic Auth:

php
<?php
use Tina4\Api;

$api = new Api('https://api.example.com');
$api->setUsernamePassword('my-api-key', 'my-api-secret');

$result = $api->sendRequest('GET', '/account');

echo $result['body']['plan'];   // 'enterprise'

The credentials are Base64-encoded and sent as an Authorization: Basic ... header.


9. Bearer Token Auth

Use a custom header for token-based auth (OAuth2, JWT):

php
<?php
use Tina4\Api;

// Step 1: Get an access token
$authApi = new Api('https://auth.example.com');
$tokenResult = $authApi->sendRequest('POST', '/oauth/token', [], [
    'grant_type'    => 'client_credentials',
    'client_id'     => getenv('CLIENT_ID'),
    'client_secret' => getenv('CLIENT_SECRET')
]);

$accessToken = $tokenResult['body']['access_token'];

// Step 2: Use the token for API calls
$api = new Api('https://api.example.com');
$api->addCustomHeaders([
    'Authorization' => "Bearer {$accessToken}",
    'Accept'        => 'application/json'
]);

$result = $api->sendRequest('GET', '/resources');

10. Error Handling

Always check status before accessing body. Handle network errors via error:

php
<?php
use Tina4\Api;

function fetchUser(int $id): ?array {
    $api = new Api('https://api.example.com');
    $result = $api->sendRequest('GET', "/users/{$id}");

    // Network or cURL failure
    if ($result['error'] !== null) {
        error_log("API network error: {$result['error']}");
        return null;
    }

    // HTTP error responses
    if ($result['status'] === 404) {
        return null;   // Not found -- expected, return null
    }

    if ($result['status'] === 429) {
        // Rate limited -- retry after delay
        sleep((int) ($result['headers']['Retry-After'] ?? 5));
        return fetchUser($id);  // Retry once
    }

    if ($result['status'] >= 500) {
        error_log("API server error {$result['status']} for user {$id}");
        throw new \RuntimeException("External API unavailable");
    }

    if ($result['status'] !== 200) {
        error_log("Unexpected status {$result['status']}");
        return null;
    }

    return $result['body'];
}

11. Calling External APIs from Routes

Wrap the Api client in route handlers to build aggregator or proxy endpoints:

php
<?php
use Tina4\Router;
use Tina4\Api;

/**
 * @noauth
 */
Router::get('/api/weather/{city}', function ($request, $response) {
    $city = $request->params['city'];
    $apiKey = getenv('OPENWEATHER_API_KEY');

    $api = new Api('https://api.openweathermap.org/data/2.5');

    $result = $api->sendRequest('GET', '/weather', [
        'q'     => $city,
        'appid' => $apiKey,
        'units' => 'metric'
    ]);

    if ($result['status'] === 404) {
        return $response->json(['error' => "City '{$city}' not found"], 404);
    }

    if ($result['status'] !== 200) {
        return $response->json(['error' => 'Weather service unavailable'], 502);
    }

    $weather = $result['body'];

    return $response->json([
        'city'        => $weather['name'],
        'country'     => $weather['sys']['country'],
        'temperature' => $weather['main']['temp'],
        'description' => $weather['weather'][0]['description'],
        'humidity'    => $weather['main']['humidity'],
        'wind_speed'  => $weather['wind']['speed']
    ]);
});
bash
curl http://localhost:7145/api/weather/London
json
{
  "city": "London",
  "country": "GB",
  "temperature": 12.5,
  "description": "overcast clouds",
  "humidity": 81,
  "wind_speed": 5.2
}

12. Exercise: GitHub API Proxy

Build a proxy that fetches GitHub repository information.

Requirements

  1. Create these endpoints:
MethodPathDescription
GET/api/github/{owner}/{repo}Repo details (stars, forks, description)
GET/api/github/{owner}/{repo}/releasesLatest 5 releases
  1. Use the GitHub public API at https://api.github.com
  2. Set the required User-Agent header (GitHub rejects requests without it)
  3. Handle 404 gracefully

Test with:

bash
curl http://localhost:7145/api/github/tina4/tina4-python
curl http://localhost:7145/api/github/tina4/tina4-python/releases
curl http://localhost:7145/api/github/nobody/nonexistent-repo

13. Gotchas

1. No timeout set

Problem: A slow external API hangs your request for 30+ seconds.

Cause: cURL default timeout is 0 (infinite wait).

Fix: Pass a timeout in the options: $api->sendRequest('GET', '/path', [], [], ['timeout' => 5]). Always set a reasonable timeout for external calls.

2. Not checking status before accessing body

Problem: Code crashes with "Undefined index" when the body is an error message, not the expected structure.

Cause: The API returned a 4xx/5xx error. The body is an error object, not the expected resource.

Fix: Always check $result['status'] before accessing $result['body'].

3. Credentials in source code

Problem: API keys and secrets are committed to the repository.

Cause: Credentials hard-coded in the Api instantiation call.

Fix: Always read credentials from environment variables: getenv('TINA4_API_KEY'). Never commit .env files.

4. Not handling rate limits

Problem: After many requests, the API returns 429 and all subsequent calls fail.

Cause: No rate limit detection or backoff logic.

Fix: Check for status === 429, read Retry-After from the response headers, and sleep before retrying.

Sponsored with 🩵 by Code InfinityCode Infinity