Test Failed
Push — master ( ba0a42...78a1a9 )
by P.R.
02:25
created

ClubCollectApiClient::composeQuery()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 2
nc 2
nop 1
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
4
namespace SetBased\ClubCollect;
5
6
use Composer\CaBundle\CaBundle;
7
use GuzzleHttp\Client;
8
use GuzzleHttp\ClientInterface;
9
use GuzzleHttp\Exception\GuzzleException;
10
use GuzzleHttp\Psr7\Request;
11
use GuzzleHttp\RequestOptions;
12
use Plaisio\Helper\Url;
13
use Psr\Http\Message\ResponseInterface;
14
use Psr\Http\Message\StreamInterface;
15
use SetBased\ClubCollect\Endpoint\CompanyEndpoint;
16
use SetBased\ClubCollect\Endpoint\ImportEndpoint;
17
use SetBased\ClubCollect\Endpoint\InvoiceEndpoint;
18
use SetBased\ClubCollect\Endpoint\TicketEndpoint;
19
use SetBased\ClubCollect\Exception\ClubCollectApiException;
20
use SetBased\ClubCollect\Helper\Cast;
21
22
/**
23
 * A ClubCollect API client.
24
 */
25
class ClubCollectApiClient
26
{
27
  //--------------------------------------------------------------------------------------------------------------------
28
  /**
29
   * GET method.
30
   */
31
  const HTTP_GET = "GET";
32
33
  /**
34
   * POST method.
35
   */
36
  const HTTP_POST = "POST";
37
38
  /**
39
   * DELETE method
40
   */
41
  const HTTP_DELETE = "DELETE";
42
43
  /**
44
   * PUT method
45
   */
46
  const HTTP_PUT = "PUT";
47
48
  /**
49
   * HTTP status code no content.
50
   */
51
  const HTTP_NO_CONTENT = 204;
52
53
  /**
54
   * Version of the remote API.
55
   */
56
  const API_VERSION = "v2";
57
58
  /**
59
   * Default response timeout (in seconds).
60
   */
61
  const TIMEOUT = 10;
62
63
  /**
64
   * RESTFul company endpoint.
65
   *
66
   * @var CompanyEndpoint
67
   */
68
  public $company;
69
70
  /**
71
   * RESTFul company endpoint.
72
   *
73
   * @var ImportEndpoint
74
   */
75
  public $import;
76
77
  /**
78
   * RESTFul invoice endpoint.
79
   *
80
   * @var InvoiceEndpoint
81
   */
82
  public $invoice;
83
84
  /**
85
   * RESTFul ticket endpoint.
86
   *
87
   * @var TicketEndpoint
88
   */
89
  public $ticket;
90
91
  /**
92
   * Endpoint of the remote API.
93
   *
94
   * @var string
95
   */
96
  protected $apiEndpoint;
97
98
  /**
99
   * Partner API Key.
100
   *
101
   * @var string
102
   */
103
  private $apiKey;
104
105
  /**
106
   * Company ID, supplied by ClubCollect.
107
   *
108
   * @var string
109
   */
110
  private $companyId;
111
112
  /**
113
   * The http client.
114
   *
115
   * @var ClientInterface
116
   */
117
  private $httpClient;
118
119
  //--------------------------------------------------------------------------------------------------------------------
120
  /**
121
   * Object constructor.
122
   *
123
   * @param string $apiEndpoint The API endpoint. For testing 'https://sandbox.clubcollect.com/api', for production
124
   *                            'https://api.clubcollect.com/api'.
125
   * @param string $apiKey      Partner API Key.
126
   * @param string $companyId   Company ID, supplied by ClubCollect.
127
   *
128
   * @throws ClubCollectApiException
129
   */
130
  public function __construct(string $apiEndpoint, string $apiKey, string $companyId)
131
  {
132
    $this->apiEndpoint = $apiEndpoint;
133
    $this->apiKey      = $apiKey;
134
    $this->companyId   = $companyId;
135
136
    $this->validateIdAndKey($apiKey, $companyId);
137
    $this->initHttpClient();
138
    $this->initializeEndpoints();
139
  }
140
141
  //--------------------------------------------------------------------------------------------------------------------
142
  /**
143
   * Returns the Partner API Key.
144
   *
145
   * @return string
146
   */
147
  public function getApiKey(): string
148
  {
149
    return $this->apiKey;
150
  }
151
152
  //--------------------------------------------------------------------------------------------------------------------
153
  /**
154
   * Returns the Company ID, supplied by ClubCollect.
155
   *
156
   * @return string
157
   */
158
  public function getCompanyId(): string
159
  {
160
    return $this->companyId;
161
  }
162
163
  //--------------------------------------------------------------------------------------------------------------------
164
  /**
165
   * Performs an HTTP call. This method is used by the resource specific classes.
166
   *
167
   * @param string     $httpMethod The HTTP method.
168
   * @param array      $path       The parts of the path.
169
   * @param array|null $query      The query parameters. A map from key to value.
170
   * @param array|null $body       The body parameters. A map from key to value.
171
   *
172
   * @return array|null
173
   *
174
   * @throws ClubCollectApiException
175
   */
176
  public function performHttpCall(string $httpMethod,
177
                                  array $path,
178
                                  ?array $query = null,
179
                                  ?array $body = null): ?array
180
  {
181
    $url = sprintf('%s/%s%s%s',
182
                   $this->apiEndpoint,
183
                   self::API_VERSION,
184
                   $this->composePath($path),
185
                   $this->composeQuery($query));
186
187
    return $this->performHttpCallToFullUrl($httpMethod, $url, $this->composeRequestBody($body));
188
  }
189
190
  //--------------------------------------------------------------------------------------------------------------------
191
  /**
192
   * Perform an http call to a full url. This method is used by the resource specific classes.
193
   *
194
   * @param string                               $httpMethod
195
   * @param string                               $url
196
   * @param string|null|resource|StreamInterface $httpBody
197
   *
198
   * @return array|null
199
   *
200
   * @throws ClubCollectApiException
201
   */
202
  public function performHttpCallToFullUrl(string $httpMethod, string $url, ?string $httpBody = null): ?array
203
  {
204
    $headers = ['Accept'       => 'application/json',
205
                'Content-Type' => 'application/json'];
206
    $request = new Request($httpMethod, $url, $headers, $httpBody);
207
208
    try
209
    {
210
      $response = $this->httpClient->send($request, ['http_errors' => false]);
211
    }
212
    catch (GuzzleException $exception)
213
    {
214
      throw ClubCollectApiException::createFromGuzzleException($exception);
215
    }
216
217
    if (!$response)
218
    {
219
      throw new ClubCollectApiException("Did not receive API response.");
220
    }
221
222
    return $this->parseResponseBody($response);
223
  }
224
225
  //--------------------------------------------------------------------------------------------------------------------
226
  /**
227
   * Returns the URL for ClubCollect Treasurer SSO login.
228
   *
229
   * @param string $salt The salt (provided by ClubCollect).
230
   * @param string $key  The key (provided by ClubCollect).
231
   *
232
   * @return string
233
   */
234
  public function ssoUrlTreasurer(string $salt, string $key)
235
  {
236
    $hash = hash('sha256', sprintf('%s%s%s%s', date('Ymd'), $this->getCompanyId(), $salt, $key));
237
238
    $parts          = parse_url($this->apiEndpoint);
239
    $parts['path']  = '/treasurer/sso';
240
    $parts['query'] = http_build_query(['company_uuid' => $this->companyId,
241
                                        'signature'    => $hash]);
242
    unset($parts['fragment']);
243
244
    return Url::unParseUrl($parts);
245
  }
246
247
  //--------------------------------------------------------------------------------------------------------------------
248
  /**
249
   * Composes the request body.
250
   *
251
   * @param array $body The body parameters. A map from key to value.
252
   *
253
   * @return string|null
254
   *
255
   * @throws ClubCollectApiException
256
   */
257
  protected function composeRequestBody(?array $body): ?string
258
  {
259
    if (empty($body))
260
    {
261
      return null;
262
    }
263
264
    try
265
    {
266
      $encoded = \GuzzleHttp\json_encode($body);
267
    }
268
    catch (\InvalidArgumentException $exception)
269
    {
270
      throw new ClubCollectApiException([$exception],
271
                                        'Error encoding parameters into JSON: %s',
272
                                        $exception->getMessage());
273
    }
274
275
    return $encoded;
276
  }
277
278
  //--------------------------------------------------------------------------------------------------------------------
279
  /**
280
   * Composes the path part of the URL.
281
   *
282
   * @param array $path The parts of the path.
283
   *
284
   * @return string
285
   */
286
  private function composePath(array $path): string
287
  {
288
    $uri = '';
289
    foreach ($path as $part)
290
    {
291
      if ($part!==null)
292
      {
293
        $uri .= '/';
294
        $uri .= urlencode(Cast::toManString($part));
295
      }
296
    }
297
298
    return $uri;
299
  }
300
301
  //--------------------------------------------------------------------------------------------------------------------
302
  /**
303
   * Composes the query part of the URL.
304
   *
305
   * @param array|null $query The query parameters. A map from key to value.
306
   *
307
   * @return string
308
   */
309
  private function composeQuery(?array $query): string
310
  {
311
    if (empty($query)) return '';
312
313
    return '?'.http_build_query($query);
314
  }
315
316
  //--------------------------------------------------------------------------------------------------------------------
317
  /**
318
   * Initializes the http client.
319
   */
320
  private function initHttpClient(): void
321
  {
322
    $this->httpClient = new Client([RequestOptions::VERIFY  => CaBundle::getBundledCaBundlePath(),
323
                                    RequestOptions::TIMEOUT => self::TIMEOUT]);
324
  }
325
326
  //--------------------------------------------------------------------------------------------------------------------
327
  /**
328
   * Initializes the endpoints.
329
   */
330
  private function initializeEndpoints(): void
331
  {
332
    $this->company = new CompanyEndpoint($this);
333
    $this->import  = new ImportEndpoint($this);
334
    $this->invoice = new InvoiceEndpoint($this);
335
    $this->ticket  = new TicketEndpoint($this);
336
  }
337
338
  //--------------------------------------------------------------------------------------------------------------------
339
  /**
340
   * Parse the PSR-7 Response body
341
   *
342
   * @param ResponseInterface $response
343
   *
344
   * @return array|null
345
   * @throws ClubCollectApiException
346
   */
347
  private function parseResponseBody(ResponseInterface $response): ?array
348
  {
349
    $body = (string)$response->getBody();
350
    if (empty($body))
351
    {
352
      if ($response->getStatusCode()===self::HTTP_NO_CONTENT)
353
      {
354
        return null;
355
      }
356
357
      throw new ClubCollectApiException("No response body found.");
358
    }
359
360
    $object = @json_decode($body, true);
361
    if (json_last_error()!==JSON_ERROR_NONE)
362
    {
363
      throw new ClubCollectApiException("Unable to decode ClubCollect response: '{$body}'.");
364
    }
365
366
    if ($response->getStatusCode()>=400)
367
    {
368
      throw ClubCollectApiException::createFromResponse($response);
369
    }
370
371
    return $object;
372
  }
373
374
  //--------------------------------------------------------------------------------------------------------------------
375
  /**
376
   * Validates the Partner API Key and Company ID.
377
   *
378
   * @param string $apiKey    Partner API Key.
379
   * @param string $companyId Company ID, supplied by ClubCollect.
380
   *
381
   * @throws ClubCollectApiException
382
   */
383
  private function validateIdAndKey(string $apiKey, string $companyId): void
384
  {
385
    if (preg_match('/^[0-9a-f]{40,}$/', $apiKey)!=1)
386
    {
387
      throw new ClubCollectApiException("Invalid API key: '%s'", $apiKey);
388
    }
389
390
    if (preg_match('/^[0-9a-f]{40,}$/', $companyId)!=1)
391
    {
392
      throw new ClubCollectApiException("Invalid company ID: '%s'", $companyId);
393
    }
394
  }
395
396
  //--------------------------------------------------------------------------------------------------------------------
397
}
398
399
//----------------------------------------------------------------------------------------------------------------------
400