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.
14. Uploading Files (New in 3.13.69)
upload() posts a multipart/form-data body: a file plus optional text fields. Supply the file two ways, so your code never stages a temp file first.
<?php
use Tina4\Api;
$api = new Api('https://api.example.com', bearerToken: getenv('API_TOKEN'));
// A file on disk. filename defaults to the basename.
$result = $api->upload('/avatars', filePath: '/tmp/me.png');
// In-memory bytes. Pass a filename so the server sees a real name.
$raw = build_thumbnail(); // raw byte string
$result = $api->upload(
'/avatars',
fileBytes: $raw,
filename: 'me.png',
extraFields: ['user_id' => '42'], // extra text parts
);The full signature:
$api->upload(
string $path = '',
?string $filePath = null,
string $fieldName = 'file',
array $extraFields = [],
array $headers = [],
?string $fileBytes = null,
?string $filename = null
): arrayfieldName is the form field the file rides under (default "file"). extraFields become additional text parts. headers merge extra per-call headers onto the request. The part's Content-Type is guessed from the filename, falling back to application/octet-stream.
upload() returns the standard result array, keyed http_code, body, headers, error. A missing file, or no source at all, returns a clean error array and never throws:
$result = $api->upload('/avatars', filePath: '/tmp/gone.png');
// ['http_code' => null, 'body' => null, 'headers' => [], 'error' => 'file not found: /tmp/gone.png']15. Streaming Downloads (New in 3.13.69)
download() streams a GET body straight to disk, 64KB at a time. A large export never buffers whole in memory.
$result = $api->download('/reports/2026.csv', destPath: '/tmp/2026.csv');
if ($result['error'] === null) {
echo "saved to {$result['path']}"; // /tmp/2026.csv
}The signature:
$api->download(string $path = '', ?string $destPath = null, array $params = []): arrayThe result has no body key. The body went to disk. It carries http_code, headers, error, and path. On success path is your destPath; on any error (no dest, an HTTP error status, or a transport failure) path is null and no file is written.
16. Testing Your Code: the transport Seam (New in 3.13.69)
The constructor accepts a transport callable that fully replaces the network call. Point it at your own closure and the code that calls an Api runs in a unit test with no live server.
<?php
use Tina4\Api;
$fake = fn (string $method, string $url, array $headers, ?string $body, int $timeout): array
=> ['http_code' => 200, 'body' => ['ok' => true], 'headers' => [], 'error' => null];
$api = new Api('https://api.example.com', transport: $fake);
$result = $api->get('/health'); // returns the canned array, opens no socketThe callable signature is fn(string $method, string $url, array $headers, ?string $body, int $timeout): array, and it returns the standard result array.
This seam is for your tests, not Tina4's. The framework's own suite never injects a canned fake: it follows the no-mock rule, so its transport-seam test injects a transport that performs real socket I/O. Reach for transport to test the code that calls an Api, never to stand in for Api itself.
17. The Cookie Jar (New in 3.13.69)
Pass cookies: true and the client keeps a per-instance, in-memory cookie jar. It reads Set-Cookie on each response and replays the accumulated Cookie header on the next request, so a session carries across a login and the calls that follow.
<?php
use Tina4\Api;
$api = new Api('https://api.example.com', cookies: true);
$api->post('/login', ['user' => 'alice', 'pass' => 'secret']); // server sets a session cookie
$api->get('/account'); // the cookie is sent automaticallyThe jar is off by default. It keeps only the leading name=value of each cookie, it is never persisted, and it is scoped to the instance.
18. Redirects and Cross-Origin Safety (Security fix, 3.13.69)
The client follows redirects, and it now strips the Authorization header and the cookie-jar Cookie header on a cross-origin hop (a different scheme, host, or port). Same-origin redirects keep both headers.
This closes a real leak. Before 3.13.69, the PHP Api used the file_get_contents stream wrapper, which auto-follows redirects and forwarded the Authorization header and the session Cookie to the new origin. A call to https://api.example.com/login that redirected to https://evil.example/ handed your bearer token and session cookie to a host you never authenticated against. The client now follows redirects itself, one hop at a time, and drops those headers whenever the origin changes.
Upgrade recommended for anyone using Api against endpoints that redirect. You get the fix on every verb, on upload(), and on download(), with no code change and no new dependency (the stream wrapper only, no ext-curl).