hts/packages/api/src/client.ts

358 lines
9.7 KiB
TypeScript

import { PermissionQuery } from "@aigxion/rbac";
import { ErrorResponse } from "./errors";
import type { paths } from "./openapi";
import { type Telemetry, getTelemetry } from "./telemetry";
export type UnkeyOptions = (
| {
token?: never;
/**
* The root key from unkey.dev.
*
* You can create/manage your root keys here:
* https://unkey.dev/app/settings/root-keys
*/
rootKey: string;
}
| {
/**
* The workspace key from unkey.dev
*
* @deprecated Use `rootKey`
*/
token: string;
rootKey?: never;
}
) & {
/**
* @default https://api.unkey.dev
*/
baseUrl?: string;
/**
*
* By default telemetry data is enabled, and sends:
* runtime (Node.js / Edge)
* platform (Node.js / Vercel / AWS)
* SDK version
*/
disableTelemetry?: boolean;
/**
* Retry on network errors
*/
retry?: {
/**
* How many attempts should be made
* The maximum number of requests will be `attempts + 1`
* `0` means no retries
*
* @default 5
*/
attempts?: number;
/**
* Return how many milliseconds to wait until the next attempt is made
*
* @default `(retryCount) => Math.round(Math.exp(retryCount) * 10)),`
*/
backoff?: (retryCount: number) => number;
};
/**
* Customize the `fetch` cache behaviour
*/
cache?: RequestCache;
/**
* The version of the SDK instantiating this client.
*
* This is used for internal metrics and is not covered by semver, and may change at any time.
*
* You can leave this blank unless you are building a wrapper around this SDK.
*/
wrapperSdkVersion?: string;
};
type ApiRequest = {
path: string[];
} & (
| {
method: "GET";
body?: never;
query?: Record<string, string | number | boolean | null>;
}
| {
method: "POST";
body?: unknown;
query?: never;
}
);
type Result<R> =
| {
result: R;
error?: never;
}
| {
result?: never;
error: ErrorResponse["error"];
};
export class Unkey {
public readonly baseUrl: string;
private readonly rootKey: string;
private readonly cache?: RequestCache;
private readonly telemetry?: Telemetry | null;
public readonly retry: {
attempts: number;
backoff: (retryCount: number) => number;
};
constructor(opts: UnkeyOptions) {
this.baseUrl = opts.baseUrl ?? "https://api.unkey.dev";
this.rootKey = opts.rootKey ?? opts.token;
if (!opts.disableTelemetry) {
this.telemetry = getTelemetry(opts);
}
this.cache = opts.cache;
/**
* Even though typescript should prevent this, some people still pass undefined or empty strings
*/
if (!this.rootKey) {
throw new Error(
"Unkey root key must be set, maybe you passed in `undefined` or an empty string?",
);
}
this.retry = {
attempts: opts.retry?.attempts ?? 5,
backoff: opts.retry?.backoff ?? ((n) => Math.round(Math.exp(n) * 10)),
};
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.rootKey}`,
};
if (this.telemetry?.sdkVersions) {
headers["Unkey-Telemetry-SDK"] = this.telemetry.sdkVersions.join(",");
}
if (this.telemetry?.platform) {
headers["Unkey-Telemetry-Platform"] = this.telemetry.platform;
}
if (this.telemetry?.runtime) {
headers["Unkey-Telemetry-Runtime"] = this.telemetry.runtime;
}
return headers;
}
private async fetch<TResult>(req: ApiRequest): Promise<Result<TResult>> {
let res: Response | null = null;
let err: Error | null = null;
for (let i = 0; i <= this.retry.attempts; i++) {
const url = new URL(`${this.baseUrl}/${req.path.join("/")}`);
if (req.query) {
for (const [k, v] of Object.entries(req.query)) {
if (v === null) {
continue;
}
url.searchParams.set(k, v.toString());
}
}
res = await fetch(url, {
method: req.method,
headers: this.getHeaders(),
cache: this.cache,
body: JSON.stringify(req.body),
}).catch((e: Error) => {
err = e;
return null; // set `res` to `null`
});
if (res?.ok) {
return { result: (await res.json()) as TResult };
}
const backoff = this.retry.backoff(i);
console.debug(
"attempt %d of %d to reach %s failed, retrying in %d ms: %s | %s",
i + 1,
this.retry.attempts + 1,
url,
backoff,
// @ts-ignore I don't understand why `err` is `never`
err?.message ?? `status=${res?.status}`,
res?.headers.get("unkey-request-id"),
);
await new Promise((r) => setTimeout(r, backoff));
}
if (res) {
return { error: (await res.json()) as ErrorResponse["error"] };
}
return {
error: {
// @ts-ignore
code: "FETCH_ERROR",
// @ts-ignore I don't understand why `err` is `never`
message: err?.message ?? "No response",
docs: "https://developer.mozilla.org/en-US/docs/Web/API/fetch",
requestId: "N/A",
},
};
}
public get keys() {
return {
create: async (
req: paths["/v1/keys.createKey"]["post"]["requestBody"]["content"]["application/json"],
): Promise<
Result<
paths["/v1/keys.createKey"]["post"]["responses"]["200"]["content"]["application/json"]
>
> => {
return await this.fetch({
path: ["v1", "keys.createKey"],
method: "POST",
body: req,
});
},
update: async (
req: paths["/v1/keys.updateKey"]["post"]["requestBody"]["content"]["application/json"],
): Promise<
Result<
paths["/v1/keys.updateKey"]["post"]["responses"]["200"]["content"]["application/json"]
>
> => {
return await this.fetch({
path: ["v1", "keys.updateKey"],
method: "POST",
body: req,
});
},
verify: async <TPermission extends string = string>(
req: Omit<
paths["/v1/keys.verifyKey"]["post"]["requestBody"]["content"]["application/json"],
"authorization"
> & { authorization?: { permissions: PermissionQuery<TPermission> } },
): Promise<
Result<
paths["/v1/keys.verifyKey"]["post"]["responses"]["200"]["content"]["application/json"]
>
> => {
return await this.fetch({
path: ["v1", "keys.verifyKey"],
method: "POST",
body: req,
});
},
delete: async (
req: paths["/v1/keys.deleteKey"]["post"]["requestBody"]["content"]["application/json"],
): Promise<
Result<
paths["/v1/keys.deleteKey"]["post"]["responses"]["200"]["content"]["application/json"]
>
> => {
return await this.fetch({
path: ["v1", "keys.deleteKey"],
method: "POST",
body: req,
});
},
updateRemaining: async (
req: paths["/v1/keys.updateRemaining"]["post"]["requestBody"]["content"]["application/json"],
): Promise<
Result<
paths["/v1/keys.updateRemaining"]["post"]["responses"]["200"]["content"]["application/json"]
>
> => {
return await this.fetch({
path: ["v1", "keys.updateRemaining"],
method: "POST",
body: req,
});
},
get: async (
req: paths["/v1/keys.getKey"]["get"]["parameters"]["query"],
): Promise<
Result<paths["/v1/keys.getKey"]["get"]["responses"]["200"]["content"]["application/json"]>
> => {
return await this.fetch({
path: ["v1", "keys.getKey"],
method: "GET",
query: req,
});
},
getVerifications: async (
req: paths["/v1/keys.getVerifications"]["get"]["parameters"]["query"],
): Promise<
Result<
paths["/v1/keys.getVerifications"]["get"]["responses"]["200"]["content"]["application/json"]
>
> => {
return await this.fetch({
path: ["v1", "keys.getVerifications"],
method: "GET",
query: req,
});
},
};
}
public get apis() {
return {
create: async (
req: paths["/v1/apis.createApi"]["post"]["requestBody"]["content"]["application/json"],
): Promise<
Result<
paths["/v1/apis.createApi"]["post"]["responses"]["200"]["content"]["application/json"]
>
> => {
return await this.fetch({
path: ["v1", "apis.createApi"],
method: "POST",
body: req,
});
},
delete: async (
req: paths["/v1/apis.deleteApi"]["post"]["requestBody"]["content"]["application/json"],
): Promise<
Result<
paths["/v1/apis.deleteApi"]["post"]["responses"]["200"]["content"]["application/json"]
>
> => {
return await this.fetch({
path: ["v1", "apis.deleteApi"],
method: "POST",
body: req,
});
},
get: async (
req: paths["/v1/apis.getApi"]["get"]["parameters"]["query"],
): Promise<
Result<paths["/v1/apis.getApi"]["get"]["responses"]["200"]["content"]["application/json"]>
> => {
return await this.fetch({
path: ["v1", "apis.getApi"],
method: "GET",
query: req,
});
},
listKeys: async (
req: paths["/v1/apis.listKeys"]["get"]["parameters"]["query"],
): Promise<
Result<paths["/v1/apis.listKeys"]["get"]["responses"]["200"]["content"]["application/json"]>
> => {
return await this.fetch({
path: ["v1", "apis.listKeys"],
method: "GET",
query: req,
});
},
};
}
}