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, with no Guzzle and no Composer dependencies.
2. Creating an API Client#
<?phpuse 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
<?phpuse Tina4\Api;โ$api = new Api('https://jsonplaceholder.typicode.com');โ// GET request$result = $api->sendRequest('GET', '/posts/1');โecho $result['status']; // 200print_r($result['body']); // ['id' => 1, 'title' => '...', ...]4. GET Requests#
Fetch a resource:
<?phpuse 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#
<?phpuse 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']; // 201echo $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:
<?phpuse 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.
<?phpuse 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']; // 200echo $result['body']['id']; // pi_3OWL...echo $result['body']['status']; // requires_payment_method8. Basic Authentication#
Use setUsernamePassword() for HTTP Basic Auth:
<?phpuse 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):
<?phpuse 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:
<?phpuse 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:
<?phpuse 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-pythoncurl http://localhost:7145/api/github/tina4/tina4-python/releasescurl 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.