Skip to content

Commit

Permalink
feat(js-sdk)!: Support Server Batch Check (#465)
Browse files Browse the repository at this point in the history
  • Loading branch information
rhamzeh authored Jan 14, 2025
2 parents 464e382 + 7c97c43 commit 43cf83f
Show file tree
Hide file tree
Showing 10 changed files with 493 additions and 39 deletions.
6 changes: 6 additions & 0 deletions config/clients/js/CHANGELOG.md.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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. The existing `BatchCheckResponse` has been renamed to `ClientBatchCheckResponse` and it now bundles the results in a field called `result` instead of `responses`.

## v0.7.0

Expand Down
182 changes: 160 additions & 22 deletions config/clients/js/template/client.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}}";

Expand Down Expand Up @@ -115,9 +121,9 @@ export type ClientCheckRequest = CheckRequestTupleKey &
Pick<CheckRequest, "context"> &
{ contextualTuples?: Array<TupleKey> };

export type ClientBatchCheckRequest = ClientCheckRequest[];
export type ClientBatchCheckClientRequest = ClientCheckRequest[];

export type ClientBatchCheckSingleResponse = {
export type ClientBatchCheckSingleClientResponse = {
_request: ClientCheckRequest;
} & ({
allowed: boolean;
Expand All @@ -127,8 +133,46 @@ export type ClientBatchCheckSingleResponse = {
error: Error;
});

export interface ClientBatchCheckClientResponse {
result: 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[];
result: ClientBatchCheckSingleResponse[];
}

export interface ClientWriteRequestOpts {
Expand All @@ -139,10 +183,6 @@ export interface ClientWriteRequestOpts {
}
}

export interface BatchCheckRequestOpts {
maxParallelRequests?: number;
}

export interface ClientWriteRequest {
writes?: TupleKey[];
deletes?: TupleKeyWithoutCondition[];
Expand Down Expand Up @@ -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<ClientBatchCheckResponse> {
async clientBatchCheck(body: ClientBatchCheckClientRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckClientRequestOpts = {}): Promise<ClientBatchCheckClientResponse> {
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 result: 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) {
Expand All @@ -609,12 +650,109 @@ export class {{appShortName}}Client extends BaseAPI {
};
})
)) {
responses.push(singleCheckResponse);
result.push(singleCheckResponse);
}
return { result };
}



return { responses };
private singleBatchCheck(body: BatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise<BatchCheckResponse> {
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<ClientBatchCheckResponse> {
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<string, ClientBatchCheckItem>();
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 { result: results };
}

/**
* Expand - Expands the relationships in userset tree format (evaluates)
* @param {ClientExpandRequest} body
Expand Down Expand Up @@ -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<ClientListRelationsResponse> {
async listRelations(listRelationsRequest: ClientListRelationsRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckClientRequestOpts = {}): Promise<ClientListRelationsResponse> {
const { user, object, relations, contextualTuples, context } = listRelationsRequest;
const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS } = options;
setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "ListRelations");
Expand All @@ -679,20 +817,20 @@ 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,
contextualTuples,
context,
})), { ...options, headers, maxParallelRequests });

const firstErrorResponse = batchCheckResults.responses.find(response => (response as any).error);
const firstErrorResponse = batchCheckResults.result.find(response => (response as any).error);
if (firstErrorResponse) {
throw (firstErrorResponse as any).error;
}

return { relations: batchCheckResults.responses.filter(result => result.allowed).map(result => result._request.relation) };
return { relations: batchCheckResults.result.filter(result => result.allowed).map(result => result._request.relation) };
}

/**
Expand Down
35 changes: 35 additions & 0 deletions config/clients/js/template/example/example1/example1.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName } from "@openfga/sdk";
import { randomUUID } from "crypto";

async function main () {
let credentials;
Expand Down Expand Up @@ -137,6 +138,11 @@ async function main () {
Type: "document"
}
}
},
{
user: "user:bob",
relation: "writer",
object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a"
}
]
}, { authorizationModelId });
Expand Down Expand Up @@ -180,6 +186,35 @@ async function main () {
});
console.log(`Allowed: ${allowed}`);

// execute a batch check
const anneCorrelationId = randomUUID();
const { result } = 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 = result.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([
{
Expand Down
29 changes: 29 additions & 0 deletions config/clients/js/template/example/opentelemetry/opentelemetry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const telemetryConfig = {
},
[TelemetryMetric.HistogramQueryDuration]: {
attributes: new Set([
TelemetryAttribute.FgaClientRequestBatchCheckSize,
TelemetryAttribute.HttpResponseStatusCode,
TelemetryAttribute.UserAgentOriginal,
TelemetryAttribute.FgaClientRequestMethod,
Expand Down Expand Up @@ -99,6 +100,34 @@ async function main () {
}
}

console.log("Calling BatcCheck")
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: [
Expand Down
Loading

0 comments on commit 43cf83f

Please sign in to comment.