diff --git a/README.md b/README.md index ff49a3c4..e59d2e91 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ This is an autogenerated JavaScript SDK for OpenFGA. It provides a wrapper aroun - [Assertions](#assertions) - [Read Assertions](#read-assertions) - [Write Assertions](#write-assertions) + - [Calling Other Endpoints](#calling-other-endpoints) - [Retries](#retries) - [API Endpoints](#api-endpoints) - [Models](#models) @@ -882,6 +883,74 @@ const response = await fgaClient.writeAssertions([{ }], options); ``` +### Calling Other Endpoints + +In certain cases you may want to call other APIs not yet wrapped by the SDK. You can do so by using the `apiExecutor` method available on the `OpenFgaClient`. The `apiExecutor` method allows you to make raw HTTP calls to any OpenFGA endpoint by specifying the HTTP method, path, body, query parameters, and path parameters, while still honoring the client configuration (authentication, telemetry, retries, and error handling). + +This is useful when: +- You want to call a new endpoint that is not yet supported by the SDK +- You are using an earlier version of the SDK that doesn't yet support a particular endpoint +- You have a custom endpoint deployed that extends the OpenFGA API + +#### Example: Calling a Custom Endpoint with POST + +```javascript +const { OpenFgaClient } = require('@openfga/sdk'); + +const fgaClient = new OpenFgaClient({ + apiUrl: process.env.FGA_API_URL, + storeId: process.env.FGA_STORE_ID, +}); + +// Call a custom endpoint using path parameters +const response = await fgaClient.apiExecutor({ + operationName: 'CustomEndpoint', // For telemetry/logging + method: 'POST', + path: '/stores/{store_id}/custom-endpoint', + pathParams: { store_id: process.env.FGA_STORE_ID }, + body: { + user: 'user:bob', + action: 'custom_action', + resource: 'resource:123', + }, + queryParams: { + page_size: 20, + }, +}); + +console.log('Response:', response); +``` + +#### Example: Calling an Existing Endpoint with GET + +```javascript +// Get a list of stores with query parameters +const stores = await fgaClient.apiExecutor({ + method: 'GET', + path: '/stores', + queryParams: { + page_size: 10, + continuation_token: 'eyJwayI6...', + }, +}); + +console.log('Stores:', stores); +``` + +#### Example: Using Path Parameters + +Path parameters are specified in the path using `{param_name}` syntax and are replaced with URL-encoded values from the `pathParams` object: + +```javascript +const response = await fgaClient.apiExecutor({ + method: 'GET', + path: '/stores/{store_id}/authorization-models/{model_id}', + pathParams: { + store_id: 'your-store-id', + model_id: 'your-model-id', + }, +}); +``` ### Retries diff --git a/api.ts b/api.ts index d04d1a6d..a138f8b2 100644 --- a/api.ts +++ b/api.ts @@ -28,6 +28,7 @@ import { import { Configuration } from "./configuration"; import { Credentials } from "./credentials"; import { assertParamExists } from "./validation"; +import { FgaValidationError } from "./errors"; import { AbortedMessageResponse, @@ -831,6 +832,74 @@ export const OpenFgaApiAxiosParamCreator = function (configuration: Configuratio localVarRequestOptions.headers = {...localVarHeaderParameter, ...options.headers}; localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions); + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Make a raw HTTP request to an arbitrary API endpoint. + * This method provides an escape hatch for calling new or experimental endpoints + * that may not yet have dedicated SDK methods. + * @summary Make a raw HTTP request + * @param {'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'} method - HTTP method + * @param {string} path - API path with optional template parameters (e.g., '/stores/{store_id}/my-endpoint') + * @param {any} [body] - Optional request body + * @param {Record} [queryParams] - Optional query parameters + * @param {Record} [pathParams] - Optional path parameters to replace template variables + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + apiExecutor: (method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body?: any, queryParams?: Record, pathParams?: Record, options: any = {}): RequestArgs => { + // Build path by replacing template parameters with URL-encoded values + let localVarPath = path; + if (pathParams) { + for (const [key, value] of Object.entries(pathParams)) { + // Use split/join to replace ALL occurrences of the parameter + localVarPath = localVarPath.split(`{${key}}`).join(encodeURIComponent(value)); + } + } + + // Validate that all path parameters have been replaced + if (localVarPath.includes("{") && localVarPath.includes("}")) { + const unresolvedMatch = localVarPath.match(/\{([^}]+)\}/); + if (unresolvedMatch) { + throw new FgaValidationError(unresolvedMatch[1], `Path parameter '${unresolvedMatch[1]}' was not provided for path: ${path}`); + } + } + + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: method, ...baseOptions, ...options }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // Add query parameters if provided + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined) { + localVarQueryParameter[key] = value; + } + } + } + + // Set Content-Type for requests with body + if (body !== undefined && (method === "POST" || method === "PUT" || method === "PATCH")) { + localVarHeaderParameter["Content-Type"] = "application/json"; + } + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + localVarRequestOptions.headers = { ...localVarHeaderParameter, ...options.headers }; + + if (body !== undefined) { + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions); + } + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -1128,6 +1197,26 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: ...TelemetryAttributes.fromRequestBody(body) }); }, + /** + * Make a raw HTTP request to an arbitrary API endpoint. + * This method provides an escape hatch for calling new or experimental endpoints + * that may not yet have dedicated SDK methods. + * @summary Make a raw HTTP request + * @param {'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'} method - HTTP method + * @param {string} path - API path with optional template parameters (e.g., '/stores/{store_id}/my-endpoint') + * @param {any} [body] - Optional request body + * @param {Record} [queryParams] - Optional query parameters + * @param {Record} [pathParams] - Optional path parameters to replace template variables + * @param {string} [operationName] - Optional operation name for telemetry (defaults to "ApiExecutor") + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + async apiExecutor(method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body?: any, queryParams?: Record, pathParams?: Record, operationName?: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + const localVarAxiosArgs = localVarAxiosParamCreator.apiExecutor(method, path, body, queryParams, pathParams, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { + [TelemetryAttribute.FgaClientRequestMethod]: operationName || "ApiExecutor", + }); + }, }; }; @@ -1341,6 +1430,23 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): PromiseResult { return localVarFp.writeAuthorizationModel(storeId, body, options).then((request) => request(axios)); }, + /** + * Make a raw HTTP request to an arbitrary API endpoint. + * This method provides an escape hatch for calling new or experimental endpoints + * that may not yet have dedicated SDK methods. + * @summary Make a raw HTTP request + * @param {'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'} method - HTTP method + * @param {string} path - API path with optional template parameters (e.g., '/stores/{store_id}/my-endpoint') + * @param {any} [body] - Optional request body + * @param {Record} [queryParams] - Optional query parameters + * @param {Record} [pathParams] - Optional path parameters to replace template variables + * @param {string} [operationName] - Optional operation name for telemetry (defaults to "ApiExecutor") + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + apiExecutor(method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body?: any, queryParams?: Record, pathParams?: Record, operationName?: string, options?: any): PromiseResult { + return localVarFp.apiExecutor(method, path, body, queryParams, pathParams, operationName, options).then((request) => request(axios)); + }, }; }; @@ -1588,6 +1694,25 @@ export class OpenFgaApi extends BaseAPI { public writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): Promise> { return OpenFgaApiFp(this.configuration, this.credentials).writeAuthorizationModel(storeId, body, options).then((request) => request(this.axios)); } + + /** + * Make a raw HTTP request to an arbitrary API endpoint. + * This method provides an escape hatch for calling new or experimental endpoints + * that may not yet have dedicated SDK methods. + * @summary Make a raw HTTP request + * @param {'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'} method - HTTP method + * @param {string} path - API path with optional template parameters (e.g., '/stores/{store_id}/my-endpoint') + * @param {any} [body] - Optional request body + * @param {Record} [queryParams] - Optional query parameters + * @param {Record} [pathParams] - Optional path parameters to replace template variables + * @param {string} [operationName] - Optional operation name for telemetry (defaults to "ApiExecutor") + * @param {*} [options] Override http request option. + * @throws { FgaError } + * @memberof OpenFgaApi + */ + public apiExecutor(method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body?: any, queryParams?: Record, pathParams?: Record, operationName?: string, options?: any): Promise> { + return OpenFgaApiFp(this.configuration, this.credentials).apiExecutor(method, path, body, queryParams, pathParams, operationName, options).then((request) => request(this.axios)); + } } diff --git a/client.ts b/client.ts index 00805c32..08889b84 100644 --- a/client.ts +++ b/client.ts @@ -260,6 +260,42 @@ export type ClientListRelationsRequest = Omit)[]; +/** + * HTTP methods supported by apiExecutor + */ +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + +/** + * Request parameters for apiExecutor method + */ +export interface ClientApiExecutorParams { + /** + * Operation name for telemetry and logging (e.g., "CustomCheck", "CustomEndpoint"). + * Used for observability when calling new or experimental endpoints. + * Defaults to "ApiExecutor" if not provided. + */ + operationName?: string; + /** HTTP method */ + method: HttpMethod; + /** + * API path with optional template parameters. + * Template parameters should be in the format {param_name} and will be replaced + * with URL-encoded values from pathParams. + * Example: '/stores/{store_id}/custom-endpoint' + */ + path: string; + /** + * Path parameters to replace template variables in the path. + * Values will be URL-encoded automatically. + * Example: { store_id: "abc123" } will replace {store_id} in the path. + */ + pathParams?: Record; + /** Optional request body for POST/PUT/PATCH requests */ + body?: any; + /** Optional query parameters */ + queryParams?: Record; +} + export class OpenFgaClient extends BaseAPI { public api: OpenFgaApi; public authorizationModelId?: string; @@ -992,4 +1028,55 @@ export class OpenFgaClient extends BaseAPI { })) }, options); } + + + /** + * apiExecutor lets you send any HTTP request directly to an OpenFGA API endpoint. + * It’s useful when you need to call a new or experimental API that doesn’t yet have a built-in method in the SDK. + * You still get the benefits of the SDK, like authentication, configuration, and consistent error handling. + * + * @param {ClientApiExecutorParams} request - The request parameters + * @param {HttpMethod} request.method - HTTP method (GET, POST, PUT, DELETE, PATCH) + * @param {string} request.path - API path (e.g., '/stores/{store_id}/my-endpoint') + * @param {any} [request.body] - Optional request body for POST/PUT/PATCH requests + * @param {Record} [request.queryParams] - Optional query parameters + * @param {ClientRequestOpts} [options] - Request options + * @param {object} [options.headers] - Custom headers to send alongside the request + * @param {object} [options.retryParams] - Override the retry parameters for this request + * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request + * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated + * @throws { FgaError } + * + * @example + * // Call a new endpoint using path parameters (recommended) + * const response = await client.apiExecutor({ + * operationName: 'CustomCheck', + * method: 'POST', + * path: '/stores/{store_id}/custom-endpoint', + * pathParams: { store_id: 'my-store-id' }, + * body: { foo: 'bar' }, + * }); + * + * @example + * // Call an existing endpoint with query parameters + * const stores = await client.apiExecutor({ + * method: 'GET', + * path: '/stores', + * queryParams: { page_size: 10 }, + * }); + */ + async apiExecutor( + request: ClientApiExecutorParams, + options: ClientRequestOpts = {} + ): PromiseResult { + return this.api.apiExecutor( + request.method, + request.path, + request.body, + request.queryParams, + request.pathParams, + request.operationName, + options + ); + } } diff --git a/tests/apiExecutor.test.ts b/tests/apiExecutor.test.ts new file mode 100644 index 00000000..0418f6a3 --- /dev/null +++ b/tests/apiExecutor.test.ts @@ -0,0 +1,495 @@ +import * as nock from "nock"; + +import { + OpenFgaClient, + UserClientConfigurationParams, + FgaApiNotFoundError, + FgaApiValidationError, +} from "../index"; +import { CredentialsMethod } from "../credentials"; +import { baseConfig, defaultConfiguration, OPENFGA_STORE_ID } from "./helpers/default-config"; + +describe("OpenFgaClient.apiExecutor", () => { + const basePath = defaultConfiguration.getBasePath(); + const testConfig: UserClientConfigurationParams = { + ...baseConfig, + credentials: { method: CredentialsMethod.None } + }; + + beforeAll(() => { + nock.restore(); + nock.cleanAll(); + nock.activate(); + nock.disableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + nock.restore(); + }); + + describe("GET requests", () => { + it("should make GET requests successfully", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const responseData = { stores: [{ id: "store-1", name: "Test Store" }] }; + + nock(basePath) + .get("/stores") + .reply(200, responseData); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores", + }); + + expect(result.stores).toEqual(responseData.stores); + }); + + it("should include query parameters in GET requests", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const responseData = { stores: [] }; + + nock(basePath) + .get("/stores") + .query({ page_size: 10, name: "test" }) + .reply(200, responseData); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores", + queryParams: { page_size: 10, name: "test" }, + }); + + expect(result.stores).toEqual([]); + }); + + it("should return $response with the full Axios response", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const responseData = { stores: [] }; + + nock(basePath) + .get("/stores") + .reply(200, responseData); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores", + }); + + expect(result.$response).toBeDefined(); + expect(result.$response.status).toBe(200); + }); + }); + + describe("POST requests", () => { + it("should make POST requests with body", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const requestBody = { name: "New Store" }; + const responseData = { id: "new-store-id", name: "New Store" }; + + nock(basePath) + .post("/stores", requestBody) + .reply(201, responseData); + + const result = await fgaClient.apiExecutor({ + method: "POST", + path: "/stores", + body: requestBody, + }); + + expect(result.id).toBe("new-store-id"); + }); + + it("should set Content-Type header for POST requests with body", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const requestBody = { foo: "bar" }; + + nock(basePath, { + reqheaders: { + "Content-Type": "application/json", + }, + }) + .post("/stores", requestBody) + .reply(200, {}); + + await fgaClient.apiExecutor({ + method: "POST", + path: "/stores", + body: requestBody, + }); + + // If we get here without error, the Content-Type header was correctly applied + expect(true).toBe(true); + }); + }); + + describe("PUT requests", () => { + it("should make PUT requests with body", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const requestBody = { name: "Updated Store" }; + + nock(basePath) + .put(`/stores/${OPENFGA_STORE_ID}`, requestBody) + .reply(200, { success: true }); + + const result = await fgaClient.apiExecutor({ + method: "PUT", + path: `/stores/${OPENFGA_STORE_ID}`, + body: requestBody, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("DELETE requests", () => { + it("should make DELETE requests", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + nock(basePath) + .delete(`/stores/${OPENFGA_STORE_ID}`) + .reply(204, {}); + + const result = await fgaClient.apiExecutor({ + method: "DELETE", + path: `/stores/${OPENFGA_STORE_ID}`, + }); + + expect(result.$response.status).toBe(204); + }); + }); + + describe("PATCH requests", () => { + it("should make PATCH requests with body", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const requestBody = { name: "Patched Store" }; + + nock(basePath) + .patch(`/stores/${OPENFGA_STORE_ID}`, requestBody) + .reply(200, { success: true }); + + const result = await fgaClient.apiExecutor({ + method: "PATCH", + path: `/stores/${OPENFGA_STORE_ID}`, + body: requestBody, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("custom headers", () => { + it("should include custom headers in requests", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + nock(basePath, { + reqheaders: { + "X-Custom-Header": "custom-value", + }, + }) + .get("/stores") + .reply(200, {}); + + await fgaClient.apiExecutor( + { + method: "GET", + path: "/stores", + }, + { + headers: { "X-Custom-Header": "custom-value" }, + } + ); + + // If we get here without error, the custom header was correctly applied + expect(true).toBe(true); + }); + }); + + describe("error handling", () => { + it("should throw FgaApiNotFoundError for 404 responses", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + nock(basePath) + .get("/nonexistent-endpoint") + .reply(404, { + code: "undefined_endpoint", + message: "Not found", + }); + + await expect( + fgaClient.apiExecutor({ + method: "GET", + path: "/nonexistent-endpoint", + }) + ).rejects.toThrow(FgaApiNotFoundError); + }); + + it("should throw FgaApiValidationError for 400 responses", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + nock(basePath) + .post("/stores") + .reply(400, { + code: "validation_error", + message: "Invalid request", + }); + + await expect( + fgaClient.apiExecutor({ + method: "POST", + path: "/stores", + body: { invalid: "data" }, + }) + ).rejects.toThrow(FgaApiValidationError); + }); + }); + + +}); + +describe("OpenFgaClient.apiExecutor - path parameters", () => { + const basePath = defaultConfiguration.getBasePath(); + const testConfig: UserClientConfigurationParams = { + ...baseConfig, + credentials: { method: CredentialsMethod.None } + }; + + beforeAll(() => { + nock.restore(); + nock.cleanAll(); + nock.activate(); + nock.disableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + nock.restore(); + }); + + describe("path parameter replacement", () => { + it("should replace path parameters with values (single_parameter)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const storeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + const responseData = { id: storeId, name: "Test Store" }; + + nock(basePath) + .get(`/stores/${storeId}`) + .reply(200, responseData); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores/{store_id}", + pathParams: { store_id: storeId }, + }); + + expect(result.id).toBe(storeId); + }); + + it("should replace multiple path parameters (multiple_parameters)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const storeId = "store-123"; + const modelId = "model-456"; + const responseData = { id: modelId }; + + nock(basePath) + .get(`/stores/${storeId}/authorization-models/${modelId}`) + .reply(200, responseData); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores/{store_id}/authorization-models/{model_id}", + pathParams: { store_id: storeId, model_id: modelId }, + }); + + expect(result.id).toBe(modelId); + }); + + it("should URL-encode path parameter values with spaces (parameter_with_special_characters)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const storeId = "store id with spaces"; + const encodedStoreId = "store%20id%20with%20spaces"; + + nock(basePath) + .get(`/stores/${encodedStoreId}`) + .reply(200, { id: storeId }); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores/{store_id}", + pathParams: { store_id: storeId }, + }); + + expect(result.id).toBe(storeId); + }); + + it("should URL-encode path parameter values with URL-unsafe characters (parameter_with_url_unsafe_characters)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const id = "test/with?special&chars"; + const encodedId = encodeURIComponent(id); + + // Use regex matching to handle URL-encoded special characters properly + nock(basePath) + .get(new RegExp(`/items/${encodedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) + .reply(200, { id: id }); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/items/{id}", + pathParams: { id: id }, + }); + + expect(result.id).toBe(id); + }); + + it("should URL-encode unicode characters in path parameters (parameter_with_unicode)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const name = "用户"; + const encodedName = encodeURIComponent(name); // %E7%94%A8%E6%88%B7 + + // Use regex matching to handle URL-encoded unicode characters properly + nock(basePath) + .get(new RegExp(`/users/${encodedName}`)) + .reply(200, { name: name }); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/users/{name}", + pathParams: { name: name }, + }); + + expect(result.name).toBe(name); + }); + + it("should ignore unused path parameters (unused_parameters_ignored)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const storeId = "123"; + + nock(basePath) + .get(`/stores/${storeId}`) + .reply(200, { id: storeId }); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores/{store_id}", + pathParams: { + store_id: storeId, + unused: "value" // Should be ignored + }, + }); + + expect(result.id).toBe(storeId); + }); + + it("should replace parameter appearing multiple times in path (parameter_appears_multiple_times)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + const id = "abc"; + + nock(basePath) + .get(`/stores/${id}/check/${id}`) + .reply(200, { id: id }); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores/{id}/check/{id}", + pathParams: { id: id }, + }); + + expect(result.id).toBe(id); + }); + + it("should allow empty parameter value (empty_parameter_value)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + nock(basePath) + .get("/stores/") + .reply(200, { id: "" }); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores/{store_id}", + pathParams: { store_id: "" }, + }); + + expect(result.id).toBe(""); + }); + + it("should work without pathParams for paths with no template (no_parameters)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + nock(basePath) + .get("/stores") + .reply(200, { stores: [] }); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores", + }); + + expect(result.stores).toEqual([]); + }); + + it("should throw error for unresolved path parameters", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + await expect( + fgaClient.apiExecutor({ + method: "GET", + path: "/stores/{store_id}/check", + // pathParams intentionally omitted + }) + ).rejects.toThrow("Path parameter 'store_id' was not provided for path: /stores/{store_id}/check"); + }); + + it("should throw error when some path parameters are missing", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + await expect( + fgaClient.apiExecutor({ + method: "GET", + path: "/stores/{store_id}/authorization-models/{model_id}", + pathParams: { store_id: "abc" }, // model_id is missing + }) + ).rejects.toThrow("Path parameter 'model_id' was not provided"); + }); + }); + + describe("operationName for telemetry", () => { + it("should accept operationName parameter", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + nock(basePath) + .get("/stores") + .reply(200, { stores: [] }); + + // Should complete without error when operationName is provided + const result = await fgaClient.apiExecutor({ + operationName: "CustomListStores", + method: "GET", + path: "/stores", + }); + + expect(result.stores).toEqual([]); + }); + + it("should work without operationName (backward compatible)", async () => { + const fgaClient = new OpenFgaClient(testConfig); + + nock(basePath) + .get("/stores") + .reply(200, { stores: [] }); + + const result = await fgaClient.apiExecutor({ + method: "GET", + path: "/stores", + }); + + expect(result.stores).toEqual([]); + }); + }); +});