UNPKG

@cerbos/http

Version:

Client library for interacting with the Cerbos policy decision point service over HTTP from browser-based applications

205 lines (166 loc) 5.07 kB
import type { ReadableStream } from "stream/web"; import type { DescMessage, DescMethod, DescMethodServerStreaming, DescMethodUnary, JsonValue, Message, MessageShape, MessageValidType, } from "@bufbuild/protobuf"; import { fromJson as bufFromJson } from "@bufbuild/protobuf"; import type { AnyJson } from "@bufbuild/protobuf/wkt"; import type { StatusJson } from "@cerbos/api/google/rpc/status_pb"; import { NotOK, Status } from "@cerbos/core"; import type { AbortHandler, Transport as CoreTransport, ErrorDetails, } from "@cerbos/core/~internal"; import { AbstractErrorResponse, cancelBody, isObject, methodName, } from "@cerbos/core/~internal"; import type { RequestInitWithUrl } from "./endpoints.js"; import { endpoints } from "./endpoints.js"; export class Transport implements CoreTransport { public constructor( private readonly baseUrl: string, private readonly userAgent: string, ) {} public async unary<I extends DescMessage, O extends DescMessage>( method: DescMethodUnary<I, O>, request: MessageValidType<I>, headers: Headers, abortHandler: AbortHandler, ): Promise<MessageShape<O>> { const response = await this.fetch(method, request, headers, abortHandler); const json = (await response.json()) as JsonValue; if (!response.ok) { throw new ErrorResponse(json); } return fromJson(method.output, json); } public async *serverStream<I extends DescMessage, O extends DescMessage>( method: DescMethodServerStreaming<I, O>, request: MessageValidType<I>, headers: Headers, abortHandler: AbortHandler, ): AsyncGenerator<MessageShape<O>, void, undefined> { const response = await this.fetch(method, request, headers, abortHandler); if (!response.body) { throw new Error("Missing response body"); } try { for await (const line of eachLine( response.body as ReadableStream<Uint8Array>, )) { const message = JSON.parse(line) as JsonValue; if (!isObject(message)) { throw new Error(`Unexpected message: wanted object, got ${line}`); } const { result, error } = message; if (error) { throw new ErrorResponse(error); } if (!result) { throw new Error(`Missing result in ${line}`); } yield fromJson(method.output, result); } } finally { cancelBody(response); } } private async fetch( method: DescMethod, request: Message, headers: Headers, abortHandler: AbortHandler, ): Promise<Response> { const endpoint = endpoints.get(methodName(method)); if (!endpoint) { throw new NotOK(Status.UNIMPLEMENTED, "Unimplemented"); } headers.set("User-Agent", this.userAgent); const init: RequestInitWithUrl = { url: this.baseUrl + endpoint.path, method: endpoint.method, headers, }; if (abortHandler.signal) { init.signal = abortHandler.signal; } const { url, ...rest } = endpoint.serialize(request, init); return await fetch(url, rest); } } 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; } } class ErrorResponse extends AbstractErrorResponse<JsonValue> { private readonly jsonDetails: JsonValue[] | undefined; public constructor(json: JsonValue) { if (!isStatus(json)) { throw new Error(`Invalid error response: ${JSON.stringify(json)}`); } super(json.code, json.message); this.jsonDetails = json.details; } protected override *details(): Generator< ErrorDetails<JsonValue>, void, undefined > { if (!this.jsonDetails) { return; } for (const details of this.jsonDetails) { if (isAny(details)) { const { "@type": typeUrl, ...value } = details; yield { typeUrl, value }; } } } protected override readonly parseDetails = fromJson; } type StatusObject = Required<Pick<StatusJson, "code" | "message">> & { details?: JsonValue[]; }; function isStatus(json: JsonValue): json is StatusObject { return ( isObject(json) && typeof json["code"] === "number" && typeof json["message"] === "string" && (json["details"] === undefined || Array.isArray(json["details"])) ); } function isAny(json: JsonValue): json is Required<AnyJson> { return isObject(json) && typeof json["@type"] === "string"; } function fromJson<T extends DescMessage>( schema: T, json: JsonValue, ): MessageShape<T> { return bufFromJson(schema, json, { ignoreUnknownFields: true }); }