From da0f80c8a17b7f5cf0d465af6693b5a20d7259b0 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Tue, 7 Jan 2025 17:42:08 -0600 Subject: [PATCH] feat(js-sdk)!: Support Server Batch Check --- config/clients/js/CHANGELOG.md.mustache | 6 + config/clients/js/template/client.mustache | 174 +++++++++++++-- .../js/template/example/example1/example1.mjs | 35 +++ .../example/opentelemetry/opentelemetry.mjs | 29 +++ config/clients/js/template/package.mustache | 14 +- .../template/telemetry/attributes.ts.mustache | 4 + .../telemetry/configuration.ts.mustache | 4 +- .../js/template/tests/client.test.ts.mustache | 211 +++++++++++++++++- .../template/tests/helpers/nocks.ts.mustache | 16 ++ .../telemetry/attributes.test.ts.mustache | 27 +++ 10 files changed, 487 insertions(+), 33 deletions(-) diff --git a/config/clients/js/CHANGELOG.md.mustache b/config/clients/js/CHANGELOG.md.mustache index 2a4cdbec..155a1d34 100644 --- a/config/clients/js/CHANGELOG.md.mustache +++ b/config/clients/js/CHANGELOG.md.mustache @@ -6,6 +6,12 @@ - fix: error correctly if apiUrl is not provided (#161) - feat: add support for `start_time` parameter in `ReadChanges` endpoint - BREAKING: As of this release, the min node version required by the SDK is now v16.15.0 +- feat!: add support for server-side `BatchCheck` method. + +BREAKING CHNAGES: + +- The minimum noce version required by this SDK is now v16.15.0 +- Usage of the existing `batchCheck` method should now use the `clientBatchCheck` method. Additionally, the existing `BatchCheckResponse` has been renamed to `ClientBatchCheckResponse`. ## v0.7.0 diff --git a/config/clients/js/template/client.mustache b/config/clients/js/template/client.mustache index 4b50f674..54fa8cb4 100644 --- a/config/clients/js/template/client.mustache +++ b/config/clients/js/template/client.mustache @@ -6,10 +6,15 @@ import asyncPool = require("tiny-async-pool"); import { {{appShortName}}Api } from "./api"; import { Assertion, + BatchCheckItem, + BatchCheckRequest, + BatchCheckResponse, + CheckError, CheckRequest, CheckRequestTupleKey, CheckResponse, ConsistencyPreference, + ContextualTupleKeys, CreateStoreRequest, CreateStoreResponse, ExpandRequestTupleKey, @@ -85,6 +90,7 @@ export class ClientConfiguration extends Configuration { } const DEFAULT_MAX_METHOD_PARALLEL_REQS = {{clientMaxMethodParallelRequests}}; +const DEFAULT_MAX_BATCH_SIZE = 50; const CLIENT_METHOD_HEADER = "{{clientMethodHeader}}"; const CLIENT_BULK_REQUEST_ID_HEADER = "{{clientBulkRequestIdHeader}}"; @@ -115,9 +121,9 @@ export type ClientCheckRequest = CheckRequestTupleKey & Pick & { contextualTuples?: Array }; -export type ClientBatchCheckRequest = ClientCheckRequest[]; +export type ClientBatchCheckClientRequest = ClientCheckRequest[]; -export type ClientBatchCheckSingleResponse = { +export type ClientBatchCheckSingleClientResponse = { _request: ClientCheckRequest; } & ({ allowed: boolean; @@ -127,6 +133,44 @@ export type ClientBatchCheckSingleResponse = { error: Error; }); +export interface ClientBatchCheckClientResponse { + responses: ClientBatchCheckSingleClientResponse[]; +} + +export interface ClientBatchCheckClientRequestOpts { + maxParallelRequests?: number; +} + +// For server batch check +export type ClientBatchCheckItem = { + user: string; + relation: string; + object: string; + correlationId?: string; + contextualTuples?: ContextualTupleKeys; + context?: object; +}; + +// for server batch check +export type ClientBatchCheckRequest = { + checks: ClientBatchCheckItem[]; +}; + +// for server batch check +export interface ClientBatchCheckRequestOpts { + maxParallelRequests?: number; + maxBatchSize?: number; +} + + +// for server batch check +export type ClientBatchCheckSingleResponse = { + allowed: boolean; + request: ClientBatchCheckItem; + correlationId: string; + error?: CheckError; +} + export interface ClientBatchCheckResponse { responses: ClientBatchCheckSingleResponse[]; } @@ -139,10 +183,6 @@ export interface ClientWriteRequestOpts { } } -export interface BatchCheckRequestOpts { - maxParallelRequests?: number; -} - export interface ClientWriteRequest { writes?: TupleKey[]; deletes?: TupleKeyWithoutCondition[]; @@ -575,27 +615,28 @@ export class {{appShortName}}Client extends BaseAPI { }, options); } - /** - * BatchCheck - Run a set of checks (evaluates) - * @param {ClientBatchCheckRequest} body - * @param {ClientRequestOptsWithAuthZModelId & BatchCheckRequestOpts} [options] +/** + * BatchCheck - Run a set of checks (evaluates) by calling the single check endpoint multiple times in parallel. + * @param {ClientBatchCheckClientRequest} body + * @param {ClientRequestOptsWithAuthZModelId & ClientBatchCheckClientRequestOpts} [options] * @param {number} [options.maxParallelRequests] - Max number of requests to issue in parallel. Defaults to `10` * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration + * @param {string} [options.consistency] - Optional consistency level for the request. Default is `MINIMIZE_LATENCY` * @param {object} [options.headers] - Custom headers to send alongside the request * @param {object} [options.retryParams] - Override the retry parameters for this request * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated */ - async batchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & BatchCheckRequestOpts = {}): Promise { + async clientBatchCheck(body: ClientBatchCheckClientRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckClientRequestOpts = {}): Promise { const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS } = options; - setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); + setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "ClientBatchCheck"); setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); - const responses: ClientBatchCheckSingleResponse[] = []; + const responses: ClientBatchCheckSingleClientResponse[] = []; for await (const singleCheckResponse of asyncPool(maxParallelRequests, body, (tuple) => this.check(tuple, { ...options, headers }) .then(response => { - (response as ClientBatchCheckSingleResponse)._request = tuple; - return response as ClientBatchCheckSingleResponse; + (response as ClientBatchCheckSingleClientResponse)._request = tuple; + return response as ClientBatchCheckSingleClientResponse; }) .catch(err => { if (err instanceof FgaApiAuthenticationError) { @@ -611,10 +652,107 @@ export class {{appShortName}}Client extends BaseAPI { )) { responses.push(singleCheckResponse); } - return { responses }; } + + + private singleBatchCheck(body: BatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { + return this.api.batchCheck(this.getStoreId(options)!, body, options); + } + + /** + * BatchCheck - Run a set of checks (evaluates) by calling the batch-check endpoint. + * Given the provided list of checks, it will call batch check, splitting the checks into batches based + * on the `options.maxBatchSize` parameter (default 50 checks) if needed. + * @param {ClientBatchCheckClientRequest} body + * @param {ClientRequestOptsWithAuthZModelId & ClientBatchCheckClientRequestOpts} [options] + * @param {number} [options.maxParallelRequests] - Max number of requests to issue in parallel, if executing multiple requests. Defaults to `10` + * @param {number} [options.maxBatchSize] - Max number of checks to include in a single batch check request. Defaults to `50`. + * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration. + * @param {string} [options.consistency] - + * @param {object} [options.headers] - Custom headers to send alongside the request + * @param {object} [options.retryParams] - Override the retry parameters for this request + * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request + * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated + */ + async batchCheck( + body: ClientBatchCheckRequest, + options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {} + ): Promise { + const { + headers = {}, + maxBatchSize = DEFAULT_MAX_BATCH_SIZE, + maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, + } = options; + + setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); + + const correlationIdToCheck = new Map(); + const transformed: BatchCheckItem[] = []; + + // Validate and transform checks + for (const check of body.checks) { + // Generate a correlation ID if not provided + if (!check.correlationId) { + check.correlationId = generateRandomIdWithNonUniqueFallback(); + } + + // Ensure that correlation IDs are unique + if (correlationIdToCheck.has(check.correlationId)) { + throw new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique"); + } + correlationIdToCheck.set(check.correlationId, check); + + // Transform the check into the BatchCheckItem format + transformed.push({ + tuple_key: { + user: check.user, + relation: check.relation, + object: check.object, + }, + context: check.context, + contextual_tuples: check.contextualTuples, + correlation_id: check.correlationId, + }); + } + + // Split the transformed checks into batches based on maxBatchSize + const batchedChecks = chunkArray(transformed, maxBatchSize); + + // Execute batch checks in parallel with a limit of maxParallelRequests + const results: ClientBatchCheckSingleResponse[] = []; + const batchResponses = asyncPool(maxParallelRequests, batchedChecks, async (batch: BatchCheckItem[]) => { + const batchRequest: BatchCheckRequest = { + checks: batch, + authorization_model_id: options.authorizationModelId, + consistency: options.consistency, + }; + + const response = await this.singleBatchCheck(batchRequest, { ...options, headers }); + return response.result; + }); + + // Collect the responses and associate them with their correlation IDs + for await (const response of batchResponses) { + if (response) { + for (const [correlationId, result] of Object.entries(response)) { + const check = correlationIdToCheck.get(correlationId); + if (check && result) { + results.push({ + allowed: result.allowed || false, + request: check, + correlationId, + error: result.error, + }); + } + } + } + } + + return { responses: results }; + } + /** * Expand - Expands the relationships in userset tree format (evaluates) * @param {ClientExpandRequest} body @@ -669,7 +807,7 @@ export class {{appShortName}}Client extends BaseAPI { * @param {object} listRelationsRequest.context The contextual tuples to send * @param options */ - async listRelations(listRelationsRequest: ClientListRelationsRequest, options: ClientRequestOptsWithConsistency & BatchCheckRequestOpts = {}): Promise { + async listRelations(listRelationsRequest: ClientListRelationsRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckClientRequestOpts = {}): Promise { const { user, object, relations, contextualTuples, context } = listRelationsRequest; const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS } = options; setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "ListRelations"); @@ -679,7 +817,7 @@ export class {{appShortName}}Client extends BaseAPI { throw new FgaValidationError("relations", "When calling listRelations, at least one relation must be passed in the relations field"); } - const batchCheckResults = await this.batchCheck(relations.map(relation => ({ + const batchCheckResults = await this.clientBatchCheck(relations.map(relation => ({ user, relation, object, diff --git a/config/clients/js/template/example/example1/example1.mjs b/config/clients/js/template/example/example1/example1.mjs index f8b82be4..521040e9 100644 --- a/config/clients/js/template/example/example1/example1.mjs +++ b/config/clients/js/template/example/example1/example1.mjs @@ -1,4 +1,5 @@ import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName } from "@openfga/sdk"; +import { randomUUID } from "crypto"; async function main () { let credentials; @@ -137,6 +138,11 @@ async function main () { Type: "document" } } + }, + { + user: "user:bob", + relation: "writer", + object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a" } ] }, { authorizationModelId }); @@ -180,6 +186,35 @@ async function main () { }); console.log(`Allowed: ${allowed}`); + // execute a batch check + const anneCorrelationId = randomUUID(); + const { responses } = await fgaClient.batchCheck({ + checks: [ + { + // should have access + user: "user:anne", + relation: "viewer", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + context: { + ViewCount: 100 + }, + correlationId: anneCorrelationId, + }, + { + // should NOT have access + user: "user:anne", + relation: "viewer", + object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + ] + }); + + const anneAllowed = responses.filter(r => r.correlationId === anneCorrelationId); + console.log(`Anne is allowed access to ${anneAllowed.length} documents`); + anneAllowed.forEach(item => { + console.log(`Anne is allowed access to ${item.request.object}`); + }); + console.log("Writing Assertions"); await fgaClient.writeAssertions([ { diff --git a/config/clients/js/template/example/opentelemetry/opentelemetry.mjs b/config/clients/js/template/example/opentelemetry/opentelemetry.mjs index 87635de5..2414f154 100644 --- a/config/clients/js/template/example/opentelemetry/opentelemetry.mjs +++ b/config/clients/js/template/example/opentelemetry/opentelemetry.mjs @@ -40,6 +40,7 @@ const telemetryConfig = { }, [TelemetryMetric.HistogramQueryDuration]: { attributes: new Set([ + TelemetryAttribute.FgaClientRequestBatchCheckSize, TelemetryAttribute.HttpResponseStatusCode, TelemetryAttribute.UserAgentOriginal, TelemetryAttribute.FgaClientRequestMethod, @@ -99,6 +100,34 @@ async function main () { } } + console.log("Calling BatcCheck") + const { responses } = await fgaClient.batchCheck({ + checks: [ + { + object: "doc:roadmap", + relation: "can_read", + user: "user:anne", + }, + { + object: "doc:roadmap", + relation: "can_read", + user: "user:dan", + }, + { + object: "doc:finances", + relation: "can_read", + user: "user:dan" + }, + { + object: "doc:finances", + relation: "can_reads", + user: "user:anne", + } + ] + }, { + authorizationModelId: "01JC6KPJ0CKSZ69C5Z26CYWX2N" + }); + console.log("writing tuple"); await fgaClient.write({ writes: [ diff --git a/config/clients/js/template/package.mustache b/config/clients/js/template/package.mustache index 9843a019..ae5b856d 100644 --- a/config/clients/js/template/package.mustache +++ b/config/clients/js/template/package.mustache @@ -20,20 +20,20 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "axios": "^1.7.5", + "axios": "^1.7.9", "tiny-async-pool": "^2.1.0" }, "devDependencies": { - "@types/jest": "^29.5.12", - "@types/node": "^22.5.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.5", "@types/tiny-async-pool": "^2.0.3", - "@typescript-eslint/eslint-plugin": "^8.2.0", - "@typescript-eslint/parser": "^8.2.0", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/parser": "^8.19.1", "eslint": "^8.57.0", "jest": "^29.7.0", - "nock": "^13.5.5", + "nock": "^13.5.6", "ts-jest": "^29.2.5", - "typescript": "^5.5.4" + "typescript": "^5.7.2" }, "files": [ "CHANGELOG.md", diff --git a/config/clients/js/template/telemetry/attributes.ts.mustache b/config/clients/js/template/telemetry/attributes.ts.mustache index 1c3b0610..f18eefe2 100644 --- a/config/clients/js/template/telemetry/attributes.ts.mustache +++ b/config/clients/js/template/telemetry/attributes.ts.mustache @@ -10,6 +10,7 @@ export enum TelemetryAttribute { FgaClientResponseModelId = "fga-client.response.model_id", FgaClientUser = "fga-client.user", HttpClientRequestDuration = "http.client.request.duration", + FgaClientRequestBatchCheckSize = "fga-client.request.batch_check_size", HttpHost = "http.host", HttpRequestMethod = "http.request.method", HttpRequestResendCount = "http.request.resend_count", @@ -110,6 +111,9 @@ export class TelemetryAttributes { attributes[TelemetryAttribute.FgaClientUser] = body.tuple_key.user; } + if (body?.checks?.length) { + attributes[TelemetryAttribute.FgaClientRequestBatchCheckSize] = body.checks.length; + } return attributes; } } diff --git a/config/clients/js/template/telemetry/configuration.ts.mustache b/config/clients/js/template/telemetry/configuration.ts.mustache index 09ece74c..dff2e186 100644 --- a/config/clients/js/template/telemetry/configuration.ts.mustache +++ b/config/clients/js/template/telemetry/configuration.ts.mustache @@ -60,7 +60,8 @@ export class TelemetryConfiguration implements TelemetryConfig { // TelemetryAttribute.HttpServerRequestDuration, // This not included by default as it has a very high cardinality which could increase costs for users - // TelemetryAttribute.FgaClientUser + // TelemetryAttribute.FgaClientUser, + // TelemetryAttribute.FgaClientRequestBatchCheckSize ]); /** @@ -86,6 +87,7 @@ export class TelemetryConfiguration implements TelemetryConfig { TelemetryAttribute.HttpClientRequestDuration, TelemetryAttribute.HttpServerRequestDuration, TelemetryAttribute.FgaClientUser, + TelemetryAttribute.FgaClientRequestBatchCheckSize, ]); /** diff --git a/config/clients/js/template/tests/client.test.ts.mustache b/config/clients/js/template/tests/client.test.ts.mustache index a817bcd8..afeda694 100644 --- a/config/clients/js/template/tests/client.test.ts.mustache +++ b/config/clients/js/template/tests/client.test.ts.mustache @@ -11,6 +11,8 @@ import { {{appShortName}}Client, ListUsersResponse, ConsistencyPreference, + ErrorCode, + BatchCheckRequest, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -135,7 +137,7 @@ describe("{{appTitleCaseName}} Client", () => { it("should allow overriding the store ID", async () => { - const overriddenStoreId = "01HWD53SDGYRXHBXTYA10PF6T4" + const overriddenStoreId = "01HWD53SDGYRXHBXTYA10PF6T4"; const store = { id: overriddenStoreId, name: "some-name" }; const scope = nocks.getStore(store.id, defaultConfiguration.getBasePath(), { @@ -500,7 +502,7 @@ describe("{{appTitleCaseName}} Client", () => { }); }); - describe("BatchCheck", () => { + describe("ClientBatchCheck", () => { it("should properly call the Check API", async () => { const tuples = [{ user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", @@ -515,23 +517,22 @@ describe("{{appTitleCaseName}} Client", () => { relation: "reader", object: "workspace:3", }]; - const scope0 = nocks.check(defaultConfiguration.storeId!, tuples[0], defaultConfiguration.getBasePath(), { allowed: true }, 200, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "BatchCheck"); - const scope1 = nocks.check(defaultConfiguration.storeId!, tuples[1], defaultConfiguration.getBasePath(), { allowed: false }, 200, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "BatchCheck"); + const scope0 = nocks.check(defaultConfiguration.storeId!, tuples[0], defaultConfiguration.getBasePath(), { allowed: true }, 200, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "ClientBatchCheck"); + const scope1 = nocks.check(defaultConfiguration.storeId!, tuples[1], defaultConfiguration.getBasePath(), { allowed: false }, 200, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "ClientBatchCheck"); const scope2 = nocks.check(defaultConfiguration.storeId!, tuples[2], defaultConfiguration.getBasePath(), { "code": "validation_error", "message": "relation 'workspace#reader' not found" - }, 400, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "BatchCheck"); + }, 400, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "ClientBatchCheck"); const scope3 = nock(defaultConfiguration.getBasePath()) .get(`/stores/${defaultConfiguration.storeId!}/authorization-models`) .query({ page_size: 1 }) .reply(200, { authorization_models: [], }); - expect(scope0.isDone()).toBe(false); expect(scope1.isDone()).toBe(false); expect(scope2.isDone()).toBe(false); - const response = await fgaClient.batchCheck([tuples[0], tuples[1], tuples[2]], { consistency: ConsistencyPreference.HigherConsistency }); + const response = await fgaClient.clientBatchCheck([tuples[0], tuples[1], tuples[2]], { consistency: ConsistencyPreference.HigherConsistency }); expect(scope0.isDone()).toBe(true); expect(scope1.isDone()).toBe(true); @@ -547,6 +548,202 @@ describe("{{appTitleCaseName}} Client", () => { }); }); + describe("BatchCheck", () => { + it(" should throw error when correlationIds are duplicated", async () => { + expect( + fgaClient.batchCheck({ + checks: [ + { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + object: "workspace:1", + relation: "viewer", + correlationId: "cor-id", + }, + { + user: "user:91284243-9356-4421-8fbf-a4f8d36aa31b", + object: "workspace:2", + relation: "viewer", + correlationId: "cor-id", + }, + ] + } + ) + ).rejects.toThrow(new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique")); + }); + it("should return empty results when empty checks are specified", async () => { + const response = await fgaClient.batchCheck({ + checks: [], + }); + expect(response.responses.length).toBe(0); + }); + it("should handle single batch successfully", async () => { + const mockedResponse = { + result: { + "cor-1": { + allowed: true, + error: undefined, + }, + "cor-2": { + allowed: false, + error: undefined, + }, + }, + }; + + const scope = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV").matchHeader("X-OpenFGA-Client-Bulk-Request-Id", /.*/); + + expect(scope.isDone()).toBe(false); + const response = await fgaClient.batchCheck({ + checks: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "can_read", + object: "document", + contextualTuples: { + tuple_keys: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "editor", + object: "folder:product" + }, { + user: "folder:product", + relation: "parent", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a" + } + ] + }, + correlationId: "cor-1", + }, + { + user: "folder:product", + relation: "parent", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + correlationId: "cor-2", + }], + }, { + authorizationModelId: "01GAHCE4YVKPQEKZQHT2R89MQV", + consistency: ConsistencyPreference.HigherConsistency, + }); + + expect(scope.isDone()).toBe(true); + expect(response.responses).toHaveLength(2); + expect(response.responses[0].allowed).toBe(true); + expect(response.responses[1].allowed).toBe(false); + }); + it("should split batches successfully", async () => { + const mockedResponse0 = { + result: { + "cor-1": { + allowed: true, + error: undefined, + }, + "cor-2": { + allowed: false, + error: undefined, + }, + }, + }; + const mockedResponse1 = { + result: { + "cor-3": { + allowed: false, + error: { + input_error: ErrorCode.RelationNotFound, + message: "relation not found", + } + } + }, + }; + + const scope0 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse0, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV").matchHeader("X-OpenFGA-Client-Bulk-Request-Id", /.*/); + const scope1 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse1, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV").matchHeader("X-OpenFGA-Client-Bulk-Request-Id", /.*/); + + expect(scope0.isDone()).toBe(false); + expect(scope1.isDone()).toBe(false); + + const response = await fgaClient.batchCheck({ + checks: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "can_read", + object: "document", + contextualTuples: { + tuple_keys: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "editor", + object: "folder:product" + }, { + user: "folder:product", + relation: "parent", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a" + } + ] + }, + correlationId: "cor-1", + }, + { + user: "folder:product", + relation: "parent", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + correlationId: "cor-2", + }, + { + user: "folder:product", + relation: "can_view", + object: "document:9992ab2a-d83f-756d-9397-c5ed9f3cj8a4", + correlationId: "cor-3", + }], + }, { + authorizationModelId: "01GAHCE4YVKPQEKZQHT2R89MQV", + consistency: ConsistencyPreference.HigherConsistency, + maxBatchSize: 2, + }); + + expect(scope0.isDone()).toBe(true); + expect(scope1.isDone()).toBe(true); + expect(response.responses).toHaveLength(3); + + const resp0 = response.responses.find(r => r.correlationId === "cor-1"); + const resp1 = response.responses.find(r => r.correlationId === "cor-2"); + const resp2 = response.responses.find(r => r.correlationId === "cor-3"); + + expect(resp0?.allowed).toBe(true); + expect(resp0?.request.user).toBe("user:81684243-9356-4421-8fbf-a4f8d36aa31b"); + expect(resp0?.request.relation).toBe("can_read"); + expect(resp0?.request.object).toBe("document"); + + expect(resp1?.allowed).toBe(false); + expect(resp1?.request.user).toBe("folder:product"); + expect(resp1?.request.relation).toBe("parent"); + expect(resp1?.request.object).toBe("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"); + + expect(resp2?.allowed).toBe(false); + expect(resp2?.request.user).toBe("folder:product"); + expect(resp2?.request.relation).toBe("can_view"); + expect(resp2?.request.object).toBe("document:9992ab2a-d83f-756d-9397-c5ed9f3cj8a4"); + + expect(resp2?.error?.input_error).toBe(ErrorCode.RelationNotFound); + expect(resp2?.error?.message).toBe("relation not found"); + }); + it("should throw an error if auth fails", async () => { + + const scope = nock(defaultConfiguration.getBasePath()) + .post(`/stores/${baseConfig.storeId!}/batch-check`) + .reply(401, {}); + + try { + await fgaClient.batchCheck({ + checks: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "can_read", + object: "document", + }], + }); + } catch (err) { + expect(err).toBeInstanceOf(FgaApiAuthenticationError); + } finally { + expect(scope.isDone()).toBe(true); + } + }); + }); + describe("Expand", () => { it("should properly call the Expand API", async () => { const tuple = { diff --git a/config/clients/js/template/tests/helpers/nocks.ts.mustache b/config/clients/js/template/tests/helpers/nocks.ts.mustache index dd3b4d35..0cceab52 100644 --- a/config/clients/js/template/tests/helpers/nocks.ts.mustache +++ b/config/clients/js/template/tests/helpers/nocks.ts.mustache @@ -4,6 +4,8 @@ import type * as Nock from "nock"; import { AuthorizationModel, + BatchCheckRequest, + BatchCheckResponse, CheckRequest, CheckResponse, ConsistencyPreference, @@ -202,6 +204,20 @@ export const getNocks = ((nock: typeof Nock) => ({ ) .reply(statusCode, response as CheckResponse); }, + singleBatchCheck: ( + storeId: string, + responseBody: BatchCheckResponse, + basePath = defaultConfiguration.getBasePath(), + consistency: ConsistencyPreference|undefined | undefined, + authorizationModelId = "auth-model-id", + ) => { + return nock(basePath) + .post(`/stores/${storeId}/batch-check`, (body: BatchCheckRequest) => + body.consistency === consistency && + body.authorization_model_id === authorizationModelId + ) + .reply(200, responseBody); + }, expand: ( storeId: string, tuple: TupleKey, diff --git a/config/clients/js/template/tests/telemetry/attributes.test.ts.mustache b/config/clients/js/template/tests/telemetry/attributes.test.ts.mustache index a418a913..0188b876 100644 --- a/config/clients/js/template/tests/telemetry/attributes.test.ts.mustache +++ b/config/clients/js/template/tests/telemetry/attributes.test.ts.mustache @@ -95,4 +95,31 @@ describe("TelemetryAttributes", () => { expect(attributes[TelemetryAttribute.FgaClientRequestModelId]).toEqual("model-id"); expect(attributes[TelemetryAttribute.FgaClientUser]).toBeUndefined(); }); + + + test("should create attributes from a batchCheck request body correctly", () => { + const body = { + authorization_model_id: "model-id", + checks: [ + { + tuple_key: { + user: "user:anne", + object: "doc:123", + relation: "can_view" + } + }, + { + tuple_key: { + user: "user:anne", + object: "doc:789", + relation: "can_view" + } + } + ] + }; + const attributes = TelemetryAttributes.fromRequestBody(body); + + expect(attributes[TelemetryAttribute.FgaClientRequestModelId]).toEqual("model-id"); + expect(attributes[TelemetryAttribute.FgaClientRequestBatchCheckSize]).toEqual(2); + }); });