Lines 80.95% 51 / 63
Methods 72.72% 8 / 11
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 9 / 9 100.00% 1 / 1 4
 health 100.00% 1 / 1 100.00% 1 / 1 1
 createObject 100.00% 9 / 9 100.00% 1 / 1 1
 getObject 100.00% 6 / 6 100.00% 1 / 1 2
 updateObject 100.00% 5 / 5 100.00% 1 / 1 1
 deleteObject 100.00% 4 / 4 100.00% 1 / 1 1
 getObjectUsedBy 100.00% 1 / 1 100.00% 1 / 1 1
 queryAuditLog 91.66% 11 / 12 0.00% 0 / 1 4.01
 call 100.00% 3 / 3 100.00% 1 / 1 2
 expectType 50.00% 2 / 4 0.00% 0 / 1 2.50
 buildHttpClient 0.00% 0 / 9 0.00% 0 / 1 2
30final readonly class Client
31{
32    private const API_KEY_ENV_VAR = 'HUSH_HUSH_API_KEY';
33    private const DEFAULT_TIMEOUT = 30.0;
34    private const DEFAULT_MAX_RETRIES = 3;
35
36    private ObjectsApi $objectsApi;
37    private HealthApi $healthApi;
38    private AuditLogApi $auditLogApi;
39
40    /**
41     * @param string               $baseUrl    hush-hush's base URL, e.g. `https://hush-hush.example.com`.
42     * @param null|string          $apiKey     Bearer credential for write paths (create/update/delete).
43     *                                         Falls back to the `HUSH_HUSH_API_KEY` environment variable when
44     *                                         not supplied. Read paths (get, used-by, audit-log query) need
45     *                                         no credential at all.
46     * @param float                $timeout    per-request timeout, in seconds
47     * @param int                  $maxRetries maximum retry attempts for network failures and 5xx/429 responses
48     * @param null|ClientInterface $httpClient Override for the underlying HTTP client, mainly for tests.
49     *                                         When given, `$timeout` and `$maxRetries` are ignored — the
50     *                                         caller owns retry/timeout configuration on the client it passes in.
51     */
52    public function __construct(
53        string $baseUrl,
54        ?string $apiKey = null,
55        float $timeout = self::DEFAULT_TIMEOUT,
56        int $maxRetries = self::DEFAULT_MAX_RETRIES,
57        ?ClientInterface $httpClient = null,
58    ) {
59        $configuration = new Configuration();
60        $configuration->setHost(rtrim($baseUrl, '/'));
61
62        $resolvedKey = $apiKey ?? (getenv(self::API_KEY_ENV_VAR) ?: null);
63        if (null !== $resolvedKey && '' !== $resolvedKey) {
64            $configuration->setAccessToken($resolvedKey);
65        }
66
67        $client = $httpClient ?? self::buildHttpClient($timeout, $maxRetries);
68
69        $this->objectsApi = new ObjectsApi($client, $configuration);
70        $this->healthApi = new HealthApi($client, $configuration);
71        $this->auditLogApi = new AuditLogApi($client, $configuration);
72    }
73
74    /** Answers whether the server process is up. Needs no credential. */
75    public function health(): Health
76    {
77        return $this->call(fn () => $this->healthApi->health());
78    }
79
80    /**
81     * Stores an already-sealed value under a new object slug. Requires a credential.
82     *
83     * @param string        $slug   The new object's user-facing slug. Must match hush-hush's
84     *                              slug pattern (lowercase alphanumeric, `-`/`_`). The object's
85     *                              internal id is a separate, opaque value never exposed to or
86     *                              accepted from a caller.
87     * @param string        $value  The already-sealed (encrypted) value, as raw bytes. This SDK
88     *                              never encrypts or decrypts anything.
89     * @param null|string[] $usedBy Consumers (repos or hosts) recorded as depending on
90     *                              this object. Set once, at creation; unaffected by later value updates.
91     * @param null|string   $caller Recorded in the audit log as the calling program's
92     *                              self-reported identity. Not verified by the server.
93     *
94     * @throws ApiException If the server responds with anything other than 201
95     *                      (e.g. 409 if the slug already exists).
96     */
97    public function createObject(
98        string $slug,
99        string $value,
100        ?array $usedBy = null,
101        ?string $caller = null,
102    ): ObjectMetadata {
103        $request = new CreateObjectRequest([
104            'slug' => $slug,
105            'value' => base64_encode($value),
106            'used_by' => $usedBy,
107        ]);
108
109        return $this->call(fn () => self::expectType(
110            $this->objectsApi->createObject($request, $caller),
111            ObjectMetadata::class,
112        ));
113    }
114
115    /**
116     * Fetches an object's sealed ciphertext exactly as stored — this SDK never
117     * decrypts it, the same as the server. Needs no credential.
118     *
119     * @param string      $slug   the object's slug
120     * @param null|string $caller recorded in the audit log as the calling program's
121     *                            self-reported identity
122     *
123     * @throws ApiException If the server responds with anything other than 200
124     *                      (e.g. 404 if no object exists under that slug).
125     */
126    public function getObject(string $slug, ?string $caller = null): string
127    {
128        return $this->call(function () use ($slug, $caller): string {
129            $file = self::expectType($this->objectsApi->getObject($slug, $caller), \SplFileObject::class);
130            $contents = file_get_contents($file->getPathname());
131            unlink($file->getPathname());
132
133            return false === $contents ? '' : $contents;
134        });
135    }
136
137    /**
138     * Replaces the stored ciphertext for an existing object. The object's slug and
139     * used-by metadata are unchanged. Requires a credential.
140     *
141     * @param string      $slug   the existing object's slug
142     * @param string      $value  the new already-sealed (encrypted) value, as raw bytes
143     * @param null|string $caller recorded in the audit log as the calling program's
144     *                            self-reported identity
145     *
146     * @throws ApiException If the server responds with anything other than 200
147     *                      (e.g. 401 or 404).
148     */
149    public function updateObject(string $slug, string $value, ?string $caller = null): ObjectMetadata
150    {
151        $request = new UpdateObjectRequest(['value' => base64_encode($value)]);
152
153        return $this->call(fn () => self::expectType(
154            $this->objectsApi->updateObject($slug, $request, $caller),
155            ObjectMetadata::class,
156        ));
157    }
158
159    /**
160     * Permanently removes an object. A subsequent fetch by this slug returns 404.
161     * Requires a credential.
162     *
163     * @param string      $slug   the object's slug
164     * @param null|string $caller recorded in the audit log as the calling program's
165     *                            self-reported identity
166     *
167     * @throws ApiException If the server responds with anything other than 204
168     *                      (e.g. 401 or 404).
169     */
170    public function deleteObject(string $slug, ?string $caller = null): void
171    {
172        $this->call(function () use ($slug, $caller): null {
173            $this->objectsApi->deleteObject($slug, $caller);
174
175            return null;
176        });
177    }
178
179    /**
180     * Returns the recorded list of consumers for an object — the "what depends on
181     * this" mapping set at creation. Needs no credential.
182     *
183     * @param string $slug the object's slug
184     *
185     * @throws ApiException If the server responds with anything other than 200
186     *                      (e.g. 404).
187     */
188    public function getObjectUsedBy(string $slug): UsedBy
189    {
190        return $this->call(fn () => self::expectType($this->objectsApi->getObjectUsedBy($slug), UsedBy::class));
191    }
192
193    /**
194     * Queries the audit log — every create, read, update, and delete call is
195     * recorded here. Needs no credential. Filters combine with AND when more than
196     * one is given.
197     *
198     * hush-hush's `/audit-log` endpoint has no pagination parameters, so this
199     * always returns the full matching result set as a single array, never a page
200     * plus a cursor.
201     *
202     * @param array{objectId?: string, caller?: string, actor?: string, from?: string, to?: string} $filter
203     *     Optional filters. `objectId` restricts to entries for that object id;
204     *     `caller` to entries recorded with that caller identity; `actor` to
205     *     entries authenticated by that verified actor (never self-reported,
206     *     unlike `caller`); `from`/`to` (ISO-8601 timestamps) bound the
207     *     timestamp range.
208     *
209     * @return AuditLogEntry[]
210     */
211    public function queryAuditLog(array $filter = []): array
212    {
213        return $this->call(function () use ($filter): array {
214            $result = $this->auditLogApi->queryAuditLog(
215                $filter['objectId'] ?? null,
216                $filter['caller'] ?? null,
217                $filter['actor'] ?? null,
218                // Iso8601DateTime, not \DateTime — see its docblock for why a
219                // plain \DateTime crashes ObjectSerializer here.
220                isset($filter['from']) ? new Iso8601DateTime($filter['from']) : null,
221                isset($filter['to']) ? new Iso8601DateTime($filter['to']) : null,
222            );
223
224            if ($result instanceof Error) {
225                throw new ApiException(400, (string) json_encode(['error' => $result->getError()]), null);
226            }
227
228            return $result;
229        });
230    }
231
232    /**
233     * @template T
234     *
235     * @param callable(): T $fn
236     *
237     * @return T
238     */
239    private function call(callable $fn): mixed
240    {
241        try {
242            return $fn();
243        } catch (GeneratedApiException $e) {
244            throw ApiException::fromGenerated($e);
245        }
246    }
247
248    /**
249     * Generated methods declare a return type spanning every documented status
250     * code's model, since the generator has no way to know a 2xx-only shape at
251     * codegen time — a spec-conforming server only ever produces the success
252     * type here, since every non-2xx status routes through {@see GeneratedApiException}
253     * instead (Guzzle's default `http_errors` behavior). This just makes that
254     * guarantee explicit for the type checker and callers alike.
255     *
256     * @template T of object
257     *
258     * @param class-string<T> $class
259     *
260     * @return T
261     */
262    private static function expectType(mixed $value, string $class): object
263    {
264        if (!$value instanceof $class) {
265            $actual = get_debug_type($value);
266
267            throw new \UnexpectedValueException("hush-hush: expected {$class}, got {$actual}");
268        }
269
270        return $value;
271    }
272
273    private static function buildHttpClient(float $timeout, int $maxRetries): GuzzleClient
274    {
275        $handlerStack = HandlerStack::create();
276        $handlerStack->push(Middleware::retry(
277            RetryPolicy::decider($maxRetries),
278            RetryPolicy::delay(),
279        ));
280
281        return new GuzzleClient([
282            'handler' => $handlerStack,
283            'timeout' => $timeout,
284        ]);
285    }
286}