From f09145448570446b1a53cf2e722d6c1cff8f1c8b Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Tue, 31 Mar 2026 08:43:19 -0400 Subject: [PATCH 1/6] feat!: replace axios with native fetch for cross-platform support The global fetch API is built into Node 20+, browsers, Deno, Cloudflare Workers, and Vercel Edge. On modern versions of node taht use undici internally, this should result in improved perfomance. BREAKING CHANGE: The `$response` property type changes from `AxiosResponse` to `FgaResponse`. The constructor now accepts an optional `HttpClient` instead of `AxiosInstance`. `baseOptions.httpAgent`/`httpsAgent` are no longer applicable as fetch handles connection pooling natively. --- api.ts | 130 +++++----- base.ts | 25 +- client.ts | 20 +- common.ts | 379 ++++++++++++++++++++-------- credentials/credentials.ts | 16 +- errors.ts | 118 +++++---- index.ts | 1 + package.json | 1 - tests/errors-authentication.test.ts | 37 ++- tests/index.test.ts | 20 +- 10 files changed, 457 insertions(+), 290 deletions(-) diff --git a/api.ts b/api.ts index 8f4da47a..dee807d1 100644 --- a/api.ts +++ b/api.ts @@ -11,8 +11,6 @@ */ -import globalAxios, { AxiosInstance } from "axios"; - import { BaseAPI } from "./base"; import { DUMMY_BASE_URL, @@ -24,6 +22,8 @@ import { RequestArgs, CallResult, PromiseResult, + HttpClient, + globalHttpClient, HttpMethod, RequestBuilderParams, RequestBuilderOptions, @@ -140,7 +140,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async batchCheck(storeId: string, body: BatchCheckRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async batchCheck(storeId: string, body: BatchCheckRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("batchCheck", "storeId", storeId); assertParamExists("batchCheck", "body", body); return api.executeApiRequest({ @@ -159,7 +159,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async check(storeId: string, body: CheckRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async check(storeId: string, body: CheckRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("check", "storeId", storeId); assertParamExists("check", "body", body); return api.executeApiRequest({ @@ -177,7 +177,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async createStore(body: CreateStoreRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async createStore(body: CreateStoreRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("createStore", "body", body); return api.executeApiRequest({ operationName: "CreateStore", @@ -193,7 +193,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async deleteStore(storeId: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async deleteStore(storeId: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("deleteStore", "storeId", storeId); return api.executeApiRequest({ operationName: "DeleteStore", @@ -210,7 +210,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async expand(storeId: string, body: ExpandRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async expand(storeId: string, body: ExpandRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("expand", "storeId", storeId); assertParamExists("expand", "body", body); return api.executeApiRequest({ @@ -228,7 +228,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async getStore(storeId: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async getStore(storeId: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("getStore", "storeId", storeId); return api.executeApiRequest({ operationName: "GetStore", @@ -245,7 +245,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async listObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async listObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("listObjects", "storeId", storeId); assertParamExists("listObjects", "body", body); return api.executeApiRequest({ @@ -266,7 +266,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async streamedListObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise<(axios?: AxiosInstance) => Promise> { + async streamedListObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise<(httpClient?: HttpClient) => Promise> { assertParamExists("streamedListObjects", "storeId", storeId); assertParamExists("streamedListObjects", "body", body); return api.executeStreamedApiRequest({ @@ -286,7 +286,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async listStores(pageSize?: number, continuationToken?: string, name?: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async listStores(pageSize?: number, continuationToken?: string, name?: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { return api.executeApiRequest({ operationName: "ListStores", method: "GET", @@ -302,7 +302,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async listUsers(storeId: string, body: ListUsersRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async listUsers(storeId: string, body: ListUsersRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("listUsers", "storeId", storeId); assertParamExists("listUsers", "body", body); return api.executeApiRequest({ @@ -321,7 +321,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async read(storeId: string, body: ReadRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async read(storeId: string, body: ReadRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("read", "storeId", storeId); assertParamExists("read", "body", body); return api.executeApiRequest({ @@ -340,7 +340,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async readAssertions(storeId: string, authorizationModelId: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async readAssertions(storeId: string, authorizationModelId: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("readAssertions", "storeId", storeId); assertParamExists("readAssertions", "authorizationModelId", authorizationModelId); return api.executeApiRequest({ @@ -358,7 +358,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async readAuthorizationModel(storeId: string, id: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async readAuthorizationModel(storeId: string, id: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("readAuthorizationModel", "storeId", storeId); assertParamExists("readAuthorizationModel", "id", id); return api.executeApiRequest({ @@ -377,7 +377,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("readAuthorizationModels", "storeId", storeId); return api.executeApiRequest({ operationName: "ReadAuthorizationModels", @@ -398,7 +398,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("readChanges", "storeId", storeId); return api.executeApiRequest({ operationName: "ReadChanges", @@ -416,7 +416,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async write(storeId: string, body: WriteRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async write(storeId: string, body: WriteRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("write", "storeId", storeId); assertParamExists("write", "body", body); return api.executeApiRequest({ @@ -436,7 +436,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("writeAssertions", "storeId", storeId); assertParamExists("writeAssertions", "authorizationModelId", authorizationModelId); assertParamExists("writeAssertions", "body", body); @@ -456,7 +456,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + async writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("writeAuthorizationModel", "storeId", storeId); assertParamExists("writeAuthorizationModel", "body", body); return api.executeApiRequest({ @@ -476,17 +476,17 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {RequestBuilderOptions} [options] Override http request option. * @throws { FgaError } */ - async executeApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise<(axios?: AxiosInstance) => PromiseResult> { + async executeApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { const localVarAxiosArgs = RequestBuilder(request, { ...configuration.baseOptions, ...options }); - return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { + return createRequestFunction(localVarAxiosArgs, globalHttpClient, configuration, credentials, { [TelemetryAttribute.FgaClientRequestMethod]: request.operationName, [TelemetryAttribute.FgaClientRequestStoreId]: request.pathParams?.["store_id"] ?? "", ...TelemetryAttributes.fromRequestBody(request.body), }); }, - async executeStreamedApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise<(axios?: AxiosInstance) => PromiseResult> { + async executeStreamedApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { const localVarAxiosArgs = RequestBuilder(request, { ...configuration.baseOptions, ...options }); - return createStreamingRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { + return createStreamingRequestFunction(localVarAxiosArgs, globalHttpClient, configuration, credentials, { [TelemetryAttribute.FgaClientRequestMethod]: request.operationName, [TelemetryAttribute.FgaClientRequestStoreId]: request.pathParams?.["store_id"] ?? "", ...TelemetryAttributes.fromRequestBody(request.body), @@ -500,7 +500,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * OpenFgaApi - factory interface * @export */ -export const OpenFgaApiFactory = function (configuration: Configuration, credentials: Credentials, axios?: AxiosInstance) { +export const OpenFgaApiFactory = function (configuration: Configuration, credentials: Credentials, httpClient?: HttpClient) { const localVarFp = OpenFgaApiFp(configuration, credentials); return { /** @@ -512,7 +512,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ batchCheck(storeId: string, body: BatchCheckRequest, options?: any): PromiseResult { - return localVarFp.batchCheck(storeId, body, options).then((request) => request(axios)); + return localVarFp.batchCheck(storeId, body, options).then((request) => request(httpClient)); }, /** * 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\" } ``` @@ -523,7 +523,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ check(storeId: string, body: CheckRequest, options?: any): PromiseResult { - return localVarFp.check(storeId, body, options).then((request) => request(axios)); + return localVarFp.check(storeId, body, options).then((request) => request(httpClient)); }, /** * Create a unique OpenFGA store which will be used to store authorization models and relationship tuples. @@ -533,7 +533,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ createStore(body: CreateStoreRequest, options?: any): PromiseResult { - return localVarFp.createStore(body, options).then((request) => request(axios)); + return localVarFp.createStore(body, options).then((request) => request(httpClient)); }, /** * Delete an OpenFGA store. This does not delete the data associated with the store, like tuples or authorization models. @@ -543,7 +543,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ deleteStore(storeId: string, options?: any): PromiseResult { - return localVarFp.deleteStore(storeId, options).then((request) => request(axios)); + return localVarFp.deleteStore(storeId, options).then((request) => request(httpClient)); }, /** * The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. 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`. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA\'s response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. ### Expand Request with Contextual Tuples Given the model ```python model schema 1.1 type user type folder relations define owner: [user] type document relations define parent: [folder] define viewer: [user] or writer define writer: [user] or owner from parent ``` and the initial tuples ```json [{ \"user\": \"user:bob\", \"relation\": \"owner\", \"object\": \"folder:1\" }] ``` To expand all `writers` of `document:1` when `document:1` is put in `folder:1`, the first call could be ```json { \"tuple_key\": { \"object\": \"document:1\", \"relation\": \"writer\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"folder:1\", \"relation\": \"parent\", \"object\": \"document:1\" } ] } } ``` this returns: ```json { \"tree\": { \"root\": { \"name\": \"document:1#writer\", \"union\": { \"nodes\": [ { \"name\": \"document:1#writer\", \"leaf\": { \"users\": { \"users\": [] } } }, { \"name\": \"document:1#writer\", \"leaf\": { \"tupleToUserset\": { \"tupleset\": \"document:1#parent\", \"computed\": [ { \"userset\": \"folder:1#owner\" } ] } } } ] } } } } ``` This tells us that the `owner` of `folder:1` may also be a writer. So our next call could be to find the `owners` of `folder:1` ```json { \"tuple_key\": { \"object\": \"folder:1\", \"relation\": \"owner\" } } ``` which gives ```json { \"tree\": { \"root\": { \"name\": \"folder:1#owner\", \"leaf\": { \"users\": { \"users\": [ \"user:bob\" ] } } } } } ``` @@ -554,7 +554,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ expand(storeId: string, body: ExpandRequest, options?: any): PromiseResult { - return localVarFp.expand(storeId, body, options).then((request) => request(axios)); + return localVarFp.expand(storeId, body, options).then((request) => request(httpClient)); }, /** * Returns an OpenFGA store by its identifier @@ -564,7 +564,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ getStore(storeId: string, options?: any): PromiseResult { - return localVarFp.getStore(storeId, options).then((request) => request(axios)); + return localVarFp.getStore(storeId, options).then((request) => request(httpClient)); }, /** * The ListObjects API returns a list of all the objects of the given type that the user has a relation with. 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`). An `authorization_model_id` may be specified in the body. If it is not specified, the latest authorization model ID will be used. It is strongly recommended to specify authorization model id for better performance. You may also specify `contextual_tuples` that will be treated as regular tuples. Each of these tuples may have an associated `condition`. 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 contain the related objects in an array in the \"objects\" field of the response and they will be strings in the object format `:` (e.g. \"document:roadmap\"). The number of objects in the response array will be limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE and by the upper bound specified in the flag OPENFGA_LIST_OBJECTS_MAX_RESULTS, whichever is hit first. The objects given will not be sorted, and therefore two identical calls can give a given different set of objects. @@ -575,7 +575,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ listObjects(storeId: string, body: ListObjectsRequest, options?: any): PromiseResult { - return localVarFp.listObjects(storeId, body, options).then((request) => request(axios)); + return localVarFp.listObjects(storeId, body, options).then((request) => request(httpClient)); }, /** * The Streamed ListObjects API is very similar to the ListObjects API, with two differences: @@ -588,7 +588,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ streamedListObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise { - return localVarFp.streamedListObjects(storeId, body, options).then((request) => request(axios)); + return localVarFp.streamedListObjects(storeId, body, options).then((request) => request(httpClient)); }, /** * Returns a paginated list of OpenFGA stores and a continuation token to get additional stores. The continuation token will be empty if there are no more stores. @@ -600,7 +600,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ listStores(pageSize?: number, continuationToken?: string, name?: string, options?: any): PromiseResult { - return localVarFp.listStores(pageSize, continuationToken, name, options).then((request) => request(axios)); + return localVarFp.listStores(pageSize, continuationToken, name, options).then((request) => request(httpClient)); }, /** * The ListUsers API returns a list of all the users of a specific type that have a relation to a given object. 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`). An `authorization_model_id` may be specified in the body. If it is not specified, the latest authorization model ID will be used. It is strongly recommended to specify authorization model id for better performance. You may also specify `contextual_tuples` that will be treated as regular tuples. Each of these tuples may have an associated `condition`. 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. The response will contain the related users in an array in the \"users\" field of the response. These results may include specific objects, usersets or type-bound public access. Each of these types of results is encoded in its own type and not represented as a string.In cases where a type-bound public access result is returned (e.g. `user:*`), it cannot be inferred that all subjects of that type have a relation to the object; it is possible that negations exist and checks should still be queried on individual subjects to ensure access to that document.The number of users in the response array will be limited by the execution timeout specified in the flag OPENFGA_LIST_USERS_DEADLINE and by the upper bound specified in the flag OPENFGA_LIST_USERS_MAX_RESULTS, whichever is hit first. The returned users will not be sorted, and therefore two identical calls may yield different sets of users. @@ -611,7 +611,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ listUsers(storeId: string, body: ListUsersRequest, options?: any): PromiseResult { - return localVarFp.listUsers(storeId, body, options).then((request) => request(axios)); + return localVarFp.listUsers(storeId, body, options).then((request) => request(httpClient)); }, /** * The Read API will return the tuples for a certain store that match a query filter specified in the body of the request. The API doesn\'t guarantee order by any field. It is different from the `/stores/{store_id}/expand` API in that it only returns relationship tuples that are stored in the system and satisfy the query. In the body: 1. `tuple_key` is optional. If not specified, it will return all tuples in the store. 2. `tuple_key.object` is mandatory if `tuple_key` is specified. It can be a full object (e.g., `type:object_id`) or type only (e.g., `type:`). 3. `tuple_key.user` is mandatory if tuple_key is specified in the case the `tuple_key.object` is a type only. If tuple_key.user is specified, it needs to be a full object (e.g., `type:user_id`). ## Examples ### Query for all objects in a type definition To query for all objects that `user:bob` has `reader` relationship in the `document` type definition, call read API with body of ```json { \"tuple_key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:\" } } ``` The API will return tuples and a continuation token, something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `user:bob` has a `reader` relationship with 1 document `document:2021-budget`. Note that this API, unlike the List Objects API, does not evaluate the tuples in the store. The continuation token will be empty if there are no more tuples to query. ### Query for all stored relationship tuples that have a particular relation and object To query for all users that have `reader` relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`). Note that, even if the model said that all `writers` are also `readers`, the API will not return writers such as `user:anne` because it only returns tuples and does not evaluate them. ### Query for all users with all relationships for a particular document To query for all users that have any relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-05T13:42:12.356Z\" }, { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`) and 1 `writer` (`user:anne`). @@ -622,7 +622,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ read(storeId: string, body: ReadRequest, options?: any): PromiseResult { - return localVarFp.read(storeId, body, options).then((request) => request(axios)); + return localVarFp.read(storeId, body, options).then((request) => request(httpClient)); }, /** * The ReadAssertions API will return, for a given authorization model id, all the assertions stored for it. @@ -633,7 +633,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ readAssertions(storeId: string, authorizationModelId: string, options?: any): PromiseResult { - return localVarFp.readAssertions(storeId, authorizationModelId, options).then((request) => request(axios)); + return localVarFp.readAssertions(storeId, authorizationModelId, options).then((request) => request(httpClient)); }, /** * The ReadAuthorizationModel API returns an authorization model by its identifier. The response will return the authorization model for the particular version. ## Example To retrieve the authorization model with ID `01G5JAVJ41T49E9TT3SKVS7X1J` for the store, call the GET authorization-models by ID API with `01G5JAVJ41T49E9TT3SKVS7X1J` as the `id` path parameter. The API will return: ```json { \"authorization_model\":{ \"id\":\"01G5JAVJ41T49E9TT3SKVS7X1J\", \"type_definitions\":[ { \"type\":\"user\" }, { \"type\":\"document\", \"relations\":{ \"reader\":{ \"union\":{ \"child\":[ { \"this\":{} }, { \"computedUserset\":{ \"object\":\"\", \"relation\":\"writer\" } } ] } }, \"writer\":{ \"this\":{} } } } ] } } ``` In the above example, there are 2 types (`user` and `document`). The `document` type has 2 relations (`writer` and `reader`). @@ -644,7 +644,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ readAuthorizationModel(storeId: string, id: string, options?: any): PromiseResult { - return localVarFp.readAuthorizationModel(storeId, id, options).then((request) => request(axios)); + return localVarFp.readAuthorizationModel(storeId, id, options).then((request) => request(httpClient)); }, /** * The ReadAuthorizationModels API will return all the authorization models for a certain store. OpenFGA\'s response will contain an array of all authorization models, sorted in descending order of creation. ## Example Assume that a store\'s authorization model has been configured twice. To get all the authorization models that have been created in this store, call GET authorization-models. The API will return a response that looks like: ```json { \"authorization_models\": [ { \"id\": \"01G50QVV17PECNVAHX1GG4Y5NC\", \"type_definitions\": [...] }, { \"id\": \"01G4ZW8F4A07AKQ8RHSVG9RW04\", \"type_definitions\": [...] }, ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` If there are no more authorization models available, the `continuation_token` field will be empty ```json { \"authorization_models\": [ { \"id\": \"01G50QVV17PECNVAHX1GG4Y5NC\", \"type_definitions\": [...] }, { \"id\": \"01G4ZW8F4A07AKQ8RHSVG9RW04\", \"type_definitions\": [...] }, ], \"continuation_token\": \"\" } ``` @@ -656,7 +656,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: any): PromiseResult { - return localVarFp.readAuthorizationModels(storeId, pageSize, continuationToken, options).then((request) => request(axios)); + return localVarFp.readAuthorizationModels(storeId, pageSize, continuationToken, options).then((request) => request(httpClient)); }, /** * The ReadChanges API will return a paginated list of tuple changes (additions and deletions) that occurred in a given store, sorted by ascending time. The response will include a continuation token that is used to get the next set of changes. If there are no changes after the provided continuation token, the same token will be returned in order for it to be used when new changes are recorded. If the store never had any tuples added or removed, this token will be empty. You can use the `type` parameter to only get the list of tuple changes that affect objects of that type. When reading a write tuple change, if it was conditioned, the condition will be returned. When reading a delete tuple change, the condition will NOT be returned regardless of whether it was originally conditioned or not. @@ -670,7 +670,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: any): PromiseResult { - return localVarFp.readChanges(storeId, type, pageSize, continuationToken, startTime, options).then((request) => request(axios)); + return localVarFp.readChanges(storeId, type, pageSize, continuationToken, startTime, options).then((request) => request(httpClient)); }, /** * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` @@ -681,7 +681,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ write(storeId: string, body: WriteRequest, options?: any): PromiseResult { - return localVarFp.write(storeId, body, options).then((request) => request(axios)); + return localVarFp.write(storeId, body, options).then((request) => request(httpClient)); }, /** * The WriteAssertions API will upsert new assertions for an authorization model id, or overwrite the existing ones. An assertion is an object that contains a tuple key, the expectation of whether a call to the Check API of that tuple key will return true or false, and optionally a list of contextual tuples. @@ -693,7 +693,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: any): PromiseResult { - return localVarFp.writeAssertions(storeId, authorizationModelId, body, options).then((request) => request(axios)); + return localVarFp.writeAssertions(storeId, authorizationModelId, body, options).then((request) => request(httpClient)); }, /** * The WriteAuthorizationModel API will add a new authorization model to a store. Each item in the `type_definitions` array is a type definition as specified in the field `type_definition`. The response will return the authorization model\'s ID in the `id` field. ## Example To add an authorization model with `user` and `document` type definitions, call POST authorization-models API with the body: ```json { \"type_definitions\":[ { \"type\":\"user\" }, { \"type\":\"document\", \"relations\":{ \"reader\":{ \"union\":{ \"child\":[ { \"this\":{} }, { \"computedUserset\":{ \"object\":\"\", \"relation\":\"writer\" } } ] } }, \"writer\":{ \"this\":{} } } } ] } ``` OpenFGA\'s response will include the version id for this authorization model, which will look like ``` {\"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\"} ``` @@ -704,7 +704,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): PromiseResult { - return localVarFp.writeAuthorizationModel(storeId, body, options).then((request) => request(axios)); + return localVarFp.writeAuthorizationModel(storeId, body, options).then((request) => request(httpClient)); }, /** * Make a raw HTTP request to an arbitrary API endpoint. @@ -716,10 +716,10 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @throws { FgaError } */ executeApiRequest(request: RequestBuilderParams, options?: any): PromiseResult { - return localVarFp.executeApiRequest(request, options).then((request) => request(axios)); + return localVarFp.executeApiRequest(request, options).then((request) => request(httpClient)); }, executeStreamedApiRequest(request: RequestBuilderParams, options?: any): Promise { - return localVarFp.executeStreamedApiRequest(request, options).then((request) => request(axios)); + return localVarFp.executeStreamedApiRequest(request, options).then((request) => request(httpClient)); }, }; }; @@ -741,7 +741,7 @@ export class OpenFgaApi extends BaseAPI { * @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)); + return OpenFgaApiFp(this.configuration, this.credentials).batchCheck(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -754,7 +754,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public check(storeId: string, body: CheckRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).check(storeId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).check(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -766,7 +766,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public createStore(body: CreateStoreRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).createStore(body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).createStore(body, options).then((request) => request(this.httpClient)); } /** @@ -778,7 +778,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public deleteStore(storeId: string, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).deleteStore(storeId, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).deleteStore(storeId, options).then((request) => request(this.httpClient)); } /** @@ -791,7 +791,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public expand(storeId: string, body: ExpandRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).expand(storeId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).expand(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -803,7 +803,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public getStore(storeId: string, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).getStore(storeId, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).getStore(storeId, options).then((request) => request(this.httpClient)); } /** @@ -816,7 +816,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public listObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).listObjects(storeId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).listObjects(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -830,7 +830,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } */ public streamedListObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise { - return OpenFgaApiFp(this.configuration, this.credentials).streamedListObjects(storeId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).streamedListObjects(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -844,7 +844,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public listStores(pageSize?: number, continuationToken?: string, name?: string, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).listStores(pageSize, continuationToken, name, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).listStores(pageSize, continuationToken, name, options).then((request) => request(this.httpClient)); } /** @@ -857,7 +857,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public listUsers(storeId: string, body: ListUsersRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).listUsers(storeId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).listUsers(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -870,7 +870,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public read(storeId: string, body: ReadRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).read(storeId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).read(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -883,7 +883,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public readAssertions(storeId: string, authorizationModelId: string, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).readAssertions(storeId, authorizationModelId, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).readAssertions(storeId, authorizationModelId, options).then((request) => request(this.httpClient)); } /** @@ -896,7 +896,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public readAuthorizationModel(storeId: string, id: string, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).readAuthorizationModel(storeId, id, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).readAuthorizationModel(storeId, id, options).then((request) => request(this.httpClient)); } /** @@ -910,7 +910,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).readAuthorizationModels(storeId, pageSize, continuationToken, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).readAuthorizationModels(storeId, pageSize, continuationToken, options).then((request) => request(this.httpClient)); } /** @@ -926,7 +926,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).readChanges(storeId, type, pageSize, continuationToken, startTime, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).readChanges(storeId, type, pageSize, continuationToken, startTime, options).then((request) => request(this.httpClient)); } /** @@ -939,7 +939,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public write(storeId: string, body: WriteRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).write(storeId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).write(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -953,7 +953,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).writeAssertions(storeId, authorizationModelId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).writeAssertions(storeId, authorizationModelId, body, options).then((request) => request(this.httpClient)); } /** @@ -966,7 +966,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).writeAuthorizationModel(storeId, body, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).writeAuthorizationModel(storeId, body, options).then((request) => request(this.httpClient)); } /** @@ -980,7 +980,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public executeApiRequest(request: RequestBuilderParams, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).executeApiRequest(request, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).executeApiRequest(request, options).then((request) => request(this.httpClient)); } /** @@ -994,7 +994,7 @@ export class OpenFgaApi extends BaseAPI { * @memberof OpenFgaApi */ public executeStreamedApiRequest(request: RequestBuilderParams, options?: any): Promise> { - return OpenFgaApiFp(this.configuration, this.credentials).executeStreamedApiRequest(request, options).then((request) => request(this.axios)); + return OpenFgaApiFp(this.configuration, this.credentials).executeStreamedApiRequest(request, options).then((request) => request(this.httpClient)); } } diff --git a/base.ts b/base.ts index ed01083b..ed6525d6 100644 --- a/base.ts +++ b/base.ts @@ -1,8 +1,5 @@ -import globalAxios, { AxiosInstance } from "axios"; -import * as http from "http"; -import * as https from "https"; - import { Configuration, UserConfigurationParams } from "./configuration"; +import { HttpClient } from "./common"; import { Credentials } from "./credentials"; const DEFAULT_CONNECTION_TIMEOUT_IN_MS = 10000; @@ -25,8 +22,9 @@ export interface RequestArgs { export class BaseAPI { protected configuration: Configuration; protected credentials: Credentials; + protected httpClient: HttpClient; - constructor(configuration: UserConfigurationParams | Configuration, protected axios?: AxiosInstance) { + constructor(configuration: UserConfigurationParams | Configuration, httpClient?: HttpClient) { if (configuration instanceof Configuration) { this.configuration = configuration; } else { @@ -34,17 +32,12 @@ export class BaseAPI { } this.configuration.isValid(); - this.credentials = Credentials.init(this.configuration, this.axios); + this.httpClient = httpClient ?? { + fetch: globalThis.fetch.bind(globalThis), + defaultTimeout: DEFAULT_CONNECTION_TIMEOUT_IN_MS, + defaultHeaders: this.configuration.baseOptions?.headers, + }; - if (!this.axios) { - const httpAgent = new http.Agent({ keepAlive: true }); - const httpsAgent = new https.Agent({ keepAlive: true }); - this.axios = globalAxios.create({ - httpAgent, - httpsAgent, - timeout: DEFAULT_CONNECTION_TIMEOUT_IN_MS, - headers: this.configuration.baseOptions?.headers, - }); - } + this.credentials = Credentials.init(this.configuration, this.httpClient); } } diff --git a/client.ts b/client.ts index a486862d..3597a982 100644 --- a/client.ts +++ b/client.ts @@ -1,7 +1,7 @@ -import { AxiosResponse, AxiosInstance } from "axios"; import asyncPool = require("tiny-async-pool"); import { OpenFgaApi, HttpMethod, RequestBuilderParams, RequestBuilderOptions } from "./api"; +import type { FgaResponse, HttpClient } from "./common"; export type { HttpMethod, RequestBuilderParams, RequestBuilderOptions }; import { Assertion, @@ -132,7 +132,7 @@ export type ClientBatchCheckSingleClientResponse = { _request: ClientCheckRequest; } & ({ allowed: boolean; - $response: AxiosResponse; + $response: FgaResponse; } | { allowed: undefined; error: Error; @@ -268,8 +268,8 @@ export class OpenFgaClient extends BaseAPI { public storeId?: string; protected configuration: ClientConfiguration; - constructor(configuration: ClientConfiguration | UserClientConfigurationParams, protected axios?: AxiosInstance) { - super(configuration, axios); + constructor(configuration: ClientConfiguration | UserClientConfigurationParams, httpClient?: HttpClient) { + super(configuration, httpClient); if (configuration instanceof ClientConfiguration) { this.configuration = configuration; @@ -277,7 +277,7 @@ export class OpenFgaClient extends BaseAPI { this.configuration = new ClientConfiguration(configuration); } this.configuration.isValid(); - this.api = new OpenFgaApi(this.configuration, axios); + this.api = new OpenFgaApi(this.configuration, httpClient); this.storeId = configuration.storeId; this.authorizationModelId = configuration.authorizationModelId; } @@ -854,7 +854,7 @@ export class OpenFgaClient extends BaseAPI { /** * StreamedListObjects - Stream all objects of a particular type that the user has a certain relation to (evaluates) * - * Note: This method is Node.js only. Streams are supported via the axios API layer. + * Note: This method requires a runtime with ReadableStream support (Node.js 20+, browsers, Deno, etc.). * The response will be streamed as newline-delimited JSON objects. * * @param {ClientListObjectsRequest} body @@ -878,10 +878,10 @@ export class OpenFgaClient extends BaseAPI { consistency: options.consistency }, options); - // Unwrap axios CallResult to get the raw Node.js stream when needed + // The streaming function returns a ReadableStream (fetch response.body) const source = stream?.$response?.data ?? stream; - // Parse the Node.js stream + // Parse the stream (ReadableStream is AsyncIterable in Node 20+) try { for await (const item of parseNDJSONStream(source as any)) { if (item && item.result && item.result.object) { @@ -890,7 +890,9 @@ export class OpenFgaClient extends BaseAPI { } } finally { // Ensure underlying HTTP connection closes if consumer stops early - if (source && typeof source.destroy === "function") { + if (source && typeof (source as ReadableStream).cancel === "function") { + try { (source as ReadableStream).cancel(); } catch { } + } else if (source && typeof source.destroy === "function") { try { source.destroy(); } catch { } } } diff --git a/common.ts b/common.ts index a0bd1ee1..88088677 100644 --- a/common.ts +++ b/common.ts @@ -1,5 +1,3 @@ -import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; - import { Configuration } from "./configuration"; import SdkConstants from "./constants"; import type { Credentials } from "./credentials"; @@ -12,6 +10,7 @@ import { FgaApiValidationError, FgaError, FgaValidationError, + HttpErrorContext, } from "./errors"; import { setNotEnumerableProperty } from "./utils"; import { TelemetryAttribute, TelemetryAttributes } from "./telemetry/attributes"; @@ -34,6 +33,35 @@ export interface RequestArgs { options: any; } +/** + * SDK-owned response type, replacing AxiosResponse. + * @export + */ +export interface FgaResponse { + status: number; + statusText: string; + headers: Record; + data: T; +} + +/** + * Configuration for the HTTP client used by the SDK. + * @export + */ +export interface HttpClient { + fetch: typeof globalThis.fetch; + defaultHeaders?: Record; + defaultTimeout?: number; +} + +/** + * Default HttpClient using the global fetch. + * Used as fallback when no custom HttpClient is provided. + */ +export const globalHttpClient: HttpClient = { + fetch: globalThis.fetch.bind(globalThis), + defaultTimeout: 10000, +}; /** * @@ -78,7 +106,7 @@ export const setSearchParams = function (url: URL, ...objects: any[]) { * @return True if the given MIME is JSON, false otherwise. */ const isJsonMime = (mime: string): boolean => { - + const jsonMime = new RegExp("^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$", "i"); return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === "application/json-patch+json"); }; @@ -112,19 +140,11 @@ interface StringIndexable { } export type CallResult = T & { - $response: AxiosResponse + $response: FgaResponse }; export type PromiseResult = Promise>; -/** - * Returns true if this error is returned from axios - * source: https://github.com/axios/axios/blob/21a5ad34c4a5956d81d338059ac0dd34a19ed094/lib/helpers/isAxiosError.js#L12 - * @param err - */ -function isAxiosError(err: any): boolean { - return err && typeof err === "object" && err.isAxiosError === true; -} function calculateExponentialBackoffWithJitter(retryAttempt: number, minWaitInMs: number): number { const minDelayMs = Math.ceil(2 ** retryAttempt * minWaitInMs); const maxDelayMs = Math.ceil(2 ** (retryAttempt + 1) * minWaitInMs); @@ -146,20 +166,14 @@ function isValidRetryDelay(delayMs: number): boolean { * @param headers - HTTP response headers * @returns Delay in milliseconds if valid, undefined otherwise */ -function parseRetryAfterHeader(headers: Record): number | undefined { +function parseRetryAfterHeader(headers: Record): number | undefined { // Find the retry-after header regardless of case const retryAfterHeaderNameLower = SdkConstants.RetryAfterHeaderName.toLowerCase(); const retryAfterKey = Object.keys(headers).find(key => key.toLowerCase() === retryAfterHeaderNameLower ); - const retryAfterHeader = retryAfterKey ? headers[retryAfterKey] : undefined; - - if (!retryAfterHeader) { - return undefined; - } - - const retryAfterHeaderValue = Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader; + const retryAfterHeaderValue = retryAfterKey ? headers[retryAfterKey] : undefined; if (!retryAfterHeaderValue) { return undefined; @@ -191,156 +205,288 @@ function parseRetryAfterHeader(headers: Record { - response?: AxiosResponse; +/** + * Convert a fetch Headers object to a plain Record. + */ +function headersToRecord(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +} + +interface WrappedResponse { + response?: FgaResponse; retries: number; } function checkIfRetryableError( - err: any, + errCtx: HttpErrorContext, iterationCount: number, maxRetry: number ): { retryable: boolean; error?: Error } { - if (!isAxiosError(err)) { - return { retryable: false, error: new FgaError(err) }; - } - - const status = (err as any)?.response?.status; - const isNetworkError = !status; + const status = errCtx.status; - if (isNetworkError) { + // Network error — no status code + if (!status) { if (iterationCount > maxRetry) { - return { retryable: false, error: new FgaError(err) }; + return { retryable: false, error: new FgaError(undefined, `Network error for ${errCtx.requestMethod} ${errCtx.requestUrl}`) }; } return { retryable: true }; } if (status === 400 || status === 422) { - return { retryable: false, error: new FgaApiValidationError(err) }; + return { retryable: false, error: new FgaApiValidationError(errCtx) }; } else if (status === 401 || status === 403) { - return { retryable: false, error: new FgaApiAuthenticationError(err) }; + return { retryable: false, error: new FgaApiAuthenticationError(errCtx) }; } else if (status === 404) { - return { retryable: false, error: new FgaApiNotFoundError(err) }; + return { retryable: false, error: new FgaApiNotFoundError(errCtx) }; } else if (status === 429 || (status >= 500 && status !== 501)) { if (iterationCount > maxRetry) { if (status === 429) { - return { retryable: false, error: new FgaApiRateLimitExceededError(err) }; + return { retryable: false, error: new FgaApiRateLimitExceededError(errCtx) }; } else { - return { retryable: false, error: new FgaApiInternalError(err) }; + return { retryable: false, error: new FgaApiInternalError(errCtx) }; } } return { retryable: true }; } else { - return { retryable: false, error: new FgaApiError(err) }; + return { retryable: false, error: new FgaApiError(errCtx) }; } } +/** + * Request configuration for attemptHttpRequest. + */ +interface FetchRequestConfig { + url: string; + method?: string; + headers?: Record; + data?: any; + responseType?: string; + timeout?: number; +} + export async function attemptHttpRequest( - request: AxiosRequestConfig, + request: FetchRequestConfig, config: { maxRetry: number; minWaitInMs: number; }, - axiosInstance: AxiosInstance, + httpClient: HttpClient, telemetryConfig?: { telemetry?: TelemetryConfiguration; userAgent?: string; }, -): Promise | undefined> { +): Promise | undefined> { let iterationCount = 0; do { iterationCount++; - // Track HTTP request duration for this specific call const httpRequestStart = performance.now(); - let response: AxiosResponse | undefined; + let fgaResponse: FgaResponse | undefined; let httpRequestError: any; try { - response = await axiosInstance(request); - } catch (err: any) { - httpRequestError = err; - } + // Merge headers with case-insensitive override: per-request headers + // take precedence over default headers regardless of casing. + const fetchHeaders: Record = {}; + if (httpClient.defaultHeaders) { + for (const [k, v] of Object.entries(httpClient.defaultHeaders)) { + fetchHeaders[k.toLowerCase()] = v; + } + } + if (request.headers) { + for (const [k, v] of Object.entries(request.headers)) { + fetchHeaders[k.toLowerCase()] = v; + } + } - // Calculate duration for this individual HTTP call - const httpRequestDuration = Math.round(performance.now() - httpRequestStart); + const fetchInit: RequestInit = { + method: request.method || "GET", + headers: fetchHeaders, + signal: AbortSignal.timeout(request.timeout ?? httpClient.defaultTimeout ?? 10000), + }; - // Emit per-HTTP-request metric if telemetry is configured - if (telemetryConfig?.telemetry?.metrics?.histogramHttpRequestDuration) { - const httpAttrs: Record = {}; + if (request.data !== undefined) { + fetchInit.body = typeof request.data === "string" ? request.data : JSON.stringify(request.data); + } - // Build attributes from the request - if (request.url) { + const response = await httpClient.fetch(request.url, fetchInit); + const responseHeaders = headersToRecord(response.headers); + + if (!response.ok) { + // Non-2xx status — build error context for retry/error handling + let responseData: any; try { - const parsedUrl = new URL(request.url); - httpAttrs[TelemetryAttribute.HttpHost] = parsedUrl.hostname; - httpAttrs[TelemetryAttribute.UrlScheme] = parsedUrl.protocol.replace(":", ""); - httpAttrs[TelemetryAttribute.UrlFull] = request.url; + responseData = await response.json(); } catch { - // URL parsing failed, still include the raw URL - httpAttrs[TelemetryAttribute.UrlFull] = request.url; + try { + responseData = await response.text(); + } catch { + responseData = undefined; + } } + + const errCtx: HttpErrorContext = { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data: responseData, + requestUrl: request.url, + requestMethod: request.method, + requestData: request.data, + }; + + // Emit per-HTTP-request metric before checking retry + emitHttpRequestMetric(telemetryConfig, request, response.status, httpRequestStart); + + const { retryable, error } = checkIfRetryableError(errCtx, iterationCount, config.maxRetry); + + if (!retryable) { + throw error; + } + + // Calculate retry delay + let retryDelayMs: number | undefined; + const status = response.status; + if (status === 429 || (status >= 500 && status !== 501)) { + retryDelayMs = parseRetryAfterHeader(responseHeaders); + } + if (!retryDelayMs) { + retryDelayMs = calculateExponentialBackoffWithJitter(iterationCount, config.minWaitInMs); + } + + await new Promise(r => setTimeout(r, Math.min(retryDelayMs, SdkConstants.RetryHeaderMaxAllowableDurationInSec * 1000))); + continue; } - if (request.method) { - httpAttrs[TelemetryAttribute.HttpRequestMethod] = request.method.toUpperCase(); - } - if (telemetryConfig.userAgent) { - httpAttrs[TelemetryAttribute.UserAgentOriginal] = telemetryConfig.userAgent; - } - // Add response status code if available - const responseStatus = response?.status || httpRequestError?.response?.status; - if (responseStatus != null) { - httpAttrs[TelemetryAttribute.HttpResponseStatusCode] = responseStatus; + // Success path + if (request.responseType === "stream") { + // For streaming, return the body as-is without parsing + fgaResponse = { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data: response.body as any, + }; + } else { + let data: any; + const contentType = response.headers.get("content-type") || ""; + if (response.status === 204 || response.headers.get("content-length") === "0") { + data = undefined; + } else if (contentType.includes("application/json")) { + data = await response.json(); + } else { + data = await response.text(); + } + + fgaResponse = { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data, + }; + } + } catch (err: any) { + // Network errors, timeouts, or already-classified FGA errors + if (err instanceof FgaError) { + throw err; } - telemetryConfig.telemetry.recorder.histogram( - TelemetryHistograms.httpRequestDuration, - httpRequestDuration, - TelemetryAttributes.prepare( - httpAttrs, - telemetryConfig.telemetry.metrics.histogramHttpRequestDuration.attributes - ) - ); + httpRequestError = err; } + // Calculate duration for this individual HTTP call + const httpRequestDuration = Math.round(performance.now() - httpRequestStart); + + // Emit per-HTTP-request metric if telemetry is configured + emitHttpRequestMetric(telemetryConfig, request, fgaResponse?.status ?? httpRequestError?.response?.status, httpRequestStart); + // Handle successful response - if (response && !httpRequestError) { + if (fgaResponse && !httpRequestError) { return { - response: response, + response: fgaResponse, retries: iterationCount - 1, }; } - // Handle error + // Handle network/timeout error if (httpRequestError) { - const { retryable, error } = checkIfRetryableError(httpRequestError, iterationCount, config.maxRetry); - - if (!retryable) { - throw error; + // Only retry network-level errors (TypeError from fetch, DOMException for abort). + // Other errors (programming errors, unexpected throws) should propagate immediately. + const isNetworkError = httpRequestError instanceof TypeError || + (httpRequestError?.name === "AbortError") || + (httpRequestError?.name === "TimeoutError"); + + if (!isNetworkError) { + throw new FgaError(httpRequestError); } - const status = httpRequestError?.response?.status; - let retryDelayMs: number | undefined; + const errCtx: HttpErrorContext = { + requestUrl: request.url, + requestMethod: request.method, + requestData: request.data, + }; - if ((status && - (status === 429 || (status >= 500 && status !== 501))) && - httpRequestError.response?.headers) { - retryDelayMs = parseRetryAfterHeader(httpRequestError.response.headers); - } - if (!retryDelayMs) { - retryDelayMs = calculateExponentialBackoffWithJitter(iterationCount, config.minWaitInMs); + const { retryable, error } = checkIfRetryableError(errCtx, iterationCount, config.maxRetry); + + if (!retryable) { + throw error; } + const retryDelayMs = calculateExponentialBackoffWithJitter(iterationCount, config.minWaitInMs); await new Promise(r => setTimeout(r, Math.min(retryDelayMs, SdkConstants.RetryHeaderMaxAllowableDurationInSec * 1000))); } } while (iterationCount < config.maxRetry + 1); } +function emitHttpRequestMetric( + telemetryConfig: { telemetry?: TelemetryConfiguration; userAgent?: string } | undefined, + request: FetchRequestConfig, + responseStatus: number | undefined, + httpRequestStart: number +) { + if (!telemetryConfig?.telemetry?.metrics?.histogramHttpRequestDuration) return; + + const httpRequestDuration = Math.round(performance.now() - httpRequestStart); + const httpAttrs: Record = {}; + + if (request.url) { + try { + const parsedUrl = new URL(request.url); + httpAttrs[TelemetryAttribute.HttpHost] = parsedUrl.hostname; + httpAttrs[TelemetryAttribute.UrlScheme] = parsedUrl.protocol.replace(":", ""); + httpAttrs[TelemetryAttribute.UrlFull] = request.url; + } catch { + httpAttrs[TelemetryAttribute.UrlFull] = request.url; + } + } + if (request.method) { + httpAttrs[TelemetryAttribute.HttpRequestMethod] = request.method.toUpperCase(); + } + if (telemetryConfig.userAgent) { + httpAttrs[TelemetryAttribute.UserAgentOriginal] = telemetryConfig.userAgent; + } + if (responseStatus != null) { + httpAttrs[TelemetryAttribute.HttpResponseStatusCode] = responseStatus; + } + + telemetryConfig.telemetry.recorder.histogram( + TelemetryHistograms.httpRequestDuration, + httpRequestDuration, + TelemetryAttributes.prepare( + httpAttrs, + telemetryConfig.telemetry.metrics.histogramHttpRequestDuration.attributes + ) + ); +} + /** - * creates an axios request function + * creates a fetch request function */ -export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInstance: AxiosInstance, configuration: Configuration, credentials: Credentials, methodAttributes: Record = {}) { +export const createRequestFunction = function (axiosArgs: RequestArgs, httpClient: HttpClient, configuration: Configuration, credentials: Credentials, methodAttributes: Record = {}) { configuration.isValid(); const retryParams = axiosArgs.options?.retryParams ? axiosArgs.options?.retryParams : configuration.retryParams; @@ -349,16 +495,23 @@ export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInst const start = performance.now(); - return async (axios: AxiosInstance = axiosInstance): PromiseResult => { + return async (client: HttpClient = httpClient): PromiseResult => { await setBearerAuthToObject(axiosArgs.options.headers, credentials!); const url = configuration.getBasePath() + axiosArgs.url; - const axiosRequestArgs = { ...axiosArgs.options, url: url }; - const wrappedResponse = await attemptHttpRequest(axiosRequestArgs, { + const fetchRequestConfig: FetchRequestConfig = { + url, + method: axiosArgs.options.method, + headers: axiosArgs.options.headers, + data: axiosArgs.options.data, + timeout: axiosArgs.options.timeout, + }; + + const wrappedResponse = await attemptHttpRequest(fetchRequestConfig, { maxRetry, minWaitInMs, - }, axios, { + }, client, { telemetry: configuration.telemetry, userAgent: configuration.baseOptions?.headers?.["User-Agent"], }); @@ -458,12 +611,12 @@ export interface RequestBuilderOptions { headers?: Record; /** Extra query parameters appended to the URL. */ query?: Record; - /** Any other axios request config properties (e.g. timeout, auth). */ + /** Any other request config properties (e.g. timeout). */ [key: string]: unknown; } /** - * Builds the axios RequestArgs for an arbitrary API call. + * Builds the RequestArgs for an arbitrary API call. * * @param request - The request parameters * @param options - Request options (merge configuration.baseOptions and per-call overrides before passing) @@ -528,10 +681,10 @@ export function RequestBuilder(request: RequestBuilderParams, options: RequestBu } /** - * creates an axios streaming request function that returns the raw response stream + * creates a streaming request function that returns the raw response stream * for incremental parsing (used by streamedListObjects) */ -export const createStreamingRequestFunction = function (axiosArgs: RequestArgs, axiosInstance: AxiosInstance, configuration: Configuration, credentials: Credentials, methodAttributes: Record = {}) { +export const createStreamingRequestFunction = function (axiosArgs: RequestArgs, httpClient: HttpClient, configuration: Configuration, credentials: Credentials, methodAttributes: Record = {}) { configuration.isValid(); const retryParams = axiosArgs.options?.retryParams ? axiosArgs.options?.retryParams : configuration.retryParams; @@ -540,19 +693,27 @@ export const createStreamingRequestFunction = function (axiosArgs: RequestArgs, const start = performance.now(); - return async (axios: AxiosInstance = axiosInstance): Promise => { + return async (client: HttpClient = httpClient): Promise => { await setBearerAuthToObject(axiosArgs.options.headers, credentials!); const url = configuration.getBasePath() + axiosArgs.url; - const axiosRequestArgs = { ...axiosArgs.options, responseType: "stream", url: url }; - const wrappedResponse = await attemptHttpRequest(axiosRequestArgs, { + const fetchRequestConfig: FetchRequestConfig = { + url, + method: axiosArgs.options.method, + headers: axiosArgs.options.headers, + data: axiosArgs.options.data, + responseType: "stream", + timeout: axiosArgs.options.timeout, + }; + + const wrappedResponse = await attemptHttpRequest(fetchRequestConfig, { maxRetry, minWaitInMs, - }, axios); + }, client); const response = wrappedResponse?.response; - const result: any = response?.data; // raw stream + const result: any = response?.data; // raw ReadableStream let attributes: StringIndexable = {}; @@ -596,4 +757,4 @@ export const createStreamingRequestFunction = function (axiosArgs: RequestArgs, return result; }; -}; \ No newline at end of file +}; diff --git a/credentials/credentials.ts b/credentials/credentials.ts index b99cb490..1784e7c2 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -11,12 +11,11 @@ */ -import globalAxios, { AxiosInstance } from "axios"; import * as jose from "jose"; import { assertParamExists, isWellFormedUriString } from "../validation"; import { FgaApiAuthenticationError, FgaApiError, FgaValidationError } from "../errors"; -import { attemptHttpRequest } from "../common"; +import { attemptHttpRequest, HttpClient } from "../common"; import { AuthCredentialsConfig, PrivateKeyJWTConfig, ClientCredentialsConfig, ClientSecretConfig, CredentialsMethod } from "./types"; import { TelemetryAttributes } from "../telemetry/attributes"; import { TelemetryCounters } from "../telemetry/counters"; @@ -43,16 +42,21 @@ const HTTPS_SCHEME = "https://"; export const DEFAULT_TOKEN_ENDPOINT_PATH = "oauth/token"; +const defaultHttpClient: HttpClient = { + fetch: globalThis.fetch.bind(globalThis), + defaultTimeout: 10000, +}; + export class Credentials { private accessToken?: string; private accessTokenExpiryDate?: Date; private accessTokenExpiryBufferInMs = 0; - public static init(configuration: { credentials: AuthCredentialsConfig, telemetry: TelemetryConfiguration, baseOptions?: any }, axios: AxiosInstance = globalAxios): Credentials { - return new Credentials(configuration.credentials, axios, configuration.telemetry, configuration.baseOptions); + public static init(configuration: { credentials: AuthCredentialsConfig, telemetry: TelemetryConfiguration, baseOptions?: any }, httpClient: HttpClient = defaultHttpClient): Credentials { + return new Credentials(configuration.credentials, httpClient, configuration.telemetry, configuration.baseOptions); } - public constructor(private authConfig: AuthCredentialsConfig, private axios: AxiosInstance = globalAxios, private telemetryConfig: TelemetryConfiguration, private baseOptions?: any) { + public constructor(private authConfig: AuthCredentialsConfig, private httpClient: HttpClient = defaultHttpClient, private telemetryConfig: TelemetryConfiguration, private baseOptions?: any) { this.initConfig(); this.isValid(); } @@ -208,7 +212,7 @@ export class Credentials { }, { maxRetry: 3, minWaitInMs: 100, - }, this.axios); + }, this.httpClient); const response = wrappedResponse?.response; if (response) { diff --git a/errors.ts b/errors.ts index 323553de..cf916292 100644 --- a/errors.ts +++ b/errors.ts @@ -1,10 +1,23 @@ -import { AxiosError, AxiosHeaderValue, Method } from "axios"; import { ErrorCode, InternalErrorCode, NotFoundErrorCode, } from "./apiModel"; +/** + * Context extracted from a failed HTTP request/response, + * used to construct SDK error classes without coupling to any HTTP library. + */ +export interface HttpErrorContext { + status?: number; + statusText?: string; + headers?: Record; + data?: any; + requestUrl?: string; + requestMethod?: string; + requestData?: any; +} + /** * * @export @@ -45,14 +58,11 @@ function getRequestMetadataFromPath(path?: string): { const cFGARequestId = "fga-request-id"; -function getResponseHeaders(err: AxiosError): any { - return err.response - ? Object.fromEntries( - Object.entries(err.response.headers).map(([k, v]) => [ - k.toLowerCase(), v, - ]) - ) - : {}; +function normalizeHeaders(headers?: Record): Record { + if (!headers) return {}; + return Object.fromEntries( + Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]) + ); } /** @@ -65,37 +75,37 @@ export class FgaApiError extends FgaError { name = "FgaApiError"; public statusCode?: number; public statusText?: string; - public method?: Method; + public method?: string; public requestURL?: string; public storeId?: string; public endpointCategory?: string; public apiErrorMessage?: string; public requestData?: any; public responseData?: any; - public responseHeader?: Record; + public responseHeader?: Record; public requestId?: string; - constructor(err: AxiosError, msg?: string) { - super(msg ? msg : err); - this.statusCode = err.response?.status; - this.statusText = err.response?.statusText; - this.requestData = err.config?.data; - this.requestURL = err.config?.url; - this.method = err.config?.method as Method; + constructor(err: HttpErrorContext | Error, msg?: string) { + super(msg ? msg : (err instanceof Error ? err : undefined)); + if (err instanceof Error) { + if (err.stack) this.stack = err.stack; + return; + } + this.statusCode = err.status; + this.statusText = err.statusText; + this.requestData = err.requestData; + this.requestURL = err.requestUrl; + this.method = err.requestMethod; const { storeId, endpointCategory } = getRequestMetadataFromPath( - err.request?.path + err.requestUrl ? new URL(err.requestUrl, "http://localhost").pathname : undefined ); this.storeId = storeId; this.endpointCategory = endpointCategory; - this.apiErrorMessage = (err.response?.data as any)?.message; - this.responseData = err.response?.data; - this.responseHeader = err.response?.headers; - const errResponseHeaders = getResponseHeaders(err); - this.requestId = errResponseHeaders[cFGARequestId]; - - if ((err as Error)?.stack) { - this.stack = (err as Error).stack; - } + this.apiErrorMessage = err.data?.message; + this.responseData = err.data; + const normalizedHeaders = normalizeHeaders(err.headers); + this.responseHeader = normalizedHeaders; + this.requestId = normalizedHeaders[cFGARequestId]; } } @@ -108,16 +118,18 @@ export class FgaApiError extends FgaError { export class FgaApiValidationError extends FgaApiError { name = "FgaApiValidationError"; public apiErrorCode: ErrorCode; - constructor(err: AxiosError, msg?: string) { + constructor(err: HttpErrorContext, msg?: string) { // If there is a better error message, use it instead of the default error super(err); - this.apiErrorCode = (err.response?.data as any)?.code; - const { endpointCategory } = getRequestMetadataFromPath(err.request?.path); + this.apiErrorCode = err.data?.code; + const { endpointCategory } = getRequestMetadataFromPath( + err.requestUrl ? new URL(err.requestUrl, "http://localhost").pathname : undefined + ); this.message = msg ? msg - : (err.response?.data as any)?.message - ? `FGA API Validation Error: ${err.config?.method} ${endpointCategory} : Error ${(err.response?.data as any)?.message}` - : (err as Error).message; + : err.data?.message + ? `FGA API Validation Error: ${err.requestMethod} ${endpointCategory} : Error ${err.data.message}` + : this.message; } } @@ -130,15 +142,15 @@ export class FgaApiValidationError extends FgaApiError { export class FgaApiNotFoundError extends FgaApiError { name = "FgaApiNotFoundError"; public apiErrorCode: NotFoundErrorCode; - constructor(err: AxiosError, msg?: string) { + constructor(err: HttpErrorContext, msg?: string) { // If there is a better error message, use it instead of the default error super(err); - this.apiErrorCode = (err.response?.data as any)?.code; + this.apiErrorCode = err.data?.code; this.message = msg ? msg - : (err.response?.data as any)?.message - ? `FGA API NotFound Error: ${err.config?.method} : Error ${(err.response?.data as any)?.message}` - : (err as Error).message; + : err.data?.message + ? `FGA API NotFound Error: ${err.requestMethod} : Error ${err.data.message}` + : this.message; } } @@ -154,9 +166,9 @@ export class FgaApiRateLimitExceededError extends FgaApiError { name = "FgaApiRateLimitExceededError"; public apiErrorCode?: string; - constructor(err: AxiosError, msg?: string) { + constructor(err: HttpErrorContext, msg?: string) { super(err); - this.apiErrorCode = (err.response?.data as any)?.code; + this.apiErrorCode = err.data?.code; this.message = msg ? msg @@ -174,17 +186,19 @@ export class FgaApiInternalError extends FgaApiError { name = "FgaApiInternalError"; public apiErrorCode: InternalErrorCode; - constructor(err: AxiosError, msg?: string) { + constructor(err: HttpErrorContext, msg?: string) { // If there is a better error message, use it instead of the default error super(err); - const { endpointCategory } = getRequestMetadataFromPath(err.request?.path); - this.apiErrorCode = (err.response?.data as any)?.code; + const { endpointCategory } = getRequestMetadataFromPath( + err.requestUrl ? new URL(err.requestUrl, "http://localhost").pathname : undefined + ); + this.apiErrorCode = err.data?.code; this.message = msg ? msg - : (err.response?.data as any)?.message - ? `FGA API Internal Error: ${err.config?.method} ${endpointCategory} : Error ${(err.response?.data as any)?.message}` - : (err as Error).message; + : err.data?.message + ? `FGA API Internal Error: ${err.requestMethod} ${endpointCategory} : Error ${err.data.message}` + : this.message; } } @@ -201,15 +215,15 @@ export class FgaApiAuthenticationError extends FgaApiError { public grantType?: string; public apiErrorCode?: string; - constructor(err: AxiosError) { + constructor(err: HttpErrorContext) { super(err); - this.message = `FGA Authentication Error.${err.response?.statusText ? ` ${err.response.statusText}` : ""}`; - this.apiErrorCode = (err.response?.data as any)?.code; + this.message = `FGA Authentication Error.${err.statusText ? ` ${err.statusText}` : ""}`; + this.apiErrorCode = err.data?.code; let data: any; try { - data = JSON.parse(err.config?.data || "{}"); - } catch (err) { + data = typeof err.requestData === "string" ? JSON.parse(err.requestData) : (err.requestData || {}); + } catch { /* do nothing */ } this.clientId = data?.client_id; diff --git a/index.ts b/index.ts index 222f87ce..aa9eee6f 100644 --- a/index.ts +++ b/index.ts @@ -22,5 +22,6 @@ export * from "./telemetry/counters"; export * from "./telemetry/histograms"; export * from "./telemetry/metrics"; export * from "./errors"; +export { FgaResponse, HttpClient, CallResult, PromiseResult } from "./common"; export { parseNDJSONStream } from "./streaming"; diff --git a/package.json b/package.json index 8ab2796f..410df4b0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "axios": "1.14.0", "jose": "5.10.0", "tiny-async-pool": "^2.1.0" }, diff --git a/tests/errors-authentication.test.ts b/tests/errors-authentication.test.ts index bd633baa..22243fb7 100644 --- a/tests/errors-authentication.test.ts +++ b/tests/errors-authentication.test.ts @@ -1,30 +1,23 @@ -import { FgaApiAuthenticationError, FgaApiError } from "../errors"; +import { FgaApiAuthenticationError, FgaApiError, HttpErrorContext } from "../errors"; describe("errors.ts", () => { describe("FgaApiAuthenticationError", () => { test("should be an instance of FgaApiError", () => { - const axiosError = { - response: { - status: 401, - statusText: "Unauthorized", - data: { code: "auth_error" }, - headers: {}, - }, - config: { - url: "https://issuer.fga.example/oauth/token", - method: "post", - data: JSON.stringify({ - client_id: "client-id", - audience: "api-audience", - grant_type: "client_credentials", - }), - }, - request: { - path: "/stores/01H0GVCS1HCQM6SJRJ4A026FZ9/check", - }, - } as any; + const errorContext: HttpErrorContext = { + status: 401, + statusText: "Unauthorized", + data: { code: "auth_error" }, + headers: {}, + requestUrl: "https://issuer.fga.example/oauth/token", + requestMethod: "post", + requestData: JSON.stringify({ + client_id: "client-id", + audience: "api-audience", + grant_type: "client_credentials", + }), + }; - const err = new FgaApiAuthenticationError(axiosError); + const err = new FgaApiAuthenticationError(errorContext); expect(err).toBeInstanceOf(FgaApiError); expect(err).toBeInstanceOf(Error); diff --git a/tests/index.test.ts b/tests/index.test.ts index e3c661cc..0ce5ee15 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -684,8 +684,8 @@ describe("OpenFGA SDK", function () { ).rejects.toThrow(FgaApiAuthenticationError); }); }); - describe("non-Axios errors should be thrown immediately without retry", () => { - it("should throw FgaError immediately for non-Axios errors", async () => { + describe("non-network errors should be thrown immediately without retry", () => { + it("should throw FgaError immediately for non-network errors", async () => { const tupleKey = { user: "user:xyz", relation: "viewer", @@ -699,24 +699,24 @@ describe("OpenFGA SDK", function () { nocks.tokenExchange(OPENFGA_API_TOKEN_ISSUER, "test-token"); - // Mock axios to throw a non-Axios error - const originalAxios = (fgaApi as any).axios; + // Mock fetch to throw a non-network error + const originalFetch = (fgaApi as any).httpClient.fetch; let callCount = 0; - (fgaApi as any).axios = async () => { + (fgaApi as any).httpClient.fetch = async () => { callCount++; - const nonAxiosError = new Error("Non-Axios error"); - throw nonAxiosError; + const nonNetworkError = new Error("Non-network error"); + throw nonNetworkError; }; await expect( fgaApi.check(baseConfig.storeId!, { tuple_key: tupleKey }) - ).rejects.toThrow("Non-Axios error"); + ).rejects.toThrow("Non-network error"); // Should not retry - only called once expect(callCount).toBe(1); - // Restore original axios - (fgaApi as any).axios = originalAxios; + // Restore original fetch + (fgaApi as any).httpClient.fetch = originalFetch; }); }); From 744a80e446d606920c5312ee29552ef58cf67b28 Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Tue, 31 Mar 2026 08:51:17 -0400 Subject: [PATCH 2/6] chore: refactor to replace `any` with proper types --- api.ts | 116 ++++++++++++++++++------------------- common.ts | 23 ++++++-- configuration.ts | 10 ++-- credentials/credentials.ts | 2 +- 4 files changed, 81 insertions(+), 70 deletions(-) diff --git a/api.ts b/api.ts index dee807d1..2273a2c9 100644 --- a/api.ts +++ b/api.ts @@ -140,7 +140,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async batchCheck(storeId: string, body: BatchCheckRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async batchCheck(storeId: string, body: BatchCheckRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("batchCheck", "storeId", storeId); assertParamExists("batchCheck", "body", body); return api.executeApiRequest({ @@ -159,7 +159,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async check(storeId: string, body: CheckRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async check(storeId: string, body: CheckRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("check", "storeId", storeId); assertParamExists("check", "body", body); return api.executeApiRequest({ @@ -177,7 +177,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async createStore(body: CreateStoreRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async createStore(body: CreateStoreRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("createStore", "body", body); return api.executeApiRequest({ operationName: "CreateStore", @@ -193,7 +193,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async deleteStore(storeId: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async deleteStore(storeId: string, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("deleteStore", "storeId", storeId); return api.executeApiRequest({ operationName: "DeleteStore", @@ -210,7 +210,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async expand(storeId: string, body: ExpandRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async expand(storeId: string, body: ExpandRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("expand", "storeId", storeId); assertParamExists("expand", "body", body); return api.executeApiRequest({ @@ -228,7 +228,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async getStore(storeId: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async getStore(storeId: string, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("getStore", "storeId", storeId); return api.executeApiRequest({ operationName: "GetStore", @@ -245,7 +245,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async listObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async listObjects(storeId: string, body: ListObjectsRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("listObjects", "storeId", storeId); assertParamExists("listObjects", "body", body); return api.executeApiRequest({ @@ -266,7 +266,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async streamedListObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise<(httpClient?: HttpClient) => Promise> { + async streamedListObjects(storeId: string, body: ListObjectsRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => Promise> { assertParamExists("streamedListObjects", "storeId", storeId); assertParamExists("streamedListObjects", "body", body); return api.executeStreamedApiRequest({ @@ -286,7 +286,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async listStores(pageSize?: number, continuationToken?: string, name?: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async listStores(pageSize?: number, continuationToken?: string, name?: string, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { return api.executeApiRequest({ operationName: "ListStores", method: "GET", @@ -302,7 +302,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async listUsers(storeId: string, body: ListUsersRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async listUsers(storeId: string, body: ListUsersRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("listUsers", "storeId", storeId); assertParamExists("listUsers", "body", body); return api.executeApiRequest({ @@ -321,7 +321,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async read(storeId: string, body: ReadRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async read(storeId: string, body: ReadRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("read", "storeId", storeId); assertParamExists("read", "body", body); return api.executeApiRequest({ @@ -340,7 +340,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async readAssertions(storeId: string, authorizationModelId: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async readAssertions(storeId: string, authorizationModelId: string, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("readAssertions", "storeId", storeId); assertParamExists("readAssertions", "authorizationModelId", authorizationModelId); return api.executeApiRequest({ @@ -358,7 +358,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async readAuthorizationModel(storeId: string, id: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async readAuthorizationModel(storeId: string, id: string, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("readAuthorizationModel", "storeId", storeId); assertParamExists("readAuthorizationModel", "id", id); return api.executeApiRequest({ @@ -377,7 +377,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("readAuthorizationModels", "storeId", storeId); return api.executeApiRequest({ operationName: "ReadAuthorizationModels", @@ -398,7 +398,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("readChanges", "storeId", storeId); return api.executeApiRequest({ operationName: "ReadChanges", @@ -416,7 +416,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async write(storeId: string, body: WriteRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async write(storeId: string, body: WriteRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("write", "storeId", storeId); assertParamExists("write", "body", body); return api.executeApiRequest({ @@ -436,7 +436,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("writeAssertions", "storeId", storeId); assertParamExists("writeAssertions", "authorizationModelId", authorizationModelId); assertParamExists("writeAssertions", "body", body); @@ -456,7 +456,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: * @param {*} [options] Override http request option. * @throws { FgaError } */ - async writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): Promise<(httpClient?: HttpClient) => PromiseResult> { + async writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { assertParamExists("writeAuthorizationModel", "storeId", storeId); assertParamExists("writeAuthorizationModel", "body", body); return api.executeApiRequest({ @@ -511,7 +511,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - batchCheck(storeId: string, body: BatchCheckRequest, options?: any): PromiseResult { + batchCheck(storeId: string, body: BatchCheckRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.batchCheck(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -522,7 +522,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - check(storeId: string, body: CheckRequest, options?: any): PromiseResult { + check(storeId: string, body: CheckRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.check(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -532,7 +532,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - createStore(body: CreateStoreRequest, options?: any): PromiseResult { + createStore(body: CreateStoreRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.createStore(body, options).then((request) => request(httpClient)); }, /** @@ -542,7 +542,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - deleteStore(storeId: string, options?: any): PromiseResult { + deleteStore(storeId: string, options?: RequestBuilderOptions): PromiseResult { return localVarFp.deleteStore(storeId, options).then((request) => request(httpClient)); }, /** @@ -553,7 +553,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - expand(storeId: string, body: ExpandRequest, options?: any): PromiseResult { + expand(storeId: string, body: ExpandRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.expand(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -563,7 +563,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - getStore(storeId: string, options?: any): PromiseResult { + getStore(storeId: string, options?: RequestBuilderOptions): PromiseResult { return localVarFp.getStore(storeId, options).then((request) => request(httpClient)); }, /** @@ -574,7 +574,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - listObjects(storeId: string, body: ListObjectsRequest, options?: any): PromiseResult { + listObjects(storeId: string, body: ListObjectsRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.listObjects(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -587,7 +587,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - streamedListObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise { + streamedListObjects(storeId: string, body: ListObjectsRequest, options?: RequestBuilderOptions): Promise { return localVarFp.streamedListObjects(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -599,7 +599,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - listStores(pageSize?: number, continuationToken?: string, name?: string, options?: any): PromiseResult { + listStores(pageSize?: number, continuationToken?: string, name?: string, options?: RequestBuilderOptions): PromiseResult { return localVarFp.listStores(pageSize, continuationToken, name, options).then((request) => request(httpClient)); }, /** @@ -610,7 +610,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - listUsers(storeId: string, body: ListUsersRequest, options?: any): PromiseResult { + listUsers(storeId: string, body: ListUsersRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.listUsers(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -621,7 +621,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - read(storeId: string, body: ReadRequest, options?: any): PromiseResult { + read(storeId: string, body: ReadRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.read(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -632,7 +632,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - readAssertions(storeId: string, authorizationModelId: string, options?: any): PromiseResult { + readAssertions(storeId: string, authorizationModelId: string, options?: RequestBuilderOptions): PromiseResult { return localVarFp.readAssertions(storeId, authorizationModelId, options).then((request) => request(httpClient)); }, /** @@ -643,7 +643,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - readAuthorizationModel(storeId: string, id: string, options?: any): PromiseResult { + readAuthorizationModel(storeId: string, id: string, options?: RequestBuilderOptions): PromiseResult { return localVarFp.readAuthorizationModel(storeId, id, options).then((request) => request(httpClient)); }, /** @@ -655,7 +655,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: any): PromiseResult { + readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: RequestBuilderOptions): PromiseResult { return localVarFp.readAuthorizationModels(storeId, pageSize, continuationToken, options).then((request) => request(httpClient)); }, /** @@ -669,7 +669,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: any): PromiseResult { + readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: RequestBuilderOptions): PromiseResult { return localVarFp.readChanges(storeId, type, pageSize, continuationToken, startTime, options).then((request) => request(httpClient)); }, /** @@ -680,7 +680,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - write(storeId: string, body: WriteRequest, options?: any): PromiseResult { + write(storeId: string, body: WriteRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.write(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -692,7 +692,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: any): PromiseResult { + writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.writeAssertions(storeId, authorizationModelId, body, options).then((request) => request(httpClient)); }, /** @@ -703,7 +703,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): PromiseResult { + writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: RequestBuilderOptions): PromiseResult { return localVarFp.writeAuthorizationModel(storeId, body, options).then((request) => request(httpClient)); }, /** @@ -715,10 +715,10 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @param {*} [options] Override http request option. * @throws { FgaError } */ - executeApiRequest(request: RequestBuilderParams, options?: any): PromiseResult { + executeApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): PromiseResult { return localVarFp.executeApiRequest(request, options).then((request) => request(httpClient)); }, - executeStreamedApiRequest(request: RequestBuilderParams, options?: any): Promise { + executeStreamedApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise { return localVarFp.executeStreamedApiRequest(request, options).then((request) => request(httpClient)); }, }; @@ -740,7 +740,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public batchCheck(storeId: string, body: BatchCheckRequest, options?: any): Promise> { + public batchCheck(storeId: string, body: BatchCheckRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).batchCheck(storeId, body, options).then((request) => request(this.httpClient)); } @@ -753,7 +753,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public check(storeId: string, body: CheckRequest, options?: any): Promise> { + public check(storeId: string, body: CheckRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).check(storeId, body, options).then((request) => request(this.httpClient)); } @@ -765,7 +765,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public createStore(body: CreateStoreRequest, options?: any): Promise> { + public createStore(body: CreateStoreRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).createStore(body, options).then((request) => request(this.httpClient)); } @@ -777,7 +777,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public deleteStore(storeId: string, options?: any): Promise> { + public deleteStore(storeId: string, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).deleteStore(storeId, options).then((request) => request(this.httpClient)); } @@ -790,7 +790,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public expand(storeId: string, body: ExpandRequest, options?: any): Promise> { + public expand(storeId: string, body: ExpandRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).expand(storeId, body, options).then((request) => request(this.httpClient)); } @@ -802,7 +802,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public getStore(storeId: string, options?: any): Promise> { + public getStore(storeId: string, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).getStore(storeId, options).then((request) => request(this.httpClient)); } @@ -815,7 +815,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public listObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise> { + public listObjects(storeId: string, body: ListObjectsRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).listObjects(storeId, body, options).then((request) => request(this.httpClient)); } @@ -829,7 +829,7 @@ export class OpenFgaApi extends BaseAPI { * @param {*} [options] Override http request option. * @throws { FgaError } */ - public streamedListObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise { + public streamedListObjects(storeId: string, body: ListObjectsRequest, options?: RequestBuilderOptions): Promise { return OpenFgaApiFp(this.configuration, this.credentials).streamedListObjects(storeId, body, options).then((request) => request(this.httpClient)); } @@ -843,7 +843,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public listStores(pageSize?: number, continuationToken?: string, name?: string, options?: any): Promise> { + public listStores(pageSize?: number, continuationToken?: string, name?: string, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).listStores(pageSize, continuationToken, name, options).then((request) => request(this.httpClient)); } @@ -856,7 +856,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public listUsers(storeId: string, body: ListUsersRequest, options?: any): Promise> { + public listUsers(storeId: string, body: ListUsersRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).listUsers(storeId, body, options).then((request) => request(this.httpClient)); } @@ -869,7 +869,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public read(storeId: string, body: ReadRequest, options?: any): Promise> { + public read(storeId: string, body: ReadRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).read(storeId, body, options).then((request) => request(this.httpClient)); } @@ -882,7 +882,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public readAssertions(storeId: string, authorizationModelId: string, options?: any): Promise> { + public readAssertions(storeId: string, authorizationModelId: string, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).readAssertions(storeId, authorizationModelId, options).then((request) => request(this.httpClient)); } @@ -895,7 +895,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public readAuthorizationModel(storeId: string, id: string, options?: any): Promise> { + public readAuthorizationModel(storeId: string, id: string, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).readAuthorizationModel(storeId, id, options).then((request) => request(this.httpClient)); } @@ -909,7 +909,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: any): Promise> { + public readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).readAuthorizationModels(storeId, pageSize, continuationToken, options).then((request) => request(this.httpClient)); } @@ -925,7 +925,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: any): Promise> { + public readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, startTime?: string, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).readChanges(storeId, type, pageSize, continuationToken, startTime, options).then((request) => request(this.httpClient)); } @@ -938,7 +938,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public write(storeId: string, body: WriteRequest, options?: any): Promise> { + public write(storeId: string, body: WriteRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).write(storeId, body, options).then((request) => request(this.httpClient)); } @@ -952,7 +952,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: any): Promise> { + public writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).writeAssertions(storeId, authorizationModelId, body, options).then((request) => request(this.httpClient)); } @@ -965,7 +965,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): Promise> { + public writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).writeAuthorizationModel(storeId, body, options).then((request) => request(this.httpClient)); } @@ -979,7 +979,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public executeApiRequest(request: RequestBuilderParams, options?: any): Promise> { + public executeApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).executeApiRequest(request, options).then((request) => request(this.httpClient)); } @@ -993,7 +993,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public executeStreamedApiRequest(request: RequestBuilderParams, options?: any): Promise> { + public executeStreamedApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).executeStreamedApiRequest(request, options).then((request) => request(this.httpClient)); } } diff --git a/common.ts b/common.ts index 88088677..0ccad8b5 100644 --- a/common.ts +++ b/common.ts @@ -1,4 +1,4 @@ -import { Configuration } from "./configuration"; +import { Configuration, RetryParams } from "./configuration"; import SdkConstants from "./constants"; import type { Credentials } from "./credentials"; import { @@ -523,7 +523,7 @@ export const createRequestFunction = function (axiosArgs: RequestArgs, httpClien let attributes: StringIndexable = {}; attributes = TelemetryAttributes.fromRequest({ - userAgent: configuration.baseOptions?.headers["User-Agent"], + userAgent: configuration.baseOptions?.headers?.["User-Agent"], httpMethod: axiosArgs.options?.method, url, resendCount: wrappedResponse?.retries, @@ -611,8 +611,19 @@ export interface RequestBuilderOptions { headers?: Record; /** Extra query parameters appended to the URL. */ query?: Record; - /** Any other request config properties (e.g. timeout). */ - [key: string]: unknown; + /** Request timeout in milliseconds. */ + timeout?: number; + /** Retry configuration for this request. */ + retryParams?: RetryParams; +} + +/** + * Internal request options used inside RequestBuilder. + * Extends the public options with fields set by the SDK itself. + */ +interface InternalRequestOptions extends RequestBuilderOptions { + method?: string; + data?: string; } /** @@ -642,7 +653,7 @@ export function RequestBuilder(request: RequestBuilderParams, options: RequestBu } const requestUrl = new URL(requestPathTemplate, DUMMY_BASE_URL); - const requestOptions: RequestBuilderOptions = { method: request.method, ...options }; + const requestOptions: InternalRequestOptions = { method: request.method, ...options }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const queryParams = {} as any; @@ -718,7 +729,7 @@ export const createStreamingRequestFunction = function (axiosArgs: RequestArgs, let attributes: StringIndexable = {}; attributes = TelemetryAttributes.fromRequest({ - userAgent: configuration.baseOptions?.headers["User-Agent"], + userAgent: configuration.baseOptions?.headers?.["User-Agent"], httpMethod: axiosArgs.options?.method, url, resendCount: wrappedResponse?.retries, diff --git a/configuration.ts b/configuration.ts index feb2627d..0ff6d299 100644 --- a/configuration.ts +++ b/configuration.ts @@ -28,7 +28,7 @@ export interface UserConfigurationParams { */ apiHost?: string; credentials?: CredentialsConfig; - baseOptions?: any; + baseOptions?: BaseOptions; retryParams?: RetryParams; telemetry?: TelemetryConfig; } @@ -40,8 +40,8 @@ export function GetDefaultRetryParams (maxRetry: number = DEFAULT_MAX_RETRY, min }; } -interface BaseOptions { - headers: Record; +export interface BaseOptions { + headers?: Record; } type CredentialsConfig = @@ -90,9 +90,9 @@ export class Configuration { */ apiHost: string; /** - * base options for axios calls + * base options for HTTP requests * - * @type {any} + * @type {BaseOptions} * @memberof Configuration */ baseOptions?: BaseOptions; diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 1784e7c2..fa47c55e 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -229,7 +229,7 @@ export class Credentials { let attributes = {}; attributes = TelemetryAttributes.fromRequest({ - userAgent: this.baseOptions?.headers["User-Agent"], + userAgent: this.baseOptions?.headers?.["User-Agent"], fgaMethod: "TokenExchange", url, resendCount: wrappedResponse?.retries, From ae61409f3c7b9a970b140801e57133a228d8e534 Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Tue, 31 Mar 2026 09:26:10 -0400 Subject: [PATCH 3/6] chore(tests): add some tests for fetch-based HTTP client layer --- tests/fetch-http-client.test.ts | 634 ++++++++++++++++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 tests/fetch-http-client.test.ts diff --git a/tests/fetch-http-client.test.ts b/tests/fetch-http-client.test.ts new file mode 100644 index 00000000..c5a81ffb --- /dev/null +++ b/tests/fetch-http-client.test.ts @@ -0,0 +1,634 @@ +import { attemptHttpRequest, HttpClient, FgaResponse } from "../common"; +import SdkConstants from "../constants"; +import { + FgaError, + FgaApiValidationError, + FgaApiAuthenticationError, + FgaApiNotFoundError, + FgaApiRateLimitExceededError, + FgaApiInternalError, + FgaApiError, +} from "../errors"; +import { OpenFgaClient } from "../client"; + +function mockHttpClient( + fetchImpl: (url: string | URL | Request, init?: RequestInit) => Promise, + opts?: { defaultHeaders?: Record; defaultTimeout?: number } +): HttpClient { + return { + fetch: fetchImpl as typeof globalThis.fetch, + defaultHeaders: opts?.defaultHeaders, + defaultTimeout: opts?.defaultTimeout ?? 10000, + }; +} + +function mockResponse( + status: number, + body?: any, + opts?: { statusText?: string; headers?: Record } +): Response { + const headers = new Headers(opts?.headers ?? { "content-type": "application/json" }); + const bodyStr = body !== undefined ? JSON.stringify(body) : null; + return new Response(bodyStr, { + status, + statusText: opts?.statusText ?? "OK", + headers, + }); +} + +describe("fetch-based HTTP client", () => { + describe("attemptHttpRequest", () => { + describe("FgaResponse shape", () => { + it("should return a well-formed FgaResponse on success", async () => { + const responseBody = { allowed: true }; + const client = mockHttpClient(async () => + mockResponse(200, responseBody, { + statusText: "OK", + headers: { + "content-type": "application/json", + "x-request-id": "abc-123", + "fga-query-duration-ms": "42", + }, + }) + ); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {}, data: "{}" }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(result).toBeDefined(); + const resp = result!.response!; + expect(resp.status).toBe(200); + expect(resp.statusText).toBe("OK"); + expect(resp.headers["x-request-id"]).toBe("abc-123"); + expect(resp.headers["fga-query-duration-ms"]).toBe("42"); + expect(resp.data).toEqual({ allowed: true }); + expect(result!.retries).toBe(0); + }); + + it("should set data to undefined for 204 No Content", async () => { + const client = mockHttpClient(async () => + new Response(null, { status: 204, statusText: "No Content" }) + ); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/something`, method: "DELETE", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(result).toBeDefined(); + expect(result!.response!.status).toBe(204); + expect(result!.response!.data).toBeUndefined(); + }); + + it("should set data to undefined when content-length is 0", async () => { + const client = mockHttpClient(async () => + new Response(null, { + status: 200, + headers: { "content-length": "0" }, + }) + ); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/something`, method: "GET", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(result!.response!.data).toBeUndefined(); + }); + + it("should return text for non-JSON content types", async () => { + const client = mockHttpClient(async () => + new Response("plain text body", { + status: 200, + headers: { "content-type": "text/plain" }, + }) + ); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/something`, method: "GET", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(result!.response!.data).toBe("plain text body"); + }); + + it("should return the raw body for stream responseType", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{\"result\":\"ok\"}\n")); + controller.close(); + }, + }); + + const client = mockHttpClient(async () => + new Response(stream, { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stream`, method: "POST", headers: {}, responseType: "stream" }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(result!.response!.status).toBe(200); + // data should be the ReadableStream itself, not parsed JSON + expect(result!.response!.data).toBeInstanceOf(ReadableStream); + }); + }); + + describe("case-insensitive header merging", () => { + it("should merge default and request headers case-insensitively", async () => { + let capturedHeaders: Record | undefined; + const client = mockHttpClient( + async (_url, init) => { + capturedHeaders = init?.headers as Record; + return mockResponse(200, {}); + }, + { defaultHeaders: { "X-Default": "default-val", "Authorization": "Bearer token" } } + ); + + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "GET", headers: { "x-default": "override-val" } }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedHeaders).toBeDefined(); + expect(capturedHeaders!["x-default"]).toBe("override-val"); + expect(capturedHeaders!["authorization"]).toBe("Bearer token"); + // There should not be duplicate keys with different casing + expect(Object.keys(capturedHeaders!).filter(k => k.toLowerCase() === "x-default")).toHaveLength(1); + }); + + it("should lowercase all header keys", async () => { + let capturedHeaders: Record | undefined; + const client = mockHttpClient( + async (_url, init) => { + capturedHeaders = init?.headers as Record; + return mockResponse(200, {}); + }, + { defaultHeaders: { "Content-Type": "application/json" } } + ); + + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "GET", headers: { "Accept": "text/html" } }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedHeaders!["content-type"]).toBe("application/json"); + expect(capturedHeaders!["accept"]).toBe("text/html"); + expect(capturedHeaders!["Content-Type"]).toBeUndefined(); + expect(capturedHeaders!["Accept"]).toBeUndefined(); + }); + }); + + describe("timeout", () => { + it("should pass AbortSignal.timeout to fetch", async () => { + let capturedSignal: AbortSignal | undefined; + const client = mockHttpClient( + async (_url, init) => { + capturedSignal = init?.signal as AbortSignal; + return mockResponse(200, {}); + }, + { defaultTimeout: 5000 } + ); + + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "GET", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedSignal).toBeDefined(); + expect(capturedSignal).toBeInstanceOf(AbortSignal); + }); + + it("should prefer per-request timeout over default", async () => { + let capturedSignal: AbortSignal | undefined; + const client = mockHttpClient( + async (_url, init) => { + capturedSignal = init?.signal as AbortSignal; + return mockResponse(200, {}); + }, + { defaultTimeout: 30000 } + ); + + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "GET", headers: {}, timeout: 500 }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedSignal).toBeDefined(); + expect(capturedSignal!.aborted).toBe(false); + }); + + it("should classify TimeoutError as retryable network error", async () => { + let callCount = 0; + const client = mockHttpClient(async () => { + callCount++; + const err = new DOMException("The operation was aborted.", "TimeoutError"); + throw err; + }); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "GET", headers: {} }, + { maxRetry: 1, minWaitInMs: 1 }, + client + ) + ).rejects.toThrow(FgaError); + + expect(callCount).toBe(2); + }); + }); + + describe("network error classification", () => { + it("should retry on TypeError (fetch network error)", async () => { + let callCount = 0; + const client = mockHttpClient(async () => { + callCount++; + throw new TypeError("Failed to fetch"); + }); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "POST", headers: {} }, + { maxRetry: 2, minWaitInMs: 1 }, + client + ) + ).rejects.toThrow(FgaError); + + expect(callCount).toBe(3); + }); + + it("should not retry on generic Error (non-network error)", async () => { + let callCount = 0; + const client = mockHttpClient(async () => { + callCount++; + throw new Error("Some programming error"); + }); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "POST", headers: {} }, + { maxRetry: 3, minWaitInMs: 1 }, + client + ) + ).rejects.toThrow("Some programming error"); + + expect(callCount).toBe(1); + }); + + it("should not retry on RangeError", async () => { + let callCount = 0; + const client = mockHttpClient(async () => { + callCount++; + throw new RangeError("out of range"); + }); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "GET", headers: {} }, + { maxRetry: 3, minWaitInMs: 1 }, + client + ) + ).rejects.toThrow(FgaError); + + expect(callCount).toBe(1); + }); + }); + + describe("HTTP error classification", () => { + it("should throw FgaApiValidationError for 400", async () => { + const client = mockHttpClient(async () => + mockResponse(400, { code: "validation_error", message: "bad request" }, { statusText: "Bad Request" }) + ); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ) + ).rejects.toThrow(FgaApiValidationError); + }); + + it("should throw FgaApiValidationError for 422", async () => { + const client = mockHttpClient(async () => + mockResponse(422, { code: "unprocessable", message: "invalid" }, { statusText: "Unprocessable Entity" }) + ); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ) + ).rejects.toThrow(FgaApiValidationError); + }); + + it("should throw FgaApiAuthenticationError for 401", async () => { + const client = mockHttpClient(async () => + mockResponse(401, { code: "unauthenticated" }, { statusText: "Unauthorized" }) + ); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ) + ).rejects.toThrow(FgaApiAuthenticationError); + }); + + it("should throw FgaApiAuthenticationError for 403", async () => { + const client = mockHttpClient(async () => + mockResponse(403, {}, { statusText: "Forbidden" }) + ); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ) + ).rejects.toThrow(FgaApiAuthenticationError); + }); + + it("should throw FgaApiNotFoundError for 404", async () => { + const client = mockHttpClient(async () => + mockResponse(404, { code: "not_found", message: "store not found" }, { statusText: "Not Found" }) + ); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ) + ).rejects.toThrow(FgaApiNotFoundError); + }); + + it("should throw FgaApiRateLimitExceededError for 429 after exhausting retries", async () => { + const client = mockHttpClient(async () => + mockResponse(429, {}, { statusText: "Too Many Requests" }) + ); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 1 }, + client + ) + ).rejects.toThrow(FgaApiRateLimitExceededError); + }); + + it("should throw FgaApiInternalError for 500 after exhausting retries", async () => { + const client = mockHttpClient(async () => + mockResponse(500, { message: "internal error" }, { statusText: "Internal Server Error" }) + ); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 1 }, + client + ) + ).rejects.toThrow(FgaApiInternalError); + }); + + it("should not retry 501 Not Implemented", async () => { + let callCount = 0; + const client = mockHttpClient(async () => { + callCount++; + return mockResponse(501, {}, { statusText: "Not Implemented" }); + }); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 3, minWaitInMs: 1 }, + client + ) + ).rejects.toThrow(FgaApiError); + + expect(callCount).toBe(1); + }); + + it("should populate error context fields from HTTP response", async () => { + const client = mockHttpClient(async () => + mockResponse(400, { code: "validation_error", message: "invalid tuple" }, { + statusText: "Bad Request", + headers: { + "content-type": "application/json", + "fga-request-id": "req-abc-123", + }, + }) + ); + + try { + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {}, data: "{\"tuple_key\":{}}" }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + fail("should have thrown"); + } catch (err: any) { + expect(err).toBeInstanceOf(FgaApiValidationError); + expect(err.statusCode).toBe(400); + expect(err.statusText).toBe("Bad Request"); + expect(err.requestURL).toBe(`${SdkConstants.TestApiUrl}/stores/s1/check`); + expect(err.method).toBe("POST"); + expect(err.apiErrorMessage).toBe("invalid tuple"); + expect(err.requestId).toBe("req-abc-123"); + expect(err.responseData).toEqual({ code: "validation_error", message: "invalid tuple" }); + } + }); + }); + + describe("retry behavior", () => { + it("should retry 429 and succeed on subsequent attempt", async () => { + let callCount = 0; + const client = mockHttpClient(async () => { + callCount++; + if (callCount === 1) { + return mockResponse(429, {}, { statusText: "Too Many Requests" }); + } + return mockResponse(200, { allowed: true }); + }); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "POST", headers: {} }, + { maxRetry: 2, minWaitInMs: 1 }, + client + ); + + expect(callCount).toBe(2); + expect(result!.response!.status).toBe(200); + expect(result!.response!.data).toEqual({ allowed: true }); + expect(result!.retries).toBe(1); + }); + + it("should retry 500 and succeed on subsequent attempt", async () => { + let callCount = 0; + const client = mockHttpClient(async () => { + callCount++; + if (callCount <= 2) { + return mockResponse(500, {}, { statusText: "Internal Server Error" }); + } + return mockResponse(200, { stores: [] }); + }); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores`, method: "GET", headers: {} }, + { maxRetry: 3, minWaitInMs: 1 }, + client + ); + + expect(callCount).toBe(3); + expect(result!.response!.status).toBe(200); + expect(result!.retries).toBe(2); + }); + + it("should not retry 400 errors", async () => { + let callCount = 0; + const client = mockHttpClient(async () => { + callCount++; + return mockResponse(400, { message: "bad" }); + }); + + await expect( + attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "POST", headers: {} }, + { maxRetry: 3, minWaitInMs: 1 }, + client + ) + ).rejects.toThrow(FgaApiValidationError); + + expect(callCount).toBe(1); + }); + }); + + describe("request body serialization", () => { + it("should pass string body as-is", async () => { + let capturedBody: string | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body as string; + return mockResponse(200, {}); + }); + + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "POST", headers: {}, data: "already-serialized" }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBe("already-serialized"); + }); + + it("should JSON-stringify object body", async () => { + let capturedBody: string | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body as string; + return mockResponse(200, {}); + }); + + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "POST", headers: {}, data: { key: "value" } }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBe("{\"key\":\"value\"}"); + }); + + it("should not set body when data is undefined", async () => { + let capturedBody: BodyInit | null | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body; + return mockResponse(200, {}); + }); + + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores`, method: "GET", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBeUndefined(); + }); + }); + }); + + describe("custom HttpClient injection", () => { + it("should use a custom fetch function passed via HttpClient", async () => { + let fetchCalled = false; + const customClient = mockHttpClient(async () => { + fetchCalled = true; + return mockResponse(200, { allowed: true }); + }); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + customClient + ); + + expect(fetchCalled).toBe(true); + expect(result!.response!.data).toEqual({ allowed: true }); + }); + + it("should apply defaultHeaders from HttpClient", async () => { + let capturedHeaders: Record | undefined; + const customClient = mockHttpClient( + async (_url, init) => { + capturedHeaders = init?.headers as Record; + return mockResponse(200, {}); + }, + { defaultHeaders: { "x-custom-sdk-header": "custom-value" } } + ); + + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/check`, method: "GET", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + customClient + ); + + expect(capturedHeaders!["x-custom-sdk-header"]).toBe("custom-value"); + }); + + it("should pass custom HttpClient to OpenFgaClient constructor", async () => { + let fetchCallCount = 0; + const customClient: HttpClient = { + fetch: (async (url: string) => { + fetchCallCount++; + return mockResponse(200, { stores: [], continuation_token: "" }); + }) as typeof globalThis.fetch, + defaultTimeout: 5000, + }; + + const fgaClient = new OpenFgaClient( + { + apiUrl: SdkConstants.TestApiUrl, + credentials: { method: "none" as any }, + }, + customClient + ); + + const result = await fgaClient.listStores(); + expect(fetchCallCount).toBe(1); + expect(result.stores).toEqual([]); + }); + }); +}); From 1946a4935367b3a361b1bf0f4dd615152a3777a2 Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Tue, 31 Mar 2026 16:13:50 -0400 Subject: [PATCH 4/6] chore: update changelog & documentation --- CHANGELOG.md | 6 +++++ README.md | 54 +++++++++++++++++++++++++++++++++++++++++-- SUPPORTED_RUNTIMES.md | 16 +++++++++---- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e809bf12..751e3e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased](https://github.com/openfga/js-sdk/compare/v0.9.4...HEAD) +- feat!: replace axios with native `fetch` for cross-platform support (Node.js, browsers, Deno, Cloudflare Workers, Vercel Edge). + - **BREAKING**: `AxiosResponse` is no longer exposed on `$response`. The new type is `FgaResponse` (with `status`, `statusText`, `headers`, `data`). + - **BREAKING**: `AxiosInstance` injection is replaced by the `HttpClient` interface (`{ fetch, defaultHeaders, defaultTimeout }`). + - Timeouts now use `AbortSignal.timeout()`. +- chore: all `options?: any` parameters in the API layer are now properly typed as `RequestBuilderOptions`. + ## v0.9.4 ### [v0.9.4](https://github.com/openfga/js-sdk/compare/v0.9.3...v0.9.4) (2026-03-31) diff --git a/README.md b/README.md index 05c195a0..4fe2da4f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This is an autogenerated JavaScript SDK for OpenFGA. It provides a wrapper aroun - [Getting Started](#getting-started) - [Initializing the API Client](#initializing-the-api-client) - [Custom Headers](#custom-headers) + - [Custom HTTP Client](#custom-http-client) - [Get your Store ID](#get-your-store-id) - [Calling the API](#calling-the-api) - [Stores](#stores) @@ -84,7 +85,7 @@ Using [yarn](https://yarnpkg.com): yarn add @openfga/sdk ``` -#### Supported Node.js Versions +### Supported Runtimes For details on the supported Node.js versions and our support policy, see [SUPPORTED_RUNTIMES.md](./SUPPORTED_RUNTIMES.md). @@ -94,7 +95,7 @@ For details on the supported Node.js versions and our support policy, see [SUPPO [Learn how to initialize your SDK](https://openfga.dev/docs/getting-started/setup-sdk-client) -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. +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, and would be particularly costly in the client credentials flow, as that flow will be performed on every request. In Node.js, connection pooling is handled automatically by the runtime's native `fetch` implementation. > The `OpenFgaClient` will by default retry API requests up to 3 times on 429 and 5xx errors. @@ -189,6 +190,55 @@ const result = await fgaClient.check({ }); ``` +### Custom HTTP Client + +The SDK uses the native `fetch` API by default. You can provide a custom `HttpClient` to control the underlying HTTP behavior. + +```javascript +const { OpenFgaClient } = require('@openfga/sdk'); // OR import { OpenFgaClient } from '@openfga/sdk'; + +const fgaClient = new OpenFgaClient({ + apiUrl: process.env.FGA_API_URL, + storeId: process.env.FGA_STORE_ID, + httpClient: { + fetch: globalThis.fetch.bind(globalThis), // or a custom fetch implementation + defaultHeaders: { + "X-Custom-Header": "value", + }, + defaultTimeout: 15000, // timeout in milliseconds (default: 10000) + }, +}); +``` + +The `HttpClient` interface accepts: + +| Property | Type | Description | +| --- | --- | --- | +| `fetch` | `typeof globalThis.fetch` | The fetch function to use for HTTP requests | +| `defaultHeaders` | `Record` | *(Optional)* Headers to include with every request | +| `defaultTimeout` | `number` | *(Optional)* Default request timeout in milliseconds | + +#### Response Type + +Most SDK methods that are a direct call to the API return an `FgaResponse` on the `$response` property: + +```typescript +interface FgaResponse { + status: number; + statusText: string; + headers: Record; + data: T; +} +``` + +You can access it via the `$response` property on the method's return value: + +```javascript +const { headers, status } = await fgaClient.getStore().$response; +``` + +Methods that have custom logic, such as `clientBatchCheck`, `listRelations` and non-transactional `write` operations, will not contain this field. + ### Get your Store ID You need your store id to call the OpenFGA API (unless it is to call the [CreateStore](#create-store) or [ListStores](#list-stores) methods). diff --git a/SUPPORTED_RUNTIMES.md b/SUPPORTED_RUNTIMES.md index ccb9e479..5b19df69 100644 --- a/SUPPORTED_RUNTIMES.md +++ b/SUPPORTED_RUNTIMES.md @@ -1,8 +1,10 @@ -## Node.js Support Policy for OpenFGA JS SDK +## Runtime Support Policy for OpenFGA JS SDK + +### Node.js Support The OpenFGA JavaScript SDK follows the upstream [Node.js release policy](https://nodejs.org/en/about/previous-releases). We support Node.js versions that are currently in **LTS** or **Maintenance** status. -### Currently Supported Versions +#### Currently Supported Versions | Node.js Version | Upstream Support Status | Tested in CI/CD Pipelines | |:----------------|:------------------------|:-------------------------:| @@ -11,12 +13,16 @@ The OpenFGA JavaScript SDK follows the upstream [Node.js release policy](https:/ | **24** | LTS | Yes | | **25** | Current | Yes | -### Support Details +#### Support Details -#### Best-Effort Support +##### Best-Effort Support We will make a best effort to maintain compatibility with Node.js versions that have reached End-of-Life (EOL), but **we will not test** against them in our CI/CD pipelines. This means you may be able to use the JS SDK with older versions of Node.js (with yarn you can use the `--ignore-engines` flag), but you may not be able to run tests (because many of our testing dependencies have dropped support for EOL versions). -#### Long-term plan +##### Long-term plan This best-effort support will not continue indefinitely. We plan to modernize our JS SDK, adopt more functionality now native to the language, and add support for alternate runtimes such as Deno, Cloudflare Workers, and Vercel Edge. + +### Alternate Runtimes + +Support for alternate runtimes such as Deno, Cloudflare Workers, and Vercel Edge is being gradually added. We will provide updates on our progress in the future. \ No newline at end of file From 33d64f31dcd489f6ac173fa9cc7b297c4ad033a7 Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Tue, 31 Mar 2026 19:10:00 -0400 Subject: [PATCH 5/6] chore(deps): refresh package-lock.json after dropping axios --- package-lock.json | 283 ---------------------------------------------- 1 file changed, 283 deletions(-) diff --git a/package-lock.json b/package-lock.json index c7dd9ef1..b3736393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.9.0", - "axios": "1.14.0", "jose": "5.10.0", "tiny-async-pool": "^2.1.0" }, @@ -2256,23 +2255,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -2485,19 +2467,6 @@ "dev": true, "license": "MIT" }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2705,18 +2674,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2796,15 +2753,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2815,20 +2763,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2873,51 +2807,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3330,26 +3219,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3367,22 +3236,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3405,15 +3258,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3434,30 +3278,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3468,19 +3288,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3575,18 +3382,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3626,45 +3421,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4729,15 +4485,6 @@ "tmpl": "1.0.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4759,27 +4506,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -5248,15 +4974,6 @@ "node": ">= 8" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", From ef3a6384ca6e959af513b92076ef9cfaa33ff046 Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Tue, 31 Mar 2026 19:14:41 -0400 Subject: [PATCH 6/6] fix: fixes and refinements to the httpclient --- README.md | 17 ++- api.ts | 7 +- client.ts | 2 +- common.ts | 62 +++++++- errors.ts | 6 +- tests/errors.test.ts | 84 +++++++++- tests/fetch-http-client.test.ts | 262 ++++++++++++++++++++++++++++++++ 7 files changed, 420 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4fe2da4f..8dfe1aa4 100644 --- a/README.md +++ b/README.md @@ -197,17 +197,19 @@ The SDK uses the native `fetch` API by default. You can provide a custom `HttpCl ```javascript const { OpenFgaClient } = require('@openfga/sdk'); // OR import { OpenFgaClient } from '@openfga/sdk'; -const fgaClient = new OpenFgaClient({ - apiUrl: process.env.FGA_API_URL, - storeId: process.env.FGA_STORE_ID, - httpClient: { +const fgaClient = new OpenFgaClient( + { + apiUrl: process.env.FGA_API_URL, + storeId: process.env.FGA_STORE_ID, + }, + { fetch: globalThis.fetch.bind(globalThis), // or a custom fetch implementation defaultHeaders: { "X-Custom-Header": "value", }, defaultTimeout: 15000, // timeout in milliseconds (default: 10000) - }, -}); + } +); ``` The `HttpClient` interface accepts: @@ -234,7 +236,8 @@ interface FgaResponse { You can access it via the `$response` property on the method's return value: ```javascript -const { headers, status } = await fgaClient.getStore().$response; +const res = await fgaClient.getStore(); +const { headers, status } = res.$response; ``` Methods that have custom logic, such as `clientBatchCheck`, `listRelations` and non-transactional `write` operations, will not contain this field. diff --git a/api.ts b/api.ts index 2273a2c9..85bc26ea 100644 --- a/api.ts +++ b/api.ts @@ -484,7 +484,10 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: ...TelemetryAttributes.fromRequestBody(request.body), }); }, - async executeStreamedApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise<(httpClient?: HttpClient) => PromiseResult> { + async executeStreamedApiRequest( + request: RequestBuilderParams, + options?: RequestBuilderOptions + ): Promise<(httpClient?: HttpClient) => Promise> { const localVarAxiosArgs = RequestBuilder(request, { ...configuration.baseOptions, ...options }); return createStreamingRequestFunction(localVarAxiosArgs, globalHttpClient, configuration, credentials, { [TelemetryAttribute.FgaClientRequestMethod]: request.operationName, @@ -993,7 +996,7 @@ export class OpenFgaApi extends BaseAPI { * @throws { FgaError } * @memberof OpenFgaApi */ - public executeStreamedApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise> { + public executeStreamedApiRequest(request: RequestBuilderParams, options?: RequestBuilderOptions): Promise { return OpenFgaApiFp(this.configuration, this.credentials).executeStreamedApiRequest(request, options).then((request) => request(this.httpClient)); } } diff --git a/client.ts b/client.ts index 3597a982..2bde222a 100644 --- a/client.ts +++ b/client.ts @@ -891,7 +891,7 @@ export class OpenFgaClient extends BaseAPI { } finally { // Ensure underlying HTTP connection closes if consumer stops early if (source && typeof (source as ReadableStream).cancel === "function") { - try { (source as ReadableStream).cancel(); } catch { } + try { await (source as ReadableStream).cancel(); } catch { } } else if (source && typeof source.destroy === "function") { try { source.destroy(); } catch { } } diff --git a/common.ts b/common.ts index 0ccad8b5..f6d63f03 100644 --- a/common.ts +++ b/common.ts @@ -303,14 +303,55 @@ export async function attemptHttpRequest( } } + const timeoutMs = request.timeout ?? httpClient.defaultTimeout ?? 10000; + let signal: AbortSignal | undefined; + if (typeof AbortSignal !== "undefined" && typeof (AbortSignal as any).timeout === "function") { + // Use native AbortSignal.timeout when available. + signal = (AbortSignal as any).timeout(timeoutMs); + } else if (typeof AbortController !== "undefined") { + // Fallback for environments without AbortSignal.timeout. + const controller = new AbortController(); + setTimeout(() => controller.abort(), timeoutMs); + signal = controller.signal; + } const fetchInit: RequestInit = { method: request.method || "GET", headers: fetchHeaders, - signal: AbortSignal.timeout(request.timeout ?? httpClient.defaultTimeout ?? 10000), + signal, }; if (request.data !== undefined) { - fetchInit.body = typeof request.data === "string" ? request.data : JSON.stringify(request.data); + if (typeof request.data === "string") { + fetchInit.body = request.data; + } else { + const contentTypeHeader = Object.entries(fetchHeaders).find( + ([name]) => name.toLowerCase() === "content-type", + )?.[1]; + + const contentType = contentTypeHeader?.split(";")[0].trim().toLowerCase(); + + if (contentType === "application/x-www-form-urlencoded") { + if (request.data instanceof URLSearchParams) { + fetchInit.body = request.data.toString(); + } else { + const formParams = new URLSearchParams(); + for (const [key, value] of Object.entries(request.data as Record)) { + if (value === undefined || value === null) continue; + formParams.append(key, String(value)); + } + fetchInit.body = formParams.toString(); + } + } else if ( + contentType === "application/json" || + contentType === "text/json" || + (contentType?.endsWith("+json") ?? false) + ) { + fetchInit.body = JSON.stringify(request.data); + } else { + // Default to JSON serialization to preserve existing behavior. + fetchInit.body = JSON.stringify(request.data); + } + } } const response = await httpClient.fetch(request.url, fetchInit); @@ -318,15 +359,17 @@ export async function attemptHttpRequest( if (!response.ok) { // Non-2xx status — build error context for retry/error handling + // Read body as text first to avoid consuming the stream with response.json(). let responseData: any; try { - responseData = await response.json(); - } catch { + const rawBody = await response.text(); try { - responseData = await response.text(); + responseData = JSON.parse(rawBody); } catch { - responseData = undefined; + responseData = rawBody; } + } catch { + responseData = undefined; } const errCtx: HttpErrorContext = { @@ -376,7 +419,7 @@ export async function attemptHttpRequest( const contentType = response.headers.get("content-type") || ""; if (response.status === 204 || response.headers.get("content-length") === "0") { data = undefined; - } else if (contentType.includes("application/json")) { + } else if (isJsonMime(contentType)) { data = await response.json(); } else { data = await response.text(); @@ -721,7 +764,10 @@ export const createStreamingRequestFunction = function (axiosArgs: RequestArgs, const wrappedResponse = await attemptHttpRequest(fetchRequestConfig, { maxRetry, minWaitInMs, - }, client); + }, client, { + telemetry: configuration.telemetry, + userAgent: configuration.baseOptions?.headers?.["User-Agent"], + }); const response = wrappedResponse?.response; const result: any = response?.data; // raw ReadableStream diff --git a/errors.ts b/errors.ts index cf916292..ffc8841e 100644 --- a/errors.ts +++ b/errors.ts @@ -86,7 +86,11 @@ export class FgaApiError extends FgaError { public requestId?: string; constructor(err: HttpErrorContext | Error, msg?: string) { - super(msg ? msg : (err instanceof Error ? err : undefined)); + super(msg ? msg : (err instanceof Error ? err : undefined), + !msg && !(err instanceof Error) + ? `FGA API Error: ${err.requestMethod ?? "Unknown Method"} ${err.requestUrl ?? "Unknown URL"} - ${err.status ?? "Unknown Status"}${err.statusText ? ` ${err.statusText}` : ""}` + : undefined + ); if (err instanceof Error) { if (err.stack) this.stack = err.stack; return; diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 7fd29d9a..2285a99d 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -1,4 +1,5 @@ -import { FgaError } from "../errors"; +import { FgaError, FgaApiError, HttpErrorContext } from "../errors"; +import SdkConstants from "../constants"; describe("errors.ts", () => { describe("FgaError", () => { @@ -26,4 +27,85 @@ describe("errors.ts", () => { expect(err.message).toBe(""); }); }); + + describe("FgaApiError", () => { + test("should produce descriptive message from HttpErrorContext with all fields", () => { + const ctx: HttpErrorContext = { + status: 500, + statusText: "Internal Server Error", + requestUrl: `${SdkConstants.TestApiUrl}/stores/s1/check`, + requestMethod: "POST", + }; + const err = new FgaApiError(ctx); + + expect(err.message).toBe(`FGA API Error: POST ${SdkConstants.TestApiUrl}/stores/s1/check - 500 Internal Server Error`); + }); + + test("should produce descriptive message without statusText", () => { + const ctx: HttpErrorContext = { + status: 502, + requestUrl: `${SdkConstants.TestApiUrl}/stores/s1/check`, + requestMethod: "GET", + }; + const err = new FgaApiError(ctx); + + expect(err.message).toBe(`FGA API Error: GET ${SdkConstants.TestApiUrl}/stores/s1/check - 502`); + }); + + test("should use fallback placeholders when HttpErrorContext fields are missing", () => { + const ctx: HttpErrorContext = {}; + const err = new FgaApiError(ctx); + + expect(err.message).toBe("FGA API Error: Unknown Method Unknown URL - Unknown Status"); + }); + + test("should use explicit msg over generated default", () => { + const ctx: HttpErrorContext = { + status: 500, + statusText: "Internal Server Error", + requestUrl: `${SdkConstants.TestApiUrl}/check`, + requestMethod: "POST", + }; + const err = new FgaApiError(ctx, "custom error message"); + + expect(err.message).toBe("custom error message"); + }); + + test("should use Error message when constructed with Error", () => { + const inner = new Error("something went wrong"); + const err = new FgaApiError(inner); + + expect(err.message).toBe("FGA Error: something went wrong"); + }); + + test("should use explicit msg over Error message", () => { + const inner = new Error("something went wrong"); + const err = new FgaApiError(inner, "override message"); + + expect(err.message).toBe("override message"); + }); + + test("should populate context fields from HttpErrorContext", () => { + const ctx: HttpErrorContext = { + status: 418, + statusText: "I'm a teapot", + requestUrl: `${SdkConstants.TestApiUrl}/stores/s1/check`, + requestMethod: "POST", + requestData: { tuple_key: {} }, + data: { message: "api error detail" }, + headers: { "fga-request-id": "req-xyz" }, + }; + const err = new FgaApiError(ctx); + + expect(err.statusCode).toBe(418); + expect(err.statusText).toBe("I'm a teapot"); + expect(err.requestURL).toBe(`${SdkConstants.TestApiUrl}/stores/s1/check`); + expect(err.method).toBe("POST"); + expect(err.requestData).toEqual({ tuple_key: {} }); + expect(err.apiErrorMessage).toBe("api error detail"); + expect(err.requestId).toBe("req-xyz"); + expect(err.storeId).toBe("s1"); + expect(err.endpointCategory).toBe("check"); + }); + }); }); diff --git a/tests/fetch-http-client.test.ts b/tests/fetch-http-client.test.ts index c5a81ffb..8f42fe0f 100644 --- a/tests/fetch-http-client.test.ts +++ b/tests/fetch-http-client.test.ts @@ -118,6 +118,58 @@ describe("fetch-based HTTP client", () => { expect(result!.response!.data).toBe("plain text body"); }); + it("should return text for text/json content type (not matched by isJsonMime)", async () => { + const client = mockHttpClient(async () => + new Response(JSON.stringify({ key: "value" }), { + status: 200, + headers: { "content-type": "text/json" }, + }) + ); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/something`, method: "GET", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + // text/json is not recognized by isJsonMime, so it's returned as text + expect(result!.response!.data).toBe("{\"key\":\"value\"}"); + }); + + it("should parse response as JSON for application/vnd.api+json content type", async () => { + const client = mockHttpClient(async () => + new Response(JSON.stringify({ items: [1, 2] }), { + status: 200, + headers: { "content-type": "application/vnd.api+json" }, + }) + ); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/something`, method: "GET", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(result!.response!.data).toEqual({ items: [1, 2] }); + }); + + it("should parse response as JSON for application/json with charset parameter", async () => { + const client = mockHttpClient(async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json; charset=utf-8" }, + }) + ); + + const result = await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/something`, method: "GET", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(result!.response!.data).toEqual({ ok: true }); + }); + it("should return the raw body for stream responseType", async () => { const stream = new ReadableStream({ start(controller) { @@ -426,6 +478,29 @@ describe("fetch-based HTTP client", () => { expect(callCount).toBe(1); }); + it("should include method, URL, and status in generic FgaApiError message for unmapped status", async () => { + const client = mockHttpClient(async () => + mockResponse(501, {}, { statusText: "Not Implemented" }) + ); + + try { + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + fail("should have thrown"); + } catch (err: any) { + expect(err).toBeInstanceOf(FgaApiError); + // Should NOT be one of the specialized subclasses + expect(err.name).toBe("FgaApiError"); + expect(err.message).toContain("POST"); + expect(err.message).toContain(`${SdkConstants.TestApiUrl}/stores/s1/check`); + expect(err.message).toContain("501"); + expect(err.message).toContain("Not Implemented"); + } + }); + it("should populate error context fields from HTTP response", async () => { const client = mockHttpClient(async () => mockResponse(400, { code: "validation_error", message: "invalid tuple" }, { @@ -455,6 +530,51 @@ describe("fetch-based HTTP client", () => { expect(err.responseData).toEqual({ code: "validation_error", message: "invalid tuple" }); } }); + + it("should capture non-JSON error body as text instead of dropping it", async () => { + const client = mockHttpClient(async () => + new Response("plain text error detail", { + status: 400, + statusText: "Bad Request", + headers: { "content-type": "text/plain" }, + }) + ); + + try { + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + fail("should have thrown"); + } catch (err: any) { + expect(err).toBeInstanceOf(FgaApiValidationError); + expect(err.responseData).toBe("plain text error detail"); + } + }); + + it("should parse JSON error body when content-type is application/json", async () => { + const client = mockHttpClient(async () => + new Response(JSON.stringify({ code: "validation_error", message: "bad request" }), { + status: 400, + statusText: "Bad Request", + headers: { "content-type": "application/json" }, + }) + ); + + try { + await attemptHttpRequest( + { url: `${SdkConstants.TestApiUrl}/stores/s1/check`, method: "POST", headers: {} }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + fail("should have thrown"); + } catch (err: any) { + expect(err).toBeInstanceOf(FgaApiValidationError); + expect(err.responseData).toEqual({ code: "validation_error", message: "bad request" }); + expect(err.apiErrorMessage).toBe("bad request"); + } + }); }); describe("retry behavior", () => { @@ -568,6 +688,148 @@ describe("fetch-based HTTP client", () => { expect(capturedBody).toBeUndefined(); }); + + it("should serialize object as form-urlencoded when content-type is application/x-www-form-urlencoded", async () => { + let capturedBody: string | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body as string; + return mockResponse(200, {}); + }); + + await attemptHttpRequest( + { + url: `${SdkConstants.TestApiUrl}/oauth/token`, + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + data: { client_id: "my-id", client_secret: "my-secret", grant_type: "client_credentials" }, + }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBeDefined(); + const params = new URLSearchParams(capturedBody!); + expect(params.get("client_id")).toBe("my-id"); + expect(params.get("client_secret")).toBe("my-secret"); + expect(params.get("grant_type")).toBe("client_credentials"); + }); + + it("should serialize URLSearchParams as form-urlencoded when content-type is application/x-www-form-urlencoded", async () => { + let capturedBody: string | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body as string; + return mockResponse(200, {}); + }); + + const formData = new URLSearchParams(); + formData.append("client_id", "my-id"); + formData.append("scope", "openid"); + + await attemptHttpRequest( + { + url: `${SdkConstants.TestApiUrl}/oauth/token`, + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + data: formData, + }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBeDefined(); + const params = new URLSearchParams(capturedBody!); + expect(params.get("client_id")).toBe("my-id"); + expect(params.get("scope")).toBe("openid"); + }); + + it("should skip null and undefined values in form-urlencoded serialization", async () => { + let capturedBody: string | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body as string; + return mockResponse(200, {}); + }); + + await attemptHttpRequest( + { + url: `${SdkConstants.TestApiUrl}/oauth/token`, + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + data: { client_id: "my-id", optional_field: null, another: undefined, keep: "yes" }, + }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBeDefined(); + const params = new URLSearchParams(capturedBody!); + expect(params.get("client_id")).toBe("my-id"); + expect(params.get("keep")).toBe("yes"); + expect(params.has("optional_field")).toBe(false); + expect(params.has("another")).toBe(false); + }); + + it("should JSON-stringify object body when content-type is text/json", async () => { + let capturedBody: string | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body as string; + return mockResponse(200, {}); + }); + + await attemptHttpRequest( + { + url: `${SdkConstants.TestApiUrl}/check`, + method: "POST", + headers: { "Content-Type": "text/json" }, + data: { key: "value" }, + }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBe("{\"key\":\"value\"}"); + }); + + it("should JSON-stringify object body when content-type is application/vnd.api+json", async () => { + let capturedBody: string | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body as string; + return mockResponse(200, {}); + }); + + await attemptHttpRequest( + { + url: `${SdkConstants.TestApiUrl}/check`, + method: "POST", + headers: { "Content-Type": "application/vnd.api+json" }, + data: { key: "value" }, + }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBe("{\"key\":\"value\"}"); + }); + + it("should default to JSON serialization when content-type is absent", async () => { + let capturedBody: string | undefined; + const client = mockHttpClient(async (_url, init) => { + capturedBody = init?.body as string; + return mockResponse(200, {}); + }); + + await attemptHttpRequest( + { + url: `${SdkConstants.TestApiUrl}/check`, + method: "POST", + headers: {}, + data: { key: "value" }, + }, + { maxRetry: 0, minWaitInMs: 100 }, + client + ); + + expect(capturedBody).toBe("{\"key\":\"value\"}"); + }); }); });