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
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 headerserror, error message string on failure,nullon success
<?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
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
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
$result = $api->sendRequest('PUT', '/posts/1', [], [
'id' => 1,
'title' => 'Updated Title',
'body' => 'Updated content.',
'userId' => 1
]);
echo $result['status']; // 200PATCH - Partial update
$result = $api->sendRequest('PATCH', '/posts/1', [], [
'title' => 'Just the title changed'
]);
echo $result['status']; // 200DELETE - Remove a resource
$result = $api->sendRequest('DELETE', '/posts/1');
echo $result['status']; // 200 or 2046. Query Parameters
Pass query parameters as the third argument:
<?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']); // 57. Custom Headers
Use addCustomHeaders() to set headers that apply to all subsequent requests from this client instance.
<?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_method8. Basic Authentication
Use setUsernamePassword() for HTTP Basic Auth:
<?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
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
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
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']
]);
});curl http://localhost:7145/api/weather/London{
"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
- Create these endpoints:
| Method | Path | Description |
|---|---|---|
GET | /api/github/{owner}/{repo} | Repo details (stars, forks, description) |
GET | /api/github/{owner}/{repo}/releases | Latest 5 releases |
- Use the GitHub public API at
https://api.github.com - Set the required
User-Agentheader (GitHub rejects requests without it) - Handle 404 gracefully
Test with:
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-repo13. 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.