UNPKG

@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
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; } }