Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
Client
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
2 / 2
5
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 buildGuzzleClient
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace ForgeDashboard;
6
7use ForgeDashboard\Generated\Api\AdminApi;
8use ForgeDashboard\Generated\Api\AuthApi;
9use ForgeDashboard\Generated\Api\DashboardApi;
10use ForgeDashboard\Generated\Api\HealthApi;
11use ForgeDashboard\Generated\Api\PullRequestsApi;
12use ForgeDashboard\Generated\Api\SettingsApi;
13use ForgeDashboard\Generated\Api\SharingApi;
14use ForgeDashboard\Generated\Api\TokensApi;
15use ForgeDashboard\Generated\Api\WebhooksApi;
16use ForgeDashboard\Generated\Configuration;
17use ForgeDashboard\Retry\RetryMiddleware;
18use GuzzleHttp\Client as GuzzleClient;
19use GuzzleHttp\ClientInterface;
20use GuzzleHttp\Handler\MockHandler;
21use GuzzleHttp\HandlerStack;
22use GuzzleHttp\Middleware;
23use Psr\Http\Message\RequestInterface;
24
25/**
26 * A forge-dashboard API client (github.com/alrayyes/forge-dashboard).
27 *
28 * Every operation except {@see HealthApi::health()}, {@see HealthApi::getVersion()}
29 * and the two webhook receivers ({@see WebhooksApi}) requires an
30 * authenticated session. forge-dashboard accepts either a browser's
31 * passkey session cookie or a personal API token
32 * (`Authorization: Bearer <token>`, minted via `POST /api/tokens`) -- this
33 * client only speaks the token half, the headless-friendly one. Pass it
34 * to the constructor or set {@see self::API_TOKEN_ENV_VAR}. See the
35 * README for the full explanation and an example.
36 *
37 * Each generated Api class is exposed as a public, readonly property
38 * ({@see self::$dashboard}, {@see self::$tokens}, ...) for direct,
39 * one-response-at-a-time calls -- forge-dashboard's spec has no
40 * pagination to wrap.
41 */
42final readonly class Client
43{
44    /**
45     * Environment variable the constructor falls back to when no API
46     * token is passed explicitly.
47     */
48    public const API_TOKEN_ENV_VAR = 'FORGE_DASHBOARD_API_TOKEN';
49
50    public HealthApi $health;
51
52    public DashboardApi $dashboard;
53
54    public AuthApi $auth;
55
56    public SettingsApi $settings;
57
58    public TokensApi $tokens;
59
60    public WebhooksApi $webhooks;
61
62    public PullRequestsApi $pullRequests;
63
64    public AdminApi $admin;
65
66    public SharingApi $sharing;
67
68    /**
69     * @param  string  $baseUrl  the forge-dashboard instance's origin, e.g.
70     *                           "https://forge-dashboard.example.com"
71     * @param  string|null  $apiToken  overrides
72     *                                 {@see self::API_TOKEN_ENV_VAR} when given
73     * @param  ClientInterface|null  $httpClient  replaces the underlying Guzzle
74     *                                            client entirely (a test double, usually); when given, the retry
75     *                                            and auth-injection middleware below are *not* applied, and
76     *                                            $handlerStack/$maxRetries/$retryBaseDelaySeconds are ignored --
77     *                                            the caller owns that behaviour instead
78     * @param  HandlerStack|null  $handlerStack  the base handler stack the
79     *                                           retry and auth-injection middleware get pushed onto -- pass a
80     *                                           stack built around a {@see MockHandler} in a
81     *                                           test to exercise both against a fake transport. Defaults to a
82     *                                           fresh {@see HandlerStack::create()} (the real cURL/stream
83     *                                           handler). Ignored when $httpClient is given.
84     * @param  int  $maxRetries  additional attempts after the first, for a
85     *                           429/5xx or network failure
86     * @param  float  $retryBaseDelaySeconds  the starting backoff before
87     *                                        jitter and any server-sent Retry-After
88     */
89    public function __construct(
90        string $baseUrl,
91        ?string $apiToken = null,
92        ?ClientInterface $httpClient = null,
93        ?HandlerStack $handlerStack = null,
94        int $maxRetries = 3,
95        float $retryBaseDelaySeconds = 0.25,
96    ) {
97        $token = $apiToken ?? (getenv(self::API_TOKEN_ENV_VAR) ?: null);
98
99        $guzzle = $httpClient ?? $this->buildGuzzleClient($handlerStack ?? HandlerStack::create(), $token, $maxRetries, $retryBaseDelaySeconds);
100
101        $config = new Configuration;
102        $config->setHost(rtrim($baseUrl, '/'));
103        // The generator sends a boolean query parameter as 1 or 0 by default;
104        // the API and its mock accept only true or false. Its serializer reads
105        // the process-wide default Configuration, not the instance above, so
106        // that is the one to set. Setting it again on every Client is harmless.
107        Configuration::getDefaultConfiguration()->setBooleanFormatForQueryString(Configuration::BOOLEAN_FORMAT_STRING);
108
109        $this->health = new HealthApi($guzzle, $config);
110        $this->dashboard = new DashboardApi($guzzle, $config);
111        $this->auth = new AuthApi($guzzle, $config);
112        $this->settings = new SettingsApi($guzzle, $config);
113        $this->tokens = new TokensApi($guzzle, $config);
114        $this->webhooks = new WebhooksApi($guzzle, $config);
115        $this->pullRequests = new PullRequestsApi($guzzle, $config);
116        $this->admin = new AdminApi($guzzle, $config);
117        $this->sharing = new SharingApi($guzzle, $config);
118    }
119
120    private function buildGuzzleClient(HandlerStack $stack, ?string $token, int $maxRetries, float $baseDelaySeconds): GuzzleClient
121    {
122        $stack->push(Middleware::mapRequest(
123            static function (RequestInterface $request) use ($token): RequestInterface {
124                if ($token === null || $token === '') {
125                    return $request;
126                }
127
128                return $request->withHeader('Authorization', 'Bearer '.$token);
129            },
130        ), 'bearer_auth');
131
132        $retry = new RetryMiddleware($maxRetries, $baseDelaySeconds);
133        $stack->push(Middleware::retry($retry->decider(), $retry->delay()), 'retry');
134
135        return new GuzzleClient(['handler' => $stack]);
136    }
137}