From 536bef3a017737c3dbd980a88c994c991a69a84e Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Thu, 5 Dec 2024 16:08:10 -0600 Subject: [PATCH 01/18] gen files for server batch check API --- .openapi-generator/FILES | 2 - README.md | 6 +- api.ts | 84 ++++++++++++++++++++++++++ apiModel.ts | 123 +++++++++++++++++++++++++++++++++++++++ configuration.ts | 2 +- 5 files changed, 211 insertions(+), 6 deletions(-) diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index e086dd2..d250384 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -7,8 +7,6 @@ .github/ISSUE_TEMPLATE/config.yaml .github/ISSUE_TEMPLATE/feature_request.yaml .github/dependabot.yaml -.github/workflows/main.yaml -.github/workflows/semgrep.yaml .gitignore .gitignore .madgerc diff --git a/README.md b/README.md index b938aa8..ab37ba2 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ yarn add @openfga/sdk We strongly recommend you initialize the `OpenFgaClient` only once and then re-use it throughout your app, otherwise you will incur the cost of having to re-initialize multiple times or at every request, the cost of reduced connection pooling and re-use, and would be particularly costly in the client credentials flow, as that flow will be performed on every request. -> The `OpenFgaClient` will by default retry API requests up to 15 times on 429 and 5xx errors. +> The `OpenFgaClient` will by default retry API requests up to 3 times on 429 and 5xx errors. #### No Credentials @@ -446,7 +446,7 @@ const result = await fgaClient.check({ ##### Batch Check Run a set of [checks](#check). Batch Check will return `allowed: false` if it encounters an error, and will return the error in the body. -If 429s or 5xxs are encountered, the underlying check will retry up to 15 times before giving up. +If 429s or 5xxs are encountered, the underlying check will retry up to 3 times before giving up. ```javascript const options = { @@ -667,7 +667,7 @@ const response = await fgaClient.writeAssertions([{ ### Retries -If a network request fails with a 429 or 5xx error from the server, the SDK will automatically retry the request up to 15 times with a minimum wait time of 100 milliseconds between each attempt. +If a network request fails with a 429 or 5xx error from the server, the SDK will automatically retry the request up to 3 times with a minimum wait time of 100 milliseconds between each attempt. To customize this behavior, create an object with `maxRetry` and `minWaitInMs` properties. `maxRetry` determines the maximum number of retries (up to 15), while `minWaitInMs` sets the minimum wait time between retries in milliseconds. diff --git a/api.ts b/api.ts index cdfa933..103f7fd 100644 --- a/api.ts +++ b/api.ts @@ -35,6 +35,11 @@ import { AssertionTupleKey, AuthErrorCode, AuthorizationModel, + BatchCheckItem, + BatchCheckRequest, + BatchCheckResponse, + BatchCheckSingleResult, + CheckError, CheckRequest, CheckRequestTupleKey, CheckResponse, @@ -120,6 +125,45 @@ import { TelemetryAttribute, TelemetryAttributes } from "./telemetry/attributes" */ export const OpenFgaApiAxiosParamCreator = function (configuration: Configuration, credentials: Credentials) { return { + /** + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map\'s keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` + * @summary Send a list of `check` operations in a single request + * @param {string} storeId + * @param {BatchCheckRequest} body + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + batchCheck: (storeId: string, body: BatchCheckRequest, options: any = {}): RequestArgs => { + // verify required parameter 'storeId' is not null or undefined + assertParamExists("batchCheck", "storeId", storeId); + // verify required parameter 'body' is not null or undefined + assertParamExists("batchCheck", "body", body); + const localVarPath = "/stores/{store_id}/batch-check" + .replace(`{${"store_id"}}`, encodeURIComponent(String(storeId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: "POST", ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + localVarRequestOptions.headers = {...localVarHeaderParameter, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * The Check API returns whether a given user has a relationship with a given object in a given store. The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. The response will return whether the relationship exists in the field `allowed`. Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. ## Examples ### Querying with contextual tuples In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple ```json { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ``` the Check API can be used with the following request body: ```json { \"tuple_key\": { \"user\": \"user:anne\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Querying usersets Some Checks will always return `true`, even without any tuples. For example, for the following authorization model ```python model schema 1.1 type user type document relations define reader: [user] ``` the following query ```json { \"tuple_key\": { \"user\": \"document:2021-budget#reader\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } } ``` will always return `{ \"allowed\": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. ### Querying usersets with difference in the model A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model ```python model schema 1.1 type user type group relations define member: [user] type document relations define blocked: [user] define reader: [group#member] but not blocked ``` the following query ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"group:finance\" }, { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, { \"user\": \"user:anne\", \"relation\": \"blocked\", \"object\": \"document:2021-budget\" } ] }, } ``` will return `{ \"allowed\": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. ### Requesting higher consistency By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"consistency\": \"HIGHER_CONSISTENCY\" } ``` * @summary Check whether a user is authorized to access an object @@ -757,6 +801,22 @@ export const OpenFgaApiAxiosParamCreator = function (configuration: Configuratio export const OpenFgaApiFp = function(configuration: Configuration, credentials: Credentials) { const localVarAxiosParamCreator = OpenFgaApiAxiosParamCreator(configuration, credentials); return { + /** + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map\'s keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` + * @summary Send a list of `check` operations in a single request + * @param {string} storeId + * @param {BatchCheckRequest} body + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + async batchCheck(storeId: string, body: BatchCheckRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + const localVarAxiosArgs = localVarAxiosParamCreator.batchCheck(storeId, body, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { + [TelemetryAttribute.FgaClientRequestMethod]: "BatchCheck", + [TelemetryAttribute.FgaClientRequestStoreId]: storeId ?? "", + ...TelemetryAttributes.fromRequestBody(body) + }); + }, /** * The Check API returns whether a given user has a relationship with a given object in a given store. The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. The response will return whether the relationship exists in the field `allowed`. Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. ## Examples ### Querying with contextual tuples In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple ```json { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ``` the Check API can be used with the following request body: ```json { \"tuple_key\": { \"user\": \"user:anne\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Querying usersets Some Checks will always return `true`, even without any tuples. For example, for the following authorization model ```python model schema 1.1 type user type document relations define reader: [user] ``` the following query ```json { \"tuple_key\": { \"user\": \"document:2021-budget#reader\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } } ``` will always return `{ \"allowed\": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. ### Querying usersets with difference in the model A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model ```python model schema 1.1 type user type group relations define member: [user] type document relations define blocked: [user] define reader: [group#member] but not blocked ``` the following query ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"group:finance\" }, { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, { \"user\": \"user:anne\", \"relation\": \"blocked\", \"object\": \"document:2021-budget\" } ] }, } ``` will return `{ \"allowed\": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. ### Requesting higher consistency By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"consistency\": \"HIGHER_CONSISTENCY\" } ``` * @summary Check whether a user is authorized to access an object @@ -1016,6 +1076,17 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: export const OpenFgaApiFactory = function (configuration: Configuration, credentials: Credentials, axios?: AxiosInstance) { const localVarFp = OpenFgaApiFp(configuration, credentials); return { + /** + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map\'s keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` + * @summary Send a list of `check` operations in a single request + * @param {string} storeId + * @param {BatchCheckRequest} body + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + batchCheck(storeId: string, body: BatchCheckRequest, options?: any): PromiseResult { + return localVarFp.batchCheck(storeId, body, options).then((request) => request(axios)); + }, /** * The Check API returns whether a given user has a relationship with a given object in a given store. The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. The response will return whether the relationship exists in the field `allowed`. Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. ## Examples ### Querying with contextual tuples In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple ```json { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ``` the Check API can be used with the following request body: ```json { \"tuple_key\": { \"user\": \"user:anne\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Querying usersets Some Checks will always return `true`, even without any tuples. For example, for the following authorization model ```python model schema 1.1 type user type document relations define reader: [user] ``` the following query ```json { \"tuple_key\": { \"user\": \"document:2021-budget#reader\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } } ``` will always return `{ \"allowed\": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. ### Querying usersets with difference in the model A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model ```python model schema 1.1 type user type group relations define member: [user] type document relations define blocked: [user] define reader: [group#member] but not blocked ``` the following query ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"group:finance\" }, { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, { \"user\": \"user:anne\", \"relation\": \"blocked\", \"object\": \"document:2021-budget\" } ] }, } ``` will return `{ \"allowed\": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. ### Requesting higher consistency By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"consistency\": \"HIGHER_CONSISTENCY\" } ``` * @summary Check whether a user is authorized to access an object @@ -1204,6 +1275,19 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @extends {BaseAPI} */ export class OpenFgaApi extends BaseAPI { + /** + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map\'s keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` + * @summary Send a list of `check` operations in a single request + * @param {string} storeId + * @param {BatchCheckRequest} body + * @param {*} [options] Override http request option. + * @throws { FgaError } + * @memberof OpenFgaApi + */ + public batchCheck(storeId: string, body: BatchCheckRequest, options?: any): Promise> { + return OpenFgaApiFp(this.configuration, this.credentials).batchCheck(storeId, body, options).then((request) => request(this.axios)); + } + /** * The Check API returns whether a given user has a relationship with a given object in a given store. The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. The response will return whether the relationship exists in the field `allowed`. Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. ## Examples ### Querying with contextual tuples In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple ```json { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ``` the Check API can be used with the following request body: ```json { \"tuple_key\": { \"user\": \"user:anne\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Querying usersets Some Checks will always return `true`, even without any tuples. For example, for the following authorization model ```python model schema 1.1 type user type document relations define reader: [user] ``` the following query ```json { \"tuple_key\": { \"user\": \"document:2021-budget#reader\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } } ``` will always return `{ \"allowed\": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. ### Querying usersets with difference in the model A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model ```python model schema 1.1 type user type group relations define member: [user] type document relations define blocked: [user] define reader: [group#member] but not blocked ``` the following query ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"group:finance\" }, { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, { \"user\": \"user:anne\", \"relation\": \"blocked\", \"object\": \"document:2021-budget\" } ] }, } ``` will return `{ \"allowed\": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. ### Requesting higher consistency By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"consistency\": \"HIGHER_CONSISTENCY\" } ``` * @summary Check whether a user is authorized to access an object diff --git a/apiModel.ts b/apiModel.ts index a1d91b1..6630599 100644 --- a/apiModel.ts +++ b/apiModel.ts @@ -152,6 +152,123 @@ export interface AuthorizationModel { */ conditions?: { [key: string]: Condition; }; } +/** + * + * @export + * @interface BatchCheckItem + */ +export interface BatchCheckItem { + /** + * + * @type {CheckRequestTupleKey} + * @memberof BatchCheckItem + */ + tuple_key: CheckRequestTupleKey; + /** + * + * @type {ContextualTupleKeys} + * @memberof BatchCheckItem + */ + contextual_tuples?: ContextualTupleKeys; + /** + * + * @type {object} + * @memberof BatchCheckItem + */ + context?: object; + /** + * correlation_id must be a string containing only letters, numbers, or hyphens, with length ≤ 36 characters. + * @type {string} + * @memberof BatchCheckItem + */ + correlation_id: string; +} +/** + * + * @export + * @interface BatchCheckRequest + */ +export interface BatchCheckRequest { + /** + * + * @type {Array} + * @memberof BatchCheckRequest + */ + checks: Array; + /** + * + * @type {string} + * @memberof BatchCheckRequest + */ + authorization_model_id?: string; + /** + * + * @type {ConsistencyPreference} + * @memberof BatchCheckRequest + */ + consistency?: ConsistencyPreference; +} + + +/** + * + * @export + * @interface BatchCheckResponse + */ +export interface BatchCheckResponse { + /** + * map keys are the correlation_id values from the BatchCheckItems in the request + * @type {{ [key: string]: BatchCheckSingleResult; }} + * @memberof BatchCheckResponse + */ + result?: { [key: string]: BatchCheckSingleResult; }; +} +/** + * + * @export + * @interface BatchCheckSingleResult + */ +export interface BatchCheckSingleResult { + /** + * + * @type {boolean} + * @memberof BatchCheckSingleResult + */ + allowed?: boolean; + /** + * + * @type {CheckError} + * @memberof BatchCheckSingleResult + */ + error?: CheckError; +} +/** + * + * @export + * @interface CheckError + */ +export interface CheckError { + /** + * + * @type {ErrorCode} + * @memberof CheckError + */ + inputError?: ErrorCode; + /** + * + * @type {InternalErrorCode} + * @memberof CheckError + */ + internalError?: InternalErrorCode; + /** + * + * @type {string} + * @memberof CheckError + */ + message?: string; +} + + /** * * @export @@ -496,6 +613,12 @@ export interface ExpandRequest { * @memberof ExpandRequest */ consistency?: ConsistencyPreference; + /** + * + * @type {ContextualTupleKeys} + * @memberof ExpandRequest + */ + contextual_tuples?: ContextualTupleKeys; } diff --git a/configuration.ts b/configuration.ts index ed0ba48..45598da 100644 --- a/configuration.ts +++ b/configuration.ts @@ -17,7 +17,7 @@ import { assertParamExists, isWellFormedUlidString, isWellFormedUriString } from import { TelemetryConfig, TelemetryConfiguration } from "./telemetry/configuration"; // default maximum number of retry -const DEFAULT_MAX_RETRY = 15; +const DEFAULT_MAX_RETRY = 3; // default minimum wait period in retry - but will backoff exponentially const DEFAULT_MIN_WAIT_MS = 100; From 032f690c145bae5ea46bcda3b758cad5f62d22ee Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Thu, 5 Dec 2024 16:27:09 -0600 Subject: [PATCH 02/18] rename batchCheck to ClientBatchCheck (!) --- client.ts | 12 ++++++------ tests/client.test.ts | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/client.ts b/client.ts index 81f375a..afde5b7 100644 --- a/client.ts +++ b/client.ts @@ -150,7 +150,7 @@ export interface ClientWriteRequestOpts { } } -export interface BatchCheckRequestOpts { +export interface ClientBatchCheckRequestOpts { maxParallelRequests?: number; } @@ -589,7 +589,7 @@ export class OpenFgaClient extends BaseAPI { /** * BatchCheck - Run a set of checks (evaluates) * @param {ClientBatchCheckRequest} body - * @param {ClientRequestOptsWithAuthZModelId & BatchCheckRequestOpts} [options] + * @param {ClientRequestOptsWithAuthZModelId & ClientBatchCheckRequestOpts} [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 {object} [options.headers] - Custom headers to send alongside the request @@ -597,9 +597,9 @@ export class OpenFgaClient extends BaseAPI { * @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: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): 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[] = []; @@ -680,7 +680,7 @@ export class OpenFgaClient 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 & ClientBatchCheckRequestOpts = {}): Promise { const { user, object, relations, contextualTuples, context } = listRelationsRequest; const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS } = options; setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "ListRelations"); @@ -690,7 +690,7 @@ export class OpenFgaClient 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/tests/client.test.ts b/tests/client.test.ts index 26440e3..bc7eb2b 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -511,7 +511,7 @@ describe("OpenFGA Client", () => { }); }); - describe("BatchCheck", () => { + describe("ClientBatchCheck", () => { it("should properly call the Check API", async () => { const tuples = [{ user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", @@ -526,12 +526,12 @@ describe("OpenFGA 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 }) @@ -542,7 +542,7 @@ describe("OpenFGA Client", () => { 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); From 4d4cc41310d24d4deab91064cde1e8d192192de7 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Thu, 5 Dec 2024 17:08:02 -0600 Subject: [PATCH 03/18] remove model changes for expand updates and start time errors --- apiModel.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apiModel.ts b/apiModel.ts index 6630599..ea71374 100644 --- a/apiModel.ts +++ b/apiModel.ts @@ -585,8 +585,7 @@ export enum ErrorCode { DuplicateContextualTuple = 'duplicate_contextual_tuple', InvalidAuthorizationModel = 'invalid_authorization_model', UnsupportedSchemaVersion = 'unsupported_schema_version', - Cancelled = 'cancelled', - InvalidStartTime = 'invalid_start_time' + Cancelled = 'cancelled' } /** @@ -613,12 +612,6 @@ export interface ExpandRequest { * @memberof ExpandRequest */ consistency?: ConsistencyPreference; - /** - * - * @type {ContextualTupleKeys} - * @memberof ExpandRequest - */ - contextual_tuples?: ContextualTupleKeys; } From 49348bed9d8710b98c0b2ba27dd2f9873d78124f Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Mon, 9 Dec 2024 15:41:26 -0600 Subject: [PATCH 04/18] update client type names --- client.ts | 81 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/client.ts b/client.ts index afde5b7..bf111c9 100644 --- a/client.ts +++ b/client.ts @@ -17,6 +17,9 @@ import asyncPool = require("tiny-async-pool"); import { OpenFgaApi } from "./api"; import { Assertion, + BatchCheckRequest, + BatchCheckResponse, + CheckError, CheckRequest, CheckRequestTupleKey, CheckResponse, @@ -126,9 +129,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; @@ -138,6 +141,45 @@ 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?: TupleKey[]; + 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 = { + // TODO which are required/optional? + allowed: boolean; + tupleKey?: TupleKey; + correlationId?: string; + error?: CheckError; +} + +// for server batch check export interface ClientBatchCheckResponse { responses: ClientBatchCheckSingleResponse[]; } @@ -150,10 +192,6 @@ export interface ClientWriteRequestOpts { } } -export interface ClientBatchCheckRequestOpts { - maxParallelRequests?: number; -} - export interface ClientWriteRequest { writes?: TupleKey[]; deletes?: TupleKeyWithoutCondition[]; @@ -588,8 +626,8 @@ export class OpenFgaClient extends BaseAPI { /** * BatchCheck - Run a set of checks (evaluates) - * @param {ClientBatchCheckRequest} body - * @param {ClientRequestOptsWithAuthZModelId & ClientBatchCheckRequestOpts} [options] + * @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 {object} [options.headers] - Custom headers to send alongside the request @@ -597,16 +635,16 @@ export class OpenFgaClient extends BaseAPI { * @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 clientBatchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { + async clientBatchCheck(body: ClientBatchCheckClientRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckClientRequestOpts = {}): Promise { const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS } = options; 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) { @@ -626,6 +664,23 @@ export class OpenFgaClient extends BaseAPI { return { responses }; } + + private singleBatchCheck(body: BatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { + return this.api.batchCheck(this.getStoreId(options)!, body, options); + } + + async batchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { + const { checks } = body; + // TODO MAKE CONSTANT + const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, maxBatchSize = 50 } = options; + // TODO what's right here? + setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); + setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); + + // TODO implement + return Promise.resolve({}); + } + /** * Expand - Expands the relationships in userset tree format (evaluates) * @param {ClientExpandRequest} body @@ -680,7 +735,7 @@ export class OpenFgaClient extends BaseAPI { * @param {object} listRelationsRequest.context The contextual tuples to send * @param options */ - async listRelations(listRelationsRequest: ClientListRelationsRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): 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"); From 176f12160834e4ad3571034be50cc652da6490f7 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Wed, 11 Dec 2024 11:23:27 -0600 Subject: [PATCH 05/18] batchCheck method arg validations --- client.ts | 44 ++++++++++++++++++++++++++--------- example/example1/package.json | 2 +- tests/client.test.ts | 32 +++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/client.ts b/client.ts index bf111c9..ad60620 100644 --- a/client.ts +++ b/client.ts @@ -17,6 +17,7 @@ import asyncPool = require("tiny-async-pool"); import { OpenFgaApi } from "./api"; import { Assertion, + BatchCheckItem, BatchCheckRequest, BatchCheckResponse, CheckError, @@ -24,6 +25,7 @@ import { CheckRequestTupleKey, CheckResponse, ConsistencyPreference, + ContextualTupleKeys, CreateStoreRequest, CreateStoreResponse, ExpandRequestTupleKey, @@ -99,6 +101,7 @@ export class ClientConfiguration extends Configuration { } const DEFAULT_MAX_METHOD_PARALLEL_REQS = 10; +const DEFAULT_MAX_BATCH_SIZE = 50; const CLIENT_METHOD_HEADER = "X-OpenFGA-Client-Method"; const CLIENT_BULK_REQUEST_ID_HEADER = "X-OpenFGA-Client-Bulk-Request-Id"; @@ -155,7 +158,7 @@ export type ClientBatchCheckItem = { relation: string; object: string; correlationId?: string; - contextualTuples?: TupleKey[]; + contextualTuples?: ContextualTupleKeys; context?: object; }; @@ -170,12 +173,12 @@ export interface ClientBatchCheckRequestOpts { maxBatchSize?: number; } + // for server batch check export type ClientBatchCheckSingleResponse = { - // TODO which are required/optional? allowed: boolean; - tupleKey?: TupleKey; - correlationId?: string; + request: ClientBatchCheckItem; + correlationId: string; error?: CheckError; } @@ -669,17 +672,36 @@ export class OpenFgaClient extends BaseAPI { return this.api.batchCheck(this.getStoreId(options)!, body, options); } - async batchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { - const { checks } = body; - // TODO MAKE CONSTANT - const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, maxBatchSize = 50 } = options; - // TODO what's right here? + async batchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { + const { + headers = {}, + maxBatchSize = DEFAULT_MAX_BATCH_SIZE, + maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, + } = options; + + // TODO is this right? setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); - // TODO implement - return Promise.resolve({}); + const seenCorrelationIds = new Set(); + for (const check of body.checks) { + if (!check.correlationId) { + check.correlationId = generateRandomIdWithNonUniqueFallback(); + } + seenCorrelationIds.add(check.correlationId); + if (seenCorrelationIds.has(check.correlationId)) { + throw new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique"); + } + } + + if (!body?.checks?.length) { + throw new FgaValidationError("checks", "When calling batchCheck, at least one check must be specified"); + } + + // TODO implement ;) + return Promise.resolve({} as ClientBatchCheckResponse); } + /** * Expand - Expands the relationships in userset tree format (evaluates) diff --git a/example/example1/package.json b/example/example1/package.json index 9835006..e1689a8 100644 --- a/example/example1/package.json +++ b/example/example1/package.json @@ -9,7 +9,7 @@ "start": "node example1.mjs" }, "dependencies": { - "@openfga/sdk": "^0.7.0" + "@openfga/sdk": "file:../../" }, "engines": { "node": ">=16.13.0" diff --git a/tests/client.test.ts b/tests/client.test.ts index bc7eb2b..b90bf16 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -558,6 +558,38 @@ describe("OpenFGA 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 throw error when no checks are specified", async () => { + expect( + fgaClient.batchCheck({ + checks: [] + }) + ).rejects.toThrow(new FgaValidationError("checks", "When calling batchCheck, at least one check must be specified")); + }); + }); + + describe("Expand", () => { it("should properly call the Expand API", async () => { const tuple = { From 0fbc635f71a88c349cae89b98bf34c463742ce66 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Thu, 12 Dec 2024 16:34:15 -0600 Subject: [PATCH 06/18] batchCheck first implementation --- client.ts | 157 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 12 deletions(-) diff --git a/client.ts b/client.ts index ad60620..1cfa679 100644 --- a/client.ts +++ b/client.ts @@ -672,37 +672,170 @@ export class OpenFgaClient extends BaseAPI { return this.api.batchCheck(this.getStoreId(options)!, body, options); } - async batchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { + async batchCheck( + body: ClientBatchCheckRequest, + options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {} + ): Promise { const { headers = {}, maxBatchSize = DEFAULT_MAX_BATCH_SIZE, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, } = options; - // TODO is this right? + // TODO this right? Do it here? setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); - - const seenCorrelationIds = new Set(); + + if (!body?.checks?.length) { + throw new FgaValidationError("checks", "When calling batchCheck, at least one check must be specified"); + } + + 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(); } - seenCorrelationIds.add(check.correlationId); - if (seenCorrelationIds.has(check.correlationId)) { + + // 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, + }); } - - if (!body?.checks?.length) { - throw new FgaValidationError("checks", "When calling batchCheck, at least one check must be specified"); + + // 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 executeBatch = async (batch: BatchCheckItem[]) => { + // Prepare request payload + const batchRequest: BatchCheckRequest = { + checks: batch, + authorization_model_id: options.authorizationModelId, // TODO this right here? + consistency: options.consistency, + }; + + // Make API call to execute the batch check + const response = await this.singleBatchCheck(batchRequest, options); + return response.result; + }; + + // Use asyncPool to process batches concurrently, but limit to maxParallelRequests + const batchResponses = asyncPool(maxParallelRequests, batchedChecks, executeBatch); + + // 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, + }); + } + } + } } - - // TODO implement ;) - return Promise.resolve({} as ClientBatchCheckResponse); + + // Return the final response in the expected format + console.log("Results before returning:", results); // Added logging + return { responses: results }; } + + + + // async batchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { + // const { + // headers = {}, + // maxBatchSize = DEFAULT_MAX_BATCH_SIZE, + // maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, + // } = options; + + // // TODO is this right? + // setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); + // setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); + + // // const seenCorrelationIds = new Set(); + // // for (const check of body.checks) { + // // if (!check.correlationId) { + // // check.correlationId = generateRandomIdWithNonUniqueFallback(); + // // } + // // seenCorrelationIds.add(check.correlationId); + // // if (seenCorrelationIds.has(check.correlationId)) { + // // throw new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique"); + // // } + // // } + + // if (!body?.checks?.length) { + // throw new FgaValidationError("checks", "When calling batchCheck, at least one check must be specified"); + // } + + // const checkToId = new Map(); + // const transformed: BatchCheckItem[] = []; + + // for (const check of body.checks) { + // if (!check.correlationId) { + // check.correlationId = generateRandomIdWithNonUniqueFallback(); + // } + // if (checkToId.has(check.correlationId)) { + // throw new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique"); + // } + // checkToId.set(check.correlationId, check); + // transformed.push({ + // tuple_key: { + // user: check.user, + // relation: check.relation, + // object: check.object, + // }, + // context: check.context, + // contextual_tuples: check.contextualTuples, + // correlation_id: check.correlationId, + // }); + // } + + // const batchCheckRequest: BatchCheckRequest = { + // checks: transformed, + // authorization_model_id: options.authorizationModelId, + // consistency: options.consistency, + // }; + + // // TODO does single need to accept options? Have some on the request? What about storeId? + // await this.singleBatchCheck(batchCheckRequest, { ...options, headers }) + // .then(response => { + + // }) + // .catch(err => { + + // }); + + // // return await(this.singleBatchCheck(batchCheckRequest, options)) + // // TODO implement ;) + // return Promise.resolve({} as ClientBatchCheckResponse); + // } + + /** * Expand - Expands the relationships in userset tree format (evaluates) * @param {ClientExpandRequest} body From a86f5d3ef447a630bcde925d0996335da455b48f Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Mon, 16 Dec 2024 16:04:56 -0600 Subject: [PATCH 07/18] return empty responses if no checks sent, add additional tests --- client.ts | 6 +- tests/client.test.ts | 158 +++++++++++++++++++++++++++++++++++++++-- tests/helpers/nocks.ts | 14 ++++ 3 files changed, 166 insertions(+), 12 deletions(-) diff --git a/client.ts b/client.ts index 1cfa679..1018d58 100644 --- a/client.ts +++ b/client.ts @@ -686,10 +686,6 @@ export class OpenFgaClient extends BaseAPI { setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); - if (!body?.checks?.length) { - throw new FgaValidationError("checks", "When calling batchCheck, at least one check must be specified"); - } - const correlationIdToCheck = new Map(); const transformed: BatchCheckItem[] = []; @@ -724,6 +720,7 @@ export class OpenFgaClient extends BaseAPI { // Execute batch checks in parallel with a limit of maxParallelRequests const results: ClientBatchCheckSingleResponse[] = []; + const executeBatch = async (batch: BatchCheckItem[]) => { // Prepare request payload const batchRequest: BatchCheckRequest = { @@ -758,7 +755,6 @@ export class OpenFgaClient extends BaseAPI { } // Return the final response in the expected format - console.log("Results before returning:", results); // Added logging return { responses: results }; } diff --git a/tests/client.test.ts b/tests/client.test.ts index b90bf16..58248df 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -22,6 +22,7 @@ import { OpenFgaClient, ListUsersResponse, ConsistencyPreference, + ErrorCode, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -580,15 +581,158 @@ describe("OpenFGA Client", () => { ) ).rejects.toThrow(new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique")); }); - it(" should throw error when no checks are specified", async () => { - expect( - fgaClient.batchCheck({ - checks: [] - }) - ).rejects.toThrow(new FgaValidationError("checks", "When calling batchCheck, at least one check must be specified")); + 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); + + 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: { + inputError: ErrorCode.RelationNotFound, + message: "relation not found", + } + } + }, + }; + + const scope0 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse0, undefined, ConsistencyPreference.HigherConsistency); + const scope1 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse1, undefined, ConsistencyPreference.HigherConsistency); + + 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?.inputError).toBe(ErrorCode.RelationNotFound); + expect(resp2?.error?.message).toBe("relation not found"); + }); + }); describe("Expand", () => { it("should properly call the Expand API", async () => { diff --git a/tests/helpers/nocks.ts b/tests/helpers/nocks.ts index 9efe1e7..62ec416 100644 --- a/tests/helpers/nocks.ts +++ b/tests/helpers/nocks.ts @@ -15,6 +15,8 @@ import type * as Nock from "nock"; import { AuthorizationModel, + BatchCheckRequest, + BatchCheckResponse, CheckRequest, CheckResponse, ConsistencyPreference, @@ -213,6 +215,18 @@ export const getNocks = ((nock: typeof Nock) => ({ ) .reply(statusCode, response as CheckResponse); }, + singleBatchCheck: ( + storeId: string, + responseBody: BatchCheckResponse, + basePath = defaultConfiguration.getBasePath(), + consistency: ConsistencyPreference|undefined | undefined, + ) => { + return nock(basePath) + .post(`/stores/${storeId}/batch-check`, (body: BatchCheckRequest) => + body.consistency === consistency + ) + .reply(200, responseBody) + }, expand: ( storeId: string, tuple: TupleKey, From 8ea943f2d2b9806db3a0445919b707644593963a Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Mon, 16 Dec 2024 16:27:34 -0600 Subject: [PATCH 08/18] lint fixes --- tests/client.test.ts | 248 ++++++++++++++++++++--------------------- tests/helpers/nocks.ts | 2 +- 2 files changed, 125 insertions(+), 125 deletions(-) diff --git a/tests/client.test.ts b/tests/client.test.ts index 58248df..a0f6216 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -578,9 +578,9 @@ describe("OpenFGA Client", () => { }, ] } - ) - ).rejects.toThrow(new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique")); - }); + ) + ).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: [], @@ -588,149 +588,149 @@ describe("OpenFGA Client", () => { 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 mockedResponse = { + result: { + "cor-1": { + allowed: true, + error: undefined, }, - }; - const scope = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse, undefined, ConsistencyPreference.HigherConsistency); + "cor-2": { + allowed: false, + error: undefined, + }, + }, + }; + const scope = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse, undefined, ConsistencyPreference.HigherConsistency); - 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" - } + 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, - }); + { + 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); + 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 mockedResponse0 = { + result: { + "cor-1": { + allowed: true, + error: undefined, }, - }; - const mockedResponse1 = { - result: { - "cor-3": { - allowed: false, - error: { - inputError: ErrorCode.RelationNotFound, - message: "relation not found", - } - } + "cor-2": { + allowed: false, + error: undefined, }, - }; + }, + }; + const mockedResponse1 = { + result: { + "cor-3": { + allowed: false, + error: { + inputError: ErrorCode.RelationNotFound, + message: "relation not found", + } + } + }, + }; - const scope0 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse0, undefined, ConsistencyPreference.HigherConsistency); - const scope1 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse1, undefined, ConsistencyPreference.HigherConsistency); + const scope0 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse0, undefined, ConsistencyPreference.HigherConsistency); + const scope1 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse1, undefined, ConsistencyPreference.HigherConsistency); - expect(scope0.isDone()).toBe(false); - expect(scope1.isDone()).toBe(false); + 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" - } + 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, - }); + { + 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); + 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"); + 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(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?.inputError).toBe(ErrorCode.RelationNotFound); - expect(resp2?.error?.message).toBe("relation not found"); + 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?.inputError).toBe(ErrorCode.RelationNotFound); + expect(resp2?.error?.message).toBe("relation not found"); }); }); diff --git a/tests/helpers/nocks.ts b/tests/helpers/nocks.ts index 62ec416..1a78816 100644 --- a/tests/helpers/nocks.ts +++ b/tests/helpers/nocks.ts @@ -225,7 +225,7 @@ export const getNocks = ((nock: typeof Nock) => ({ .post(`/stores/${storeId}/batch-check`, (body: BatchCheckRequest) => body.consistency === consistency ) - .reply(200, responseBody) + .reply(200, responseBody); }, expand: ( storeId: string, From 6e0e39548f06287319e18daa6aba4ac31ca7e86b Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Mon, 16 Dec 2024 16:28:31 -0600 Subject: [PATCH 09/18] cleanup --- client.ts | 74 ------------------------------------------------------- 1 file changed, 74 deletions(-) diff --git a/client.ts b/client.ts index 1018d58..0e7850d 100644 --- a/client.ts +++ b/client.ts @@ -757,80 +757,6 @@ export class OpenFgaClient extends BaseAPI { // Return the final response in the expected format return { responses: results }; } - - - - - - // async batchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { - // const { - // headers = {}, - // maxBatchSize = DEFAULT_MAX_BATCH_SIZE, - // maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, - // } = options; - - // // TODO is this right? - // setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); - // setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); - - // // const seenCorrelationIds = new Set(); - // // for (const check of body.checks) { - // // if (!check.correlationId) { - // // check.correlationId = generateRandomIdWithNonUniqueFallback(); - // // } - // // seenCorrelationIds.add(check.correlationId); - // // if (seenCorrelationIds.has(check.correlationId)) { - // // throw new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique"); - // // } - // // } - - // if (!body?.checks?.length) { - // throw new FgaValidationError("checks", "When calling batchCheck, at least one check must be specified"); - // } - - // const checkToId = new Map(); - // const transformed: BatchCheckItem[] = []; - - // for (const check of body.checks) { - // if (!check.correlationId) { - // check.correlationId = generateRandomIdWithNonUniqueFallback(); - // } - // if (checkToId.has(check.correlationId)) { - // throw new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique"); - // } - // checkToId.set(check.correlationId, check); - // transformed.push({ - // tuple_key: { - // user: check.user, - // relation: check.relation, - // object: check.object, - // }, - // context: check.context, - // contextual_tuples: check.contextualTuples, - // correlation_id: check.correlationId, - // }); - // } - - // const batchCheckRequest: BatchCheckRequest = { - // checks: transformed, - // authorization_model_id: options.authorizationModelId, - // consistency: options.consistency, - // }; - - // // TODO does single need to accept options? Have some on the request? What about storeId? - // await this.singleBatchCheck(batchCheckRequest, { ...options, headers }) - // .then(response => { - - // }) - // .catch(err => { - - // }); - - // // return await(this.singleBatchCheck(batchCheckRequest, options)) - // // TODO implement ;) - // return Promise.resolve({} as ClientBatchCheckResponse); - // } - /** * Expand - Expands the relationships in userset tree format (evaluates) From 0a7c477ace931fb0ea37d45839033c3b4e9fe338 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Mon, 16 Dec 2024 20:29:29 -0600 Subject: [PATCH 10/18] small refactor --- client.ts | 17 +++++------------ tests/client.test.ts | 33 ++++++++++++++++++++++++++++++--- tests/helpers/nocks.ts | 4 +++- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/client.ts b/client.ts index 0e7850d..3fc3bf0 100644 --- a/client.ts +++ b/client.ts @@ -681,7 +681,7 @@ export class OpenFgaClient extends BaseAPI { maxBatchSize = DEFAULT_MAX_BATCH_SIZE, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, } = options; - + // TODO this right? Do it here? setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); @@ -720,22 +720,16 @@ export class OpenFgaClient extends BaseAPI { // Execute batch checks in parallel with a limit of maxParallelRequests const results: ClientBatchCheckSingleResponse[] = []; - - const executeBatch = async (batch: BatchCheckItem[]) => { - // Prepare request payload + const batchResponses = asyncPool(maxParallelRequests, batchedChecks, async (batch: BatchCheckItem[]) => { const batchRequest: BatchCheckRequest = { checks: batch, - authorization_model_id: options.authorizationModelId, // TODO this right here? + authorization_model_id: options.authorizationModelId, consistency: options.consistency, }; - - // Make API call to execute the batch check + const response = await this.singleBatchCheck(batchRequest, options); return response.result; - }; - - // Use asyncPool to process batches concurrently, but limit to maxParallelRequests - const batchResponses = asyncPool(maxParallelRequests, batchedChecks, executeBatch); + }); // Collect the responses and associate them with their correlation IDs for await (const response of batchResponses) { @@ -754,7 +748,6 @@ export class OpenFgaClient extends BaseAPI { } } - // Return the final response in the expected format return { responses: results }; } diff --git a/tests/client.test.ts b/tests/client.test.ts index a0f6216..fcd2354 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -600,7 +600,7 @@ describe("OpenFGA Client", () => { }, }, }; - const scope = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse, undefined, ConsistencyPreference.HigherConsistency); + const scope = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV"); expect(scope.isDone()).toBe(false); const response = await fgaClient.batchCheck({ @@ -663,8 +663,8 @@ describe("OpenFGA Client", () => { }, }; - const scope0 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse0, undefined, ConsistencyPreference.HigherConsistency); - const scope1 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse1, undefined, ConsistencyPreference.HigherConsistency); + const scope0 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse0, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV"); + const scope1 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse1, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV"); expect(scope0.isDone()).toBe(false); expect(scope1.isDone()).toBe(false); @@ -732,6 +732,33 @@ describe("OpenFGA Client", () => { expect(resp2?.error?.inputError).toBe(ErrorCode.RelationNotFound); expect(resp2?.error?.message).toBe("relation not found"); }); + // it("should throw an error if auth fails", async () => { + // const tuples = [{ + // user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + // relation: "admin", + // object: "workspace:1", + // }]; + + // const scope0 = nocks.check(baseConfig.storeId!, tuples[0], defaultConfiguration.getBasePath(), {} as any,401); + // const scope1 = nock(defaultConfiguration.getBasePath()) + // .get(`/stores/${defaultConfiguration.storeId!}/authorization-models`) + // .query({ page_size: 1 }) + // .reply(401, { + // authorization_models: [], + // }); + // try { + // await fgaClient.listRelations({ + // user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + // object: "workspace:1", + // relations: ["admin"], + // }); + // } catch (err) { + // expect(err).toBeInstanceOf(FgaApiAuthenticationError); + // } finally { + // expect(scope0.isDone()).toBe(true); + // expect(scope1.isDone()).toBe(false); + // } + // }); }); describe("Expand", () => { diff --git a/tests/helpers/nocks.ts b/tests/helpers/nocks.ts index 1a78816..e647555 100644 --- a/tests/helpers/nocks.ts +++ b/tests/helpers/nocks.ts @@ -220,10 +220,12 @@ export const getNocks = ((nock: typeof Nock) => ({ 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.consistency === consistency && + body.authorization_model_id === authorizationModelId ) .reply(200, responseBody); }, From 99dffc28de4b7ae5823c4b0c2dc0dc6f4e8a5493 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Tue, 17 Dec 2024 10:34:13 -0600 Subject: [PATCH 11/18] fix client headers --- client.ts | 4 +--- tests/client.test.ts | 7 ++++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/client.ts b/client.ts index 3fc3bf0..bb6f845 100644 --- a/client.ts +++ b/client.ts @@ -682,8 +682,6 @@ export class OpenFgaClient extends BaseAPI { maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, } = options; - // TODO this right? Do it here? - setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); const correlationIdToCheck = new Map(); @@ -727,7 +725,7 @@ export class OpenFgaClient extends BaseAPI { consistency: options.consistency, }; - const response = await this.singleBatchCheck(batchRequest, options); + const response = await this.singleBatchCheck(batchRequest, { ...options, headers }); return response.result; }); diff --git a/tests/client.test.ts b/tests/client.test.ts index fcd2354..174d7d5 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -600,7 +600,8 @@ describe("OpenFGA Client", () => { }, }, }; - const scope = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV"); + + 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({ @@ -663,8 +664,8 @@ describe("OpenFGA Client", () => { }, }; - const scope0 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse0, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV"); - const scope1 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse1, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV"); + 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); From 4562cc4856b9ecab74dec93e9cd40554cd7bc17a Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Tue, 17 Dec 2024 13:25:02 -0600 Subject: [PATCH 12/18] jsdocs, added error handling test --- client.ts | 18 ++++++++++++++++- tests/client.test.ts | 48 +++++++++++++++++++------------------------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/client.ts b/client.ts index bb6f845..2840d57 100644 --- a/client.ts +++ b/client.ts @@ -628,11 +628,12 @@ export class OpenFgaClient extends BaseAPI { } /** - * BatchCheck - Run a set of checks (evaluates) + * 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 @@ -672,6 +673,21 @@ export class OpenFgaClient extends BaseAPI { 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 = {} diff --git a/tests/client.test.ts b/tests/client.test.ts index 174d7d5..7e002da 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -23,6 +23,7 @@ import { ListUsersResponse, ConsistencyPreference, ErrorCode, + BatchCheckRequest, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -733,33 +734,26 @@ describe("OpenFGA Client", () => { expect(resp2?.error?.inputError).toBe(ErrorCode.RelationNotFound); expect(resp2?.error?.message).toBe("relation not found"); }); - // it("should throw an error if auth fails", async () => { - // const tuples = [{ - // user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", - // relation: "admin", - // object: "workspace:1", - // }]; - - // const scope0 = nocks.check(baseConfig.storeId!, tuples[0], defaultConfiguration.getBasePath(), {} as any,401); - // const scope1 = nock(defaultConfiguration.getBasePath()) - // .get(`/stores/${defaultConfiguration.storeId!}/authorization-models`) - // .query({ page_size: 1 }) - // .reply(401, { - // authorization_models: [], - // }); - // try { - // await fgaClient.listRelations({ - // user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", - // object: "workspace:1", - // relations: ["admin"], - // }); - // } catch (err) { - // expect(err).toBeInstanceOf(FgaApiAuthenticationError); - // } finally { - // expect(scope0.isDone()).toBe(true); - // expect(scope1.isDone()).toBe(false); - // } - // }); + 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", () => { From 6d45a4d0221ca1b930c2566ac318ad2a8e4e48ea Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Tue, 17 Dec 2024 13:57:51 -0600 Subject: [PATCH 13/18] update example with batchCheck call --- example/example1/example1.mjs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/example/example1/example1.mjs b/example/example1/example1.mjs index f8b82be..521040e 100644 --- a/example/example1/example1.mjs +++ b/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([ { From 59c000b7303212158932344602337539b7465f72 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Wed, 18 Dec 2024 16:36:13 -0600 Subject: [PATCH 14/18] add batch check size telemetry attribute --- example/opentelemetry/opentelemetry.mjs | 29 +++++++++++++++++++++++++ telemetry/attributes.ts | 4 ++++ telemetry/configuration.ts | 2 ++ tests/telemetry/attributes.test.ts | 26 ++++++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/example/opentelemetry/opentelemetry.mjs b/example/opentelemetry/opentelemetry.mjs index 87635de..9f87527 100644 --- a/example/opentelemetry/opentelemetry.mjs +++ b/example/opentelemetry/opentelemetry.mjs @@ -29,6 +29,7 @@ const telemetryConfig = { }, [TelemetryMetric.HistogramRequestDuration]: { 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/telemetry/attributes.ts b/telemetry/attributes.ts index 41ac4cd..f713853 100644 --- a/telemetry/attributes.ts +++ b/telemetry/attributes.ts @@ -14,6 +14,7 @@ import { URL } from "url"; export enum TelemetryAttribute { + FgaClientRequestBatchCheckSize = "fga-client.request.batch_check_size", FgaClientRequestClientId = "fga-client.request.client_id", FgaClientRequestMethod = "fga-client.request.method", FgaClientRequestModelId = "fga-client.request.model_id", @@ -121,6 +122,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/telemetry/configuration.ts b/telemetry/configuration.ts index 0df3948..ab93da1 100644 --- a/telemetry/configuration.ts +++ b/telemetry/configuration.ts @@ -72,6 +72,7 @@ export class TelemetryConfiguration implements TelemetryConfig { // This not included by default as it has a very high cardinality which could increase costs for users // TelemetryAttribute.FgaClientUser + // TelemetryAttribute.FgaClientRequestBatchCheckSize ]); /** @@ -97,6 +98,7 @@ export class TelemetryConfiguration implements TelemetryConfig { TelemetryAttribute.HttpClientRequestDuration, TelemetryAttribute.HttpServerRequestDuration, TelemetryAttribute.FgaClientUser, + TelemetryAttribute.FgaClientRequestBatchCheckSize, ]); /** diff --git a/tests/telemetry/attributes.test.ts b/tests/telemetry/attributes.test.ts index 33bfee5..9ff2355 100644 --- a/tests/telemetry/attributes.test.ts +++ b/tests/telemetry/attributes.test.ts @@ -106,4 +106,30 @@ 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); + }); }); From 407fbe27e683ab8fbc417e0a8c19694bae8696c8 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Tue, 7 Jan 2025 13:24:21 -0600 Subject: [PATCH 15/18] update from latest generator --- apiModel.ts | 7 ++++--- client.ts | 21 ++++++++++----------- example/example1/package.json | 2 +- example/opentelemetry/opentelemetry.mjs | 4 ++-- telemetry/attributes.ts | 2 +- telemetry/configuration.ts | 2 +- tests/client.test.ts | 11 +++++------ tests/telemetry/attributes.test.ts | 1 + 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apiModel.ts b/apiModel.ts index ea71374..984ea49 100644 --- a/apiModel.ts +++ b/apiModel.ts @@ -253,13 +253,13 @@ export interface CheckError { * @type {ErrorCode} * @memberof CheckError */ - inputError?: ErrorCode; + input_error?: ErrorCode; /** * * @type {InternalErrorCode} * @memberof CheckError */ - internalError?: InternalErrorCode; + internal_error?: InternalErrorCode; /** * * @type {string} @@ -585,7 +585,8 @@ export enum ErrorCode { DuplicateContextualTuple = 'duplicate_contextual_tuple', InvalidAuthorizationModel = 'invalid_authorization_model', UnsupportedSchemaVersion = 'unsupported_schema_version', - Cancelled = 'cancelled' + Cancelled = 'cancelled', + InvalidStartTime = 'invalid_start_time' } /** diff --git a/client.ts b/client.ts index 2840d57..c28f9bb 100644 --- a/client.ts +++ b/client.ts @@ -182,7 +182,6 @@ export type ClientBatchCheckSingleResponse = { error?: CheckError; } -// for server batch check export interface ClientBatchCheckResponse { responses: ClientBatchCheckSingleResponse[]; } @@ -664,11 +663,11 @@ export class OpenFgaClient extends BaseAPI { )) { responses.push(singleCheckResponse); } - return { responses }; } + private singleBatchCheck(body: BatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { return this.api.batchCheck(this.getStoreId(options)!, body, options); } @@ -699,23 +698,23 @@ export class OpenFgaClient extends BaseAPI { } = 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: { @@ -728,10 +727,10 @@ export class OpenFgaClient extends BaseAPI { 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[]) => { @@ -744,7 +743,7 @@ export class OpenFgaClient extends BaseAPI { 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) { @@ -761,9 +760,9 @@ export class OpenFgaClient extends BaseAPI { } } } - + return { responses: results }; - } + } /** * Expand - Expands the relationships in userset tree format (evaluates) diff --git a/example/example1/package.json b/example/example1/package.json index e1689a8..9835006 100644 --- a/example/example1/package.json +++ b/example/example1/package.json @@ -9,7 +9,7 @@ "start": "node example1.mjs" }, "dependencies": { - "@openfga/sdk": "file:../../" + "@openfga/sdk": "^0.7.0" }, "engines": { "node": ">=16.13.0" diff --git a/example/opentelemetry/opentelemetry.mjs b/example/opentelemetry/opentelemetry.mjs index 9f87527..2414f15 100644 --- a/example/opentelemetry/opentelemetry.mjs +++ b/example/opentelemetry/opentelemetry.mjs @@ -29,7 +29,6 @@ const telemetryConfig = { }, [TelemetryMetric.HistogramRequestDuration]: { attributes: new Set([ - TelemetryAttribute.FgaClientRequestBatchCheckSize, TelemetryAttribute.HttpResponseStatusCode, TelemetryAttribute.UserAgentOriginal, TelemetryAttribute.FgaClientRequestMethod, @@ -41,6 +40,7 @@ const telemetryConfig = { }, [TelemetryMetric.HistogramQueryDuration]: { attributes: new Set([ + TelemetryAttribute.FgaClientRequestBatchCheckSize, TelemetryAttribute.HttpResponseStatusCode, TelemetryAttribute.UserAgentOriginal, TelemetryAttribute.FgaClientRequestMethod, @@ -127,7 +127,7 @@ async function main () { }, { authorizationModelId: "01JC6KPJ0CKSZ69C5Z26CYWX2N" }); - + console.log("writing tuple"); await fgaClient.write({ writes: [ diff --git a/telemetry/attributes.ts b/telemetry/attributes.ts index f713853..7da58c7 100644 --- a/telemetry/attributes.ts +++ b/telemetry/attributes.ts @@ -14,7 +14,6 @@ import { URL } from "url"; export enum TelemetryAttribute { - FgaClientRequestBatchCheckSize = "fga-client.request.batch_check_size", FgaClientRequestClientId = "fga-client.request.client_id", FgaClientRequestMethod = "fga-client.request.method", FgaClientRequestModelId = "fga-client.request.model_id", @@ -22,6 +21,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", diff --git a/telemetry/configuration.ts b/telemetry/configuration.ts index ab93da1..5de796b 100644 --- a/telemetry/configuration.ts +++ b/telemetry/configuration.ts @@ -71,7 +71,7 @@ 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 ]); diff --git a/tests/client.test.ts b/tests/client.test.ts index 7e002da..bd88ab7 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -540,7 +540,6 @@ describe("OpenFGA Client", () => { .reply(200, { authorization_models: [], }); - expect(scope0.isDone()).toBe(false); expect(scope1.isDone()).toBe(false); expect(scope2.isDone()).toBe(false); @@ -634,7 +633,7 @@ describe("OpenFGA Client", () => { authorizationModelId: "01GAHCE4YVKPQEKZQHT2R89MQV", consistency: ConsistencyPreference.HigherConsistency, }); - + expect(scope.isDone()).toBe(true); expect(response.responses).toHaveLength(2); expect(response.responses[0].allowed).toBe(true); @@ -658,7 +657,7 @@ describe("OpenFGA Client", () => { "cor-3": { allowed: false, error: { - inputError: ErrorCode.RelationNotFound, + input_error: ErrorCode.RelationNotFound, message: "relation not found", } } @@ -707,7 +706,7 @@ describe("OpenFGA Client", () => { consistency: ConsistencyPreference.HigherConsistency, maxBatchSize: 2, }); - + expect(scope0.isDone()).toBe(true); expect(scope1.isDone()).toBe(true); expect(response.responses).toHaveLength(3); @@ -720,7 +719,7 @@ describe("OpenFGA Client", () => { 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"); @@ -731,7 +730,7 @@ describe("OpenFGA Client", () => { expect(resp2?.request.relation).toBe("can_view"); expect(resp2?.request.object).toBe("document:9992ab2a-d83f-756d-9397-c5ed9f3cj8a4"); - expect(resp2?.error?.inputError).toBe(ErrorCode.RelationNotFound); + 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 () => { diff --git a/tests/telemetry/attributes.test.ts b/tests/telemetry/attributes.test.ts index 9ff2355..8116207 100644 --- a/tests/telemetry/attributes.test.ts +++ b/tests/telemetry/attributes.test.ts @@ -107,6 +107,7 @@ describe("TelemetryAttributes", () => { expect(attributes[TelemetryAttribute.FgaClientUser]).toBeUndefined(); }); + test("should create attributes from a batchCheck request body correctly", () => { const body = { authorization_model_id: "model-id", From b68e18e66957da7972cd224ba7563cd380a32c82 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Tue, 7 Jan 2025 13:30:43 -0600 Subject: [PATCH 16/18] update unreleased CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648e386..e6685fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 0d365435898a7e159213f80ab292baa4490d3850 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Fri, 10 Jan 2025 12:13:17 -0600 Subject: [PATCH 17/18] Update response for batchCheck and clientBatchCheck --- client.ts | 16 ++++++++-------- example/example1/example1.mjs | 4 ++-- example/opentelemetry/opentelemetry.mjs | 2 +- tests/client.test.ts | 20 ++++++++++---------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/client.ts b/client.ts index c28f9bb..6faf8e3 100644 --- a/client.ts +++ b/client.ts @@ -145,7 +145,7 @@ export type ClientBatchCheckSingleClientResponse = { }); export interface ClientBatchCheckClientResponse { - responses: ClientBatchCheckSingleClientResponse[]; + result: ClientBatchCheckSingleClientResponse[]; } export interface ClientBatchCheckClientRequestOpts { @@ -183,7 +183,7 @@ export type ClientBatchCheckSingleResponse = { } export interface ClientBatchCheckResponse { - responses: ClientBatchCheckSingleResponse[]; + result: ClientBatchCheckSingleResponse[]; } export interface ClientWriteRequestOpts { @@ -643,7 +643,7 @@ export class OpenFgaClient extends BaseAPI { setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "ClientBatchCheck"); setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); - const responses: ClientBatchCheckSingleClientResponse[] = []; + const result: ClientBatchCheckSingleClientResponse[] = []; for await (const singleCheckResponse of asyncPool(maxParallelRequests, body, (tuple) => this.check(tuple, { ...options, headers }) .then(response => { (response as ClientBatchCheckSingleClientResponse)._request = tuple; @@ -661,9 +661,9 @@ export class OpenFgaClient extends BaseAPI { }; }) )) { - responses.push(singleCheckResponse); + result.push(singleCheckResponse); } - return { responses }; + return { result }; } @@ -761,7 +761,7 @@ export class OpenFgaClient extends BaseAPI { } } - return { responses: results }; + return { result: results }; } /** @@ -836,12 +836,12 @@ export class OpenFgaClient extends BaseAPI { 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) }; } /** diff --git a/example/example1/example1.mjs b/example/example1/example1.mjs index 521040e..dae177f 100644 --- a/example/example1/example1.mjs +++ b/example/example1/example1.mjs @@ -188,7 +188,7 @@ async function main () { // execute a batch check const anneCorrelationId = randomUUID(); - const { responses } = await fgaClient.batchCheck({ + const { result } = await fgaClient.batchCheck({ checks: [ { // should have access @@ -209,7 +209,7 @@ async function main () { ] }); - const anneAllowed = responses.filter(r => r.correlationId === anneCorrelationId); + 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}`); diff --git a/example/opentelemetry/opentelemetry.mjs b/example/opentelemetry/opentelemetry.mjs index 2414f15..c29fbe4 100644 --- a/example/opentelemetry/opentelemetry.mjs +++ b/example/opentelemetry/opentelemetry.mjs @@ -101,7 +101,7 @@ async function main () { } console.log("Calling BatcCheck") - const { responses } = await fgaClient.batchCheck({ + await fgaClient.batchCheck({ checks: [ { object: "doc:roadmap", diff --git a/tests/client.test.ts b/tests/client.test.ts index bd88ab7..5e96114 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -549,8 +549,8 @@ describe("OpenFGA Client", () => { expect(scope1.isDone()).toBe(true); expect(scope2.isDone()).toBe(true); expect(scope3.isDone()).toBe(false); - expect(response.responses.length).toBe(3); - expect(response.responses.sort((a, b) => String(a._request.object).localeCompare(b._request.object))) + expect(response.result.length).toBe(3); + expect(response.result.sort((a, b) => String(a._request.object).localeCompare(b._request.object))) .toMatchObject(expect.arrayContaining([ { _request: tuples[0], allowed: true, }, { _request: tuples[1], allowed: false }, @@ -585,7 +585,7 @@ describe("OpenFGA Client", () => { const response = await fgaClient.batchCheck({ checks: [], }); - expect(response.responses.length).toBe(0); + expect(response.result.length).toBe(0); }); it("should handle single batch successfully", async () => { const mockedResponse = { @@ -635,9 +635,9 @@ describe("OpenFGA Client", () => { }); expect(scope.isDone()).toBe(true); - expect(response.responses).toHaveLength(2); - expect(response.responses[0].allowed).toBe(true); - expect(response.responses[1].allowed).toBe(false); + expect(response.result).toHaveLength(2); + expect(response.result[0].allowed).toBe(true); + expect(response.result[1].allowed).toBe(false); }); it("should split batches successfully", async () => { const mockedResponse0 = { @@ -709,11 +709,11 @@ describe("OpenFGA Client", () => { expect(scope0.isDone()).toBe(true); expect(scope1.isDone()).toBe(true); - expect(response.responses).toHaveLength(3); + expect(response.result).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"); + const resp0 = response.result.find(r => r.correlationId === "cor-1"); + const resp1 = response.result.find(r => r.correlationId === "cor-2"); + const resp2 = response.result.find(r => r.correlationId === "cor-3"); expect(resp0?.allowed).toBe(true); expect(resp0?.request.user).toBe("user:81684243-9356-4421-8fbf-a4f8d36aa31b"); From 55a053f20b770809659ca54bbc84f521d41655c3 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Mon, 13 Jan 2025 08:44:09 -0600 Subject: [PATCH 18/18] update CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6685fa..718e71e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ 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`. +- 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