@cerbos/http
Version:
Client library for interacting with the Cerbos policy decision point service over HTTP from browser-based applications
466 lines (423 loc) • 12.1 kB
text/typescript
import type { ReadableStream } from "stream/web";
import { stringify as queryStringify } from "qs";
import type {
_AbortHandler,
_Method,
_MethodKind,
_Request,
_Response,
_Service,
_Transport,
} from "@cerbos/core";
import { NotOK, Status, _isObject } from "@cerbos/core";
import {
AddOrUpdatePolicyRequest,
AddOrUpdateSchemaRequest,
CheckResourcesRequest,
DeleteSchemaRequest,
DisablePolicyRequest,
EnablePolicyRequest,
GetPolicyRequest,
GetSchemaRequest,
InspectPoliciesRequest,
ListAuditLogEntriesRequest,
ListAuditLogEntriesRequest_Kind,
ListPoliciesRequest,
ListSchemasRequest,
PlanResourcesRequest,
ReloadStoreRequest,
ServerInfoRequest,
} from "./protobuf/cerbos/request/v1/request";
import {
AddOrUpdatePolicyResponse,
AddOrUpdateSchemaResponse,
CheckResourcesResponse,
DeleteSchemaResponse,
DisablePolicyResponse,
EnablePolicyResponse,
GetPolicyResponse,
GetSchemaResponse,
InspectPoliciesResponse,
ListAuditLogEntriesResponse,
ListPoliciesResponse,
ListSchemasResponse,
PlanResourcesResponse,
ReloadStoreResponse,
ServerInfoResponse,
} from "./protobuf/cerbos/response/v1/response";
import {
HealthCheckRequest,
HealthCheckResponse,
} from "./protobuf/grpc/health/v1/health";
interface Endpoint<
Service extends _Service,
MethodKind extends _MethodKind,
Method extends _Method<Service, MethodKind>,
> {
method: "GET" | "POST" | "DELETE";
path: string;
requestType: RequestType<Service, MethodKind, Method>;
responseType: ResponseType<Service, MethodKind, Method>;
serializeRequest: SerializeRequest<Service, MethodKind, Method>;
}
interface RequestType<
Service extends _Service,
MethodKind extends _MethodKind,
Method extends _Method<Service, MethodKind>,
> {
toJSON: (request: _Request<Service, MethodKind, Method>) => unknown;
}
interface ResponseType<
Service extends _Service,
MethodKind extends _MethodKind,
Method extends _Method<Service, MethodKind>,
> {
fromJSON: (response: unknown) => _Response<Service, MethodKind, Method>;
}
type SerializeRequest<
Service extends _Service,
MethodKind extends _MethodKind,
Method extends _Method<Service, MethodKind>,
> = (
request: _Request<Service, MethodKind, Method>,
requestType: RequestType<Service, MethodKind, Method>,
init: RequestInitWithURL,
) => RequestInitWithURL;
interface RequestInitWithURL extends RequestInit {
url: string;
}
type Services = {
[Service in _Service]: {
[MethodKind in _MethodKind]: {
[Method in _Method<Service, MethodKind>]: Endpoint<
Service,
MethodKind,
Method
>;
};
}[_MethodKind];
};
const services: Services = {
admin: {
addOrUpdatePolicy: {
method: "POST",
path: "/admin/policy",
requestType: AddOrUpdatePolicyRequest,
responseType: AddOrUpdatePolicyResponse,
serializeRequest: serializeRequestToBody,
},
addOrUpdateSchema: {
method: "POST",
path: "/admin/schema",
requestType: AddOrUpdateSchemaRequest,
responseType: AddOrUpdateSchemaResponse,
serializeRequest: serializeRequestToBody,
},
deleteSchema: {
method: "DELETE",
path: "/admin/schema",
requestType: DeleteSchemaRequest,
responseType: DeleteSchemaResponse,
serializeRequest: serializeRequestToQueryString,
},
disablePolicy: {
method: "DELETE",
path: "/admin/policy",
requestType: DisablePolicyRequest,
responseType: DisablePolicyResponse,
serializeRequest: serializeRequestToQueryString,
},
enablePolicy: {
method: "POST",
path: "/admin/policy/enable",
requestType: EnablePolicyRequest,
responseType: EnablePolicyResponse,
serializeRequest: serializeRequestToQueryString,
},
getPolicy: {
method: "GET",
path: "/admin/policy",
requestType: GetPolicyRequest,
responseType: GetPolicyResponse,
serializeRequest: serializeRequestToQueryString,
},
getSchema: {
method: "GET",
path: "/admin/schema",
requestType: GetSchemaRequest,
responseType: GetSchemaResponse,
serializeRequest: serializeRequestToQueryString,
},
inspectPolicies: {
method: "GET",
path: "/admin/policies/inspect",
requestType: InspectPoliciesRequest,
responseType: InspectPoliciesResponse,
serializeRequest: serializeRequestToQueryString,
},
listAuditLogEntries: {
method: "GET",
path: "/admin/auditlog/list/",
requestType: ListAuditLogEntriesRequest,
responseType: ListAuditLogEntriesResponse,
serializeRequest: serializeListAuditLogEntriesRequest,
},
listPolicies: {
method: "GET",
path: "/admin/policies",
requestType: ListPoliciesRequest,
responseType: ListPoliciesResponse,
serializeRequest: serializeRequestToQueryString,
},
listSchemas: {
method: "GET",
path: "/admin/schemas",
requestType: ListSchemasRequest,
responseType: ListSchemasResponse,
serializeRequest: serializeRequestToQueryString,
},
reloadStore: {
method: "GET",
path: "/admin/store/reload",
requestType: ReloadStoreRequest,
responseType: ReloadStoreResponse,
serializeRequest: serializeRequestToQueryString,
},
},
cerbos: {
checkResources: {
method: "POST",
path: "/api/check/resources",
requestType: CheckResourcesRequest,
responseType: CheckResourcesResponse,
serializeRequest: serializeRequestToBody,
},
planResources: {
method: "POST",
path: "/api/plan/resources",
requestType: PlanResourcesRequest,
responseType: PlanResourcesResponse,
serializeRequest: serializeRequestToBody,
},
serverInfo: {
method: "GET",
path: "/api/server_info",
requestType: ServerInfoRequest,
responseType: ServerInfoResponse,
serializeRequest: serializeRequestToQueryString,
},
},
health: {
check: {
method: "GET",
path: "/_cerbos/health",
requestType: HealthCheckRequest,
responseType: HealthCheckResponse,
serializeRequest: serializeRequestToQueryString,
},
},
};
export class Transport implements _Transport {
public constructor(
private readonly baseUrl: string,
private readonly userAgent: string,
) {}
public async unary<
Service extends _Service,
Method extends _Method<Service, "unary">,
>(
service: Service,
method: Method,
request: _Request<Service, "unary", Method>,
headers: Headers,
abortHandler: _AbortHandler,
): Promise<_Response<Service, "unary", Method>> {
const { response, responseType } = await this.fetch(
service,
method,
request,
headers,
abortHandler,
);
if (!response.ok) {
throw NotOK.fromJSON(await response.text());
}
return responseType.fromJSON(await response.json());
}
public async *serverStream<
Service extends _Service,
Method extends _Method<Service, "serverStream">,
>(
service: Service,
method: Method,
request: _Request<Service, "serverStream", Method>,
headers: Headers,
abortHandler: _AbortHandler,
): AsyncGenerator<
_Response<Service, "serverStream", Method>,
void,
undefined
> {
const { response, responseType } = await this.fetch(
service,
method,
request,
headers,
abortHandler,
);
try {
if (!response.body) {
throw new Error("Missing response body");
}
for await (const line of eachLine(
response.body as ReadableStream<Uint8Array>,
)) {
const message = JSON.parse(line) as unknown;
if (!_isObject(message)) {
throw new Error(`Unexpected message: wanted object, got ${line}`);
}
const { result, error } = message;
if (error) {
throw NotOK.fromJSON(JSON.stringify(error));
}
if (!result) {
throw new Error(`Missing result in ${line}`);
}
yield responseType.fromJSON(result);
}
} catch (error) {
response.body?.cancel().catch(() => {
// ignore failure to cancel
});
abortHandler.throwIfAborted();
if (error instanceof NotOK) {
throw error;
}
throw new NotOK(
Status.INTERNAL,
error instanceof Error
? `Invalid stream: ${error.message}`
: "Invalid stream",
{ cause: error },
);
}
}
private async fetch<
Service extends _Service,
MethodKind extends _MethodKind,
Method extends _Method<Service, MethodKind>,
>(
service: Service,
method: Method,
request: _Request<Service, MethodKind, Method>,
headers: Headers,
abortHandler: _AbortHandler,
): Promise<{
response: Response;
responseType: ResponseType<Service, MethodKind, Method>;
}> {
const {
method: requestMethod,
path,
requestType,
responseType,
serializeRequest,
} = services[service][method] as Endpoint<Service, MethodKind, Method>; // https://github.com/microsoft/TypeScript/issues/30581
headers.set("User-Agent", this.userAgent);
const init: RequestInitWithURL = {
url: this.baseUrl + path,
method: requestMethod,
headers,
};
if (abortHandler.signal) {
init.signal = abortHandler.signal;
}
const { url, ...rest } = serializeRequest(request, requestType, init);
try {
return {
response: await fetch(url, rest),
responseType,
};
} catch (error) {
abortHandler.throwIfAborted();
throw new NotOK(
Status.UNKNOWN,
error instanceof Error
? `Request failed: ${error.message}`
: "Request failed",
{ cause: error },
);
}
}
}
function serializeRequestToBody<
Service extends _Service,
MethodKind extends _MethodKind,
Method extends _Method<Service, MethodKind>,
>(
request: _Request<Service, MethodKind, Method>,
requestType: RequestType<Service, MethodKind, Method>,
init: RequestInitWithURL,
): RequestInitWithURL {
return {
...init,
body: JSON.stringify(requestType.toJSON(request)),
};
}
function serializeRequestToQueryString<
Service extends _Service,
MethodKind extends _MethodKind,
Method extends _Method<Service, MethodKind>,
>(
request: _Request<Service, MethodKind, Method>,
requestType: RequestType<Service, MethodKind, Method>,
{ url, ...init }: RequestInitWithURL,
): RequestInitWithURL {
return {
...init,
url: `${url}?${queryStringifyRequest(requestType, request)}`,
};
}
function serializeListAuditLogEntriesRequest(
{ kind, ...rest }: _Request<"admin", "serverStream", "listAuditLogEntries">,
requestType: RequestType<"admin", "serverStream", "listAuditLogEntries">,
{ url, ...init }: RequestInitWithURL,
): RequestInitWithURL {
return {
...init,
url: `${url}${ListAuditLogEntriesRequest_Kind[kind]}?${queryStringifyRequest(requestType, { kind: 0, ...rest })}`,
};
}
function queryStringifyRequest<
Service extends _Service,
MethodKind extends _MethodKind,
Method extends _Method<Service, MethodKind>,
>(
requestType: RequestType<Service, MethodKind, Method>,
request: _Request<Service, MethodKind, Method>,
): string {
return queryStringify(requestType.toJSON(request), {
allowDots: true,
arrayFormat: "repeat",
});
}
export async function* eachLine(
stream: ReadableStream<Uint8Array>,
): AsyncGenerator<string, void, undefined> {
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
let buffer = "";
let start = 0;
for await (const chunk of stream) {
buffer += utf8Decoder.decode(chunk, { stream: true });
let end: number;
while ((end = buffer.indexOf("\n", start)) >= 0) {
yield buffer.slice(start, end);
start = end + 1;
}
buffer = buffer.slice(start);
start = 0;
}
if (buffer.length > 0) {
yield buffer;
}
}