commit 337d9658bceddf89bc0a8f90edd5e9be13edf0bd Author: Snider Date: Tue Mar 10 08:46:37 2026 +0000 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 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7b0d11d --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/src/AltumClient.php b/src/AltumClient.php new file mode 100644 index 0000000..e9ce8e0 --- /dev/null +++ b/src/AltumClient.php @@ -0,0 +1,408 @@ +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 + */ + 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 $data + * @return array + */ + 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 + */ + 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 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 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 + */ + public function getAdminUser(int|string $userId): array + { + return $this->adminGet("/admin-api/users/{$userId}"); + } + + /** + * List users from the Admin API. + * + * @param array $query Optional query parameters (page, etc.). + * @return array + */ + public function listAdminUsers(array $query = []): array + { + return $this->adminGet('/admin-api/users', $query); + } + + /** + * Delete a user via the Admin API. + * + * @return array + */ + 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 + */ + public function user(): array + { + return $this->userGet('/api/user'); + } + + /** + * List resources from the User API. + * + * @param array $query Optional query parameters. + * @return array + */ + public function listResource(string $resource, array $query = []): array + { + return $this->userGet("/api/{$resource}/", $query); + } + + /** + * Retrieve a single resource from the User API. + * + * @return array + */ + public function getResource(string $resource, int|string $id): array + { + return $this->userGet("/api/{$resource}/{$id}"); + } + + /** + * Create a resource via the User API. + * + * @param array $data + * @return array + */ + 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 $data + * @return array + */ + 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 + */ + 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 $query + * @return array + */ + 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 $data + * @return array + */ + 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 $query + * @return array + */ + 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 $query + * @return array + */ + 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 $data + * @return array + */ + 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 $query + * @return array + */ + 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 $data Query params for GET/DELETE, form fields for POST. + * @return array + */ + 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; + } +}