Lines
80.95%
51 / 63
Functions and Methods
72.72%
8 / 11
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Client | 80.95% | 51 / 63 | 72.72% | 8 / 11 | 22.76 | 0.00% | 0 / 1 | |||
| __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 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace HushHush; | |
| 6 | ||
| 7 | use GuzzleHttp\Client as GuzzleClient; | |
| 8 | use GuzzleHttp\ClientInterface; | |
| 9 | use GuzzleHttp\HandlerStack; | |
| 10 | use GuzzleHttp\Middleware; | |
| 11 | use HushHush\Exception\ApiException; | |
| 12 | use HushHush\Generated\Api\AuditLogApi; | |
| 13 | use HushHush\Generated\Api\HealthApi; | |
| 14 | use HushHush\Generated\Api\ObjectsApi; | |
| 15 | use HushHush\Generated\ApiException as GeneratedApiException; | |
| 16 | use HushHush\Generated\Configuration; | |
| 17 | use HushHush\Generated\Model\AuditLogEntry; | |
| 18 | use HushHush\Generated\Model\CreateObjectRequest; | |
| 19 | use HushHush\Generated\Model\Error; | |
| 20 | use HushHush\Generated\Model\Health; | |
| 21 | use HushHush\Generated\Model\ObjectMetadata; | |
| 22 | use HushHush\Generated\Model\UpdateObjectRequest; | |
| 23 | use HushHush\Generated\Model\UsedBy; | |
| 24 | ||
| 25 | /** | |
| 26 | * A typed, synchronous client for hush-hush, a standalone secrets object store. | |
| 27 | * | |
| 28 | * @see https://github.com/alrayyes/hush-hush | |
| 29 | */ | |
| 30 | final 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 | } |