Skip to content

Commit 556d2a0

Browse files
authored
Merge pull request #6 from pushpad/full-api
Full API
2 parents ad9f5a5 + c2b2ebf commit 556d2a0

21 files changed

+2753
-294
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
/vendor/
22
/composer.lock
3+
/.phpunit.result.cache

README.md

Lines changed: 240 additions & 91 deletions
Large diffs are not rendered by default.

composer.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
],
1919
"require": {
2020
"php": ">=8.0.0",
21-
"ext-curl": "*",
22-
"ext-json": "*"
21+
"ext-curl": "*"
2322
},
2423
"autoload": {
2524
"psr-4": { "Pushpad\\" : "lib/" }

init.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
<?php
22

3-
require(dirname(__FILE__) . '/lib/Pushpad.php');
4-
require(dirname(__FILE__) . '/lib/Notification.php');
3+
require dirname(__FILE__) . '/lib/Exception/PushpadException.php';
4+
require dirname(__FILE__) . '/lib/Exception/ConfigurationException.php';
5+
require dirname(__FILE__) . '/lib/Exception/ApiException.php';
6+
require dirname(__FILE__) . '/lib/Exception/NetworkException.php';
7+
require dirname(__FILE__) . '/lib/Pushpad.php';
8+
require dirname(__FILE__) . '/lib/Resource.php';
9+
require dirname(__FILE__) . '/lib/HttpClient.php';
10+
require dirname(__FILE__) . '/lib/Notification.php';
11+
require dirname(__FILE__) . '/lib/Subscription.php';
12+
require dirname(__FILE__) . '/lib/Project.php';
13+
require dirname(__FILE__) . '/lib/Sender.php';

lib/Exception/ApiException.php

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pushpad\Exception;
6+
7+
/**
8+
* Represents an error response returned by the Pushpad API.
9+
*/
10+
class ApiException extends PushpadException
11+
{
12+
private int $statusCode;
13+
14+
/** @var mixed */
15+
private $responseBody;
16+
17+
/**
18+
* @var array<string, array<int, string>>|null
19+
*/
20+
private ?array $responseHeaders;
21+
22+
private ?string $rawBody;
23+
24+
/**
25+
* @param mixed $responseBody
26+
* @param array<string, array<int, string>>|null $responseHeaders
27+
*/
28+
public function __construct(
29+
string $message,
30+
int $statusCode,
31+
$responseBody = null,
32+
?array $responseHeaders = null,
33+
?string $rawBody = null
34+
) {
35+
parent::__construct($message, $statusCode);
36+
37+
$this->statusCode = $statusCode;
38+
$this->responseBody = $responseBody;
39+
$this->responseHeaders = $responseHeaders;
40+
$this->rawBody = $rawBody;
41+
}
42+
43+
public function getStatusCode(): int
44+
{
45+
return $this->statusCode;
46+
}
47+
48+
/**
49+
* @return mixed
50+
*/
51+
public function getResponseBody()
52+
{
53+
return $this->responseBody;
54+
}
55+
56+
/**
57+
* @return array<string, array<int, string>>|null
58+
*/
59+
public function getResponseHeaders(): ?array
60+
{
61+
return $this->responseHeaders;
62+
}
63+
64+
public function getRawBody(): ?string
65+
{
66+
return $this->rawBody;
67+
}
68+
69+
/**
70+
* @param array{status?:int, body?:mixed, headers?:array<string, array<int, string>>, raw_body?:?string} $response
71+
*/
72+
public static function fromResponse(array $response): self
73+
{
74+
$status = isset($response['status']) ? (int) $response['status'] : 0;
75+
$body = $response['body'] ?? null;
76+
$headers = $response['headers'] ?? null;
77+
$rawBody = $response['raw_body'] ?? null;
78+
79+
$message = self::buildMessage($status, $body);
80+
81+
return new self($message, $status, $body, $headers, $rawBody);
82+
}
83+
84+
/**
85+
* @param mixed $body
86+
*/
87+
private static function buildMessage(int $status, $body): string
88+
{
89+
$baseMessage = sprintf('Pushpad API responded with status %d.', $status);
90+
91+
$details = '';
92+
93+
if (is_array($body)) {
94+
foreach (['error_description', 'error', 'message'] as $key) {
95+
if (isset($body[$key]) && is_scalar($body[$key])) {
96+
$details = (string) $body[$key];
97+
break;
98+
}
99+
}
100+
101+
if ($details === '' && isset($body['errors'])) {
102+
$encoded = json_encode($body['errors']);
103+
$details = $encoded !== false ? $encoded : '';
104+
}
105+
} elseif (is_scalar($body) && $body !== '') {
106+
$details = (string) $body;
107+
}
108+
109+
if ($details === '' && $body !== null) {
110+
$encoded = json_encode($body);
111+
$details = $encoded !== false ? $encoded : '';
112+
}
113+
114+
if ($details === '' || $details === 'null') {
115+
return $baseMessage;
116+
}
117+
118+
return $baseMessage . ' ' . $details;
119+
}
120+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pushpad\Exception;
6+
7+
/**
8+
* Raised when the SDK is misconfigured.
9+
*/
10+
class ConfigurationException extends PushpadException
11+
{
12+
}
13+

lib/Exception/NetworkException.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pushpad\Exception;
6+
7+
/**
8+
* Raised when an HTTP request cannot be completed due to network errors.
9+
*/
10+
class NetworkException extends PushpadException
11+
{
12+
}

lib/Exception/PushpadException.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pushpad\Exception;
6+
7+
use RuntimeException;
8+
9+
/**
10+
* Base exception for all Pushpad SDK specific errors.
11+
*/
12+
class PushpadException extends RuntimeException
13+
{
14+
}
15+

lib/HttpClient.php

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pushpad;
6+
7+
use Pushpad\Exception\NetworkException;
8+
9+
/**
10+
* Thin wrapper around cURL tailored to the Pushpad API conventions.
11+
*/
12+
class HttpClient
13+
{
14+
private string $baseUrl;
15+
private string $authToken;
16+
private int $timeout;
17+
private string $userAgent;
18+
19+
/**
20+
* Initializes the HTTP client with some options.
21+
*
22+
* @param string $authToken API token granted by Pushpad.
23+
* @param string $baseUrl Base endpoint for the REST API.
24+
* @param int $timeout Default timeout in seconds for requests.
25+
* @param string $userAgent Forces a custom User-Agent header when provided.
26+
*
27+
* @throws \InvalidArgumentException When the authentication token is empty.
28+
*/
29+
public function __construct(string $authToken, string $baseUrl = 'https://pushpad.xyz/api/v1', int $timeout = 30, string $userAgent = 'pushpad-php')
30+
{
31+
if ($authToken === '') {
32+
throw new \InvalidArgumentException('Auth token must be a non-empty string.');
33+
}
34+
35+
$this->authToken = $authToken;
36+
$this->baseUrl = rtrim($baseUrl, '/');
37+
$this->timeout = $timeout;
38+
$this->userAgent = $userAgent;
39+
}
40+
41+
/**
42+
* Executes an HTTP request against the Pushpad API.
43+
*
44+
* @param string $method HTTP verb used for the request.
45+
* @param string $path Relative path appended to the base URL.
46+
* @param array{query?:array<string,mixed>, json?:mixed, body?:string, headers?:array<int,string>, timeout?:int} $options
47+
* @return array{status:int, body:mixed, headers:array<string, array<int, string>>, raw_body:?string}
48+
*
49+
* @throws NetworkException When the underlying cURL call fails.
50+
* @throws \RuntimeException When encoding the JSON payload fails.
51+
*/
52+
public function request(string $method, string $path, array $options = []): array
53+
{
54+
$url = $this->buildUrl($path, $options['query'] ?? []);
55+
$payload = null;
56+
$headers = $this->defaultHeaders();
57+
58+
if (isset($options['json'])) {
59+
$payload = json_encode($options['json']);
60+
if ($payload === false) {
61+
throw new \RuntimeException('Failed to encode JSON payload.');
62+
}
63+
$headers[] = 'Content-Type: application/json';
64+
} elseif (isset($options['body'])) {
65+
$payload = (string) $options['body'];
66+
}
67+
68+
if (!empty($options['headers'])) {
69+
$headers = array_merge($headers, $options['headers']);
70+
}
71+
72+
$timeout = isset($options['timeout']) ? (int) $options['timeout'] : $this->timeout;
73+
74+
$responseHeaders = [];
75+
$handle = curl_init($url);
76+
if ($handle === false) {
77+
throw new NetworkException('Unable to initialize cURL.');
78+
}
79+
80+
curl_setopt($handle, CURLOPT_CUSTOMREQUEST, strtoupper($method));
81+
curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
82+
curl_setopt($handle, CURLOPT_TIMEOUT, $timeout);
83+
curl_setopt($handle, CURLOPT_HTTPHEADER, $headers);
84+
curl_setopt($handle, CURLOPT_USERAGENT, $this->userAgent);
85+
curl_setopt($handle, CURLOPT_FOLLOWLOCATION, false);
86+
curl_setopt($handle, CURLOPT_HEADER, false);
87+
curl_setopt($handle, CURLOPT_HEADERFUNCTION, function ($curl, string $line) use (&$responseHeaders): int {
88+
$trimmed = trim($line);
89+
if ($trimmed === '' || stripos($trimmed, 'HTTP/') === 0) {
90+
return strlen($line);
91+
}
92+
[$name, $value] = array_map('trim', explode(':', $trimmed, 2));
93+
$key = strtolower($name);
94+
$responseHeaders[$key] = $responseHeaders[$key] ?? [];
95+
$responseHeaders[$key][] = $value;
96+
return strlen($line);
97+
});
98+
99+
if ($payload !== null) {
100+
curl_setopt($handle, CURLOPT_POSTFIELDS, $payload);
101+
}
102+
103+
$rawBody = curl_exec($handle);
104+
if ($rawBody === false) {
105+
$errorMessage = curl_error($handle);
106+
curl_close($handle);
107+
throw new NetworkException('cURL request error: ' . $errorMessage);
108+
}
109+
110+
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
111+
curl_close($handle);
112+
113+
return [
114+
'status' => $status,
115+
'body' => $this->decode($rawBody),
116+
'headers' => $responseHeaders,
117+
'raw_body' => $rawBody === '' ? null : $rawBody,
118+
];
119+
}
120+
121+
/**
122+
* Produces the base headers required for API requests.
123+
*
124+
* @return list<string>
125+
*/
126+
private function defaultHeaders(): array
127+
{
128+
return [
129+
'Authorization: Bearer ' . $this->authToken,
130+
'Accept: application/json',
131+
];
132+
}
133+
134+
/**
135+
* Creates an absolute URL including any query string parameters.
136+
*
137+
* @param string $path Request path relative to the base URL.
138+
* @param array<string, mixed> $query
139+
* @return string
140+
*/
141+
private function buildUrl(string $path, array $query): string
142+
{
143+
$url = $this->baseUrl . '/' . ltrim($path, '/');
144+
if (!empty($query)) {
145+
$queryString = $this->buildQueryString($query);
146+
if ($queryString !== '') {
147+
$url .= '?' . $queryString;
148+
}
149+
}
150+
151+
return $url;
152+
}
153+
154+
/**
155+
* Builds a URL-encoded query string from the provided parameters.
156+
*
157+
* @param array<string, mixed> $query
158+
* @return string
159+
*/
160+
private function buildQueryString(array $query): string
161+
{
162+
$parts = [];
163+
foreach ($query as $key => $value) {
164+
if ($value === null) {
165+
continue;
166+
}
167+
168+
if (is_array($value)) {
169+
foreach ($value as $item) {
170+
if ($item === null) {
171+
continue;
172+
}
173+
$parts[] = rawurlencode($key . '[]') . '=' . rawurlencode((string) $item);
174+
}
175+
continue;
176+
}
177+
178+
$parts[] = rawurlencode((string) $key) . '=' . rawurlencode((string) $value);
179+
}
180+
181+
return implode('&', $parts);
182+
}
183+
184+
/**
185+
* Decodes the JSON body when possible, returning the raw string otherwise.
186+
*
187+
* @param string $rawBody Raw body returned by cURL.
188+
* @return mixed
189+
*/
190+
private function decode(string $rawBody)
191+
{
192+
$trimmed = trim($rawBody);
193+
if ($trimmed === '') {
194+
return null;
195+
}
196+
197+
$decoded = json_decode($trimmed, true);
198+
if (json_last_error() === JSON_ERROR_NONE) {
199+
return $decoded;
200+
}
201+
202+
return $trimmed;
203+
}
204+
}

0 commit comments

Comments
 (0)