feat: scaffold php-plug-altum with AltumClient base class

Base API client for all 4 AltumCode products (analytics, biolinks,
pusher, socialproof). Shared REST conventions: Bearer auth, form-encoded
POST, Admin API (/admin-api/) and User API (/api/) with CRUD helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-10 08:46:37 +00:00
commit 337d9658bc
2 changed files with 437 additions and 0 deletions

29
composer.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "lthn/php-plug-altum",
"description": "AltumCode product API clients (analytics, biolinks, pusher, socialproof)",
"type": "library",
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"illuminate/http": "^12.0",
"illuminate/support": "^12.0"
},
"require-dev": {
"pestphp/pest": "^3.0"
},
"autoload": {
"psr-4": {
"Core\\Plug\\Altum\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Core\\Plug\\Altum\\Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"replace": {
"core/php-plug-altum": "self.version"
}
}

408
src/AltumClient.php Normal file
View file

@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Altum;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Base API client for AltumCode products.
*
* All four AltumCode products (66analytics, 66biolinks, 66pusher,
* 66socialproof) share identical REST conventions. This client wraps
* both the Admin API and the per-user API with consistent error
* handling and logging.
*
* AltumCode quirks:
* - POST is used for both creation and updates (never PUT/PATCH).
* - POST bodies are form-encoded (not JSON).
* - Admin endpoints live under /admin-api/.
* - User endpoints live under /api/{resource}/.
*/
class AltumClient
{
protected string $baseUrl;
protected string $adminApiKey;
protected ?string $userApiKey;
protected int $timeout;
public function __construct(
string $baseUrl,
string $adminApiKey,
?string $userApiKey = null,
int $timeout = 15,
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->adminApiKey = $adminApiKey;
$this->userApiKey = $userApiKey;
$this->timeout = $timeout;
}
// -------------------------------------------------------------------------
// Immutable cloning
// -------------------------------------------------------------------------
/**
* Return a new client instance with a different user API key.
*
* The original instance is not mutated.
*/
public function withUserKey(string $userApiKey): static
{
$clone = clone $this;
$clone->userApiKey = $userApiKey;
return $clone;
}
// -------------------------------------------------------------------------
// Admin API — Users
// -------------------------------------------------------------------------
/**
* Create a user via the Admin API.
*
* @return array<string, mixed>
*/
public function createUser(string $name, string $email, string $password): array
{
return $this->adminPost('/admin-api/users', [
'name' => $name,
'email' => $email,
'password' => $password,
]);
}
/**
* Update an existing user via the Admin API.
*
* @param int|string $userId
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function updateUser(int|string $userId, array $data): array
{
return $this->adminPost("/admin-api/users/{$userId}", $data);
}
/**
* Assign a VIP plan to a user (plan_expiration_date far in the future).
*
* This is a convenience wrapper around updateUser() that sets the
* plan_id and a distant expiry so the user effectively has a
* permanent subscription managed by our billing layer.
*
* @return array<string, mixed>
*/
public function assignVipPlan(int|string $userId, int $planId = 2, string $expiry = '2056-01-01'): array
{
return $this->updateUser($userId, [
'plan_id' => $planId,
'plan_expiration_date' => $expiry,
]);
}
/**
* Retrieve a one-time login code for a user.
*
* @return array<string, mixed> Contains 'one_time_login_code' on success.
*/
public function oneTimeLoginCode(int|string $userId): array
{
return $this->adminGet("/admin-api/users/{$userId}/one-time-login-code");
}
/**
* Build a full one-time login URL for a user.
*
* Fetches the login code via the Admin API and returns the
* complete redirect URL the customer's browser should visit.
*
* @return array<string, mixed> Contains 'url' on success, or 'error' on failure.
*/
public function oneTimeLoginUrl(int|string $userId, ?string $redirectTo = null): array
{
$result = $this->oneTimeLoginCode($userId);
if (isset($result['error'])) {
return $result;
}
$code = $result['data']['one_time_login_code']
?? $result['one_time_login_code']
?? null;
if ($code === null) {
return [
'error' => true,
'message' => 'One-time login code not found in response.',
'response' => $result,
];
}
$url = "{$this->baseUrl}/login/one-time-login-code/{$code}";
if ($redirectTo !== null) {
$url .= '?redirect=' . urlencode($redirectTo);
}
return ['url' => $url];
}
/**
* Retrieve a single user from the Admin API.
*
* @return array<string, mixed>
*/
public function getAdminUser(int|string $userId): array
{
return $this->adminGet("/admin-api/users/{$userId}");
}
/**
* List users from the Admin API.
*
* @param array<string, mixed> $query Optional query parameters (page, etc.).
* @return array<string, mixed>
*/
public function listAdminUsers(array $query = []): array
{
return $this->adminGet('/admin-api/users', $query);
}
/**
* Delete a user via the Admin API.
*
* @return array<string, mixed>
*/
public function deleteAdminUser(int|string $userId): array
{
return $this->adminDelete("/admin-api/users/{$userId}");
}
// -------------------------------------------------------------------------
// User API — generic resource helpers
// -------------------------------------------------------------------------
/**
* Retrieve the authenticated user's own profile.
*
* @return array<string, mixed>
*/
public function user(): array
{
return $this->userGet('/api/user');
}
/**
* List resources from the User API.
*
* @param array<string, mixed> $query Optional query parameters.
* @return array<string, mixed>
*/
public function listResource(string $resource, array $query = []): array
{
return $this->userGet("/api/{$resource}/", $query);
}
/**
* Retrieve a single resource from the User API.
*
* @return array<string, mixed>
*/
public function getResource(string $resource, int|string $id): array
{
return $this->userGet("/api/{$resource}/{$id}");
}
/**
* Create a resource via the User API.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function createResource(string $resource, array $data): array
{
return $this->userPost("/api/{$resource}/", $data);
}
/**
* Update a resource via the User API.
*
* AltumCode uses POST (not PUT/PATCH) for updates.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function updateResource(string $resource, int|string $id, array $data): array
{
return $this->userPost("/api/{$resource}/{$id}", $data);
}
/**
* Delete a resource via the User API.
*
* @return array<string, mixed>
*/
public function deleteResource(string $resource, int|string $id): array
{
return $this->userDelete("/api/{$resource}/{$id}");
}
// -------------------------------------------------------------------------
// Admin HTTP transport
// -------------------------------------------------------------------------
/**
* Perform a GET request against the Admin API.
*
* @param array<string, mixed> $query
* @return array<string, mixed>
*/
protected function adminGet(string $path, array $query = []): array
{
return $this->request('GET', $path, $query, $this->adminApiKey);
}
/**
* Perform a POST request against the Admin API.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function adminPost(string $path, array $data = []): array
{
return $this->request('POST', $path, $data, $this->adminApiKey);
}
/**
* Perform a DELETE request against the Admin API.
*
* @param array<string, mixed> $query
* @return array<string, mixed>
*/
protected function adminDelete(string $path, array $query = []): array
{
return $this->request('DELETE', $path, $query, $this->adminApiKey);
}
// -------------------------------------------------------------------------
// User HTTP transport
// -------------------------------------------------------------------------
/**
* Perform a GET request against the User API.
*
* @param array<string, mixed> $query
* @return array<string, mixed>
*/
protected function userGet(string $path, array $query = []): array
{
return $this->request('GET', $path, $query, $this->requireUserKey());
}
/**
* Perform a POST request against the User API.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function userPost(string $path, array $data = []): array
{
return $this->request('POST', $path, $data, $this->requireUserKey());
}
/**
* Perform a DELETE request against the User API.
*
* @param array<string, mixed> $query
* @return array<string, mixed>
*/
protected function userDelete(string $path, array $query = []): array
{
return $this->request('DELETE', $path, $query, $this->requireUserKey());
}
// -------------------------------------------------------------------------
// Core HTTP
// -------------------------------------------------------------------------
/**
* Send an HTTP request to the AltumCode backend.
*
* On failure (network error or non-2xx status), a structured error
* array is returned rather than throwing an exception. This keeps
* the calling code simple and avoids leaking transport details.
*
* @param array<string, mixed> $data Query params for GET/DELETE, form fields for POST.
* @return array<string, mixed>
*/
private function request(string $method, string $path, array $data, string $apiKey): array
{
$url = $this->baseUrl . $path;
try {
/** @var PendingRequest $pending */
$pending = Http::timeout($this->timeout)
->withToken($apiKey);
$response = match ($method) {
'GET' => $pending->get($url, $data),
'POST' => $pending->asForm()->post($url, $data),
'DELETE' => $pending->delete($url, $data),
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"),
};
if ($response->successful()) {
return $response->json() ?? [];
}
Log::warning('AltumCode API request failed', [
'method' => $method,
'url' => $url,
'status' => $response->status(),
'body' => $response->body(),
]);
return [
'error' => true,
'status' => $response->status(),
'message' => "HTTP {$response->status()} from AltumCode API.",
'body' => $response->body(),
];
} catch (\Exception $e) {
Log::error('AltumCode API request error', [
'method' => $method,
'url' => $url,
'error' => $e->getMessage(),
]);
return [
'error' => true,
'message' => $e->getMessage(),
];
}
}
/**
* Resolve the user API key, ensuring one has been set.
*
* @throws \RuntimeException If no user API key is configured.
*/
protected function requireUserKey(): string
{
if ($this->userApiKey === null || $this->userApiKey === '') {
throw new \RuntimeException(
'No user API key configured. Use withUserKey() to set one before calling User API methods.'
);
}
return $this->userApiKey;
}
}