-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: create new Metabase client (#3970)
Co-authored-by: Dafydd Llŷr Pearson <[email protected]>
- Loading branch information
1 parent
5392673
commit d4bbe91
Showing
2 changed files
with
244 additions
and
1 deletion.
There are no files selected for viewing
116 changes: 115 additions & 1 deletion
116
api.planx.uk/modules/analytics/metabase/shared/client.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,115 @@ | ||
test.todo("should test configuration and errors"); | ||
import axios from "axios"; | ||
import { | ||
validateConfig, | ||
createMetabaseClient, | ||
MetabaseError, | ||
} from "./client.js"; | ||
import nock from "nock"; | ||
|
||
const axiosCreateSpy = vi.spyOn(axios, "create"); | ||
|
||
describe("Metabase client", () => { | ||
beforeEach(() => { | ||
vi.clearAllMocks(); | ||
vi.resetModules(); | ||
}); | ||
|
||
afterEach(() => { | ||
vi.unstubAllEnvs(); | ||
}); | ||
|
||
test("returns configured client", async () => { | ||
const client = createMetabaseClient(); | ||
expect(client.defaults.baseURL).toBe(process.env.METABASE_URL_EXT); | ||
expect(client.defaults.headers["X-API-Key"]).toBe( | ||
process.env.METABASE_API_KEY, | ||
); | ||
expect(client.defaults.headers["Content-Type"]).toBe("application/json"); | ||
expect(client.defaults.timeout).toBe(30_000); | ||
}); | ||
|
||
describe("validates configuration", () => { | ||
test("throws error when URL_EXT is missing", () => { | ||
vi.stubEnv("METABASE_URL_EXT", undefined); | ||
expect(() => validateConfig()).toThrow( | ||
"Missing environment variable 'METABASE_URL_EXT'", | ||
); | ||
}); | ||
|
||
test("throws error when API_KEY is missing", () => { | ||
vi.stubEnv("METABASE_API_KEY", undefined); | ||
expect(() => validateConfig()).toThrow( | ||
"Missing environment variable 'METABASE_API_KEY'", | ||
); | ||
}); | ||
|
||
test("returns valid config object", () => { | ||
const config = validateConfig(); | ||
expect(config).toMatchObject({ | ||
baseURL: process.env.METABASE_URL_EXT, | ||
apiKey: process.env.METABASE_API_KEY, | ||
timeout: 30_000, | ||
retries: 3, | ||
}); | ||
}); | ||
}); | ||
|
||
describe("Error handling", () => { | ||
test("retries then succeeds on 5xx errors", async () => { | ||
const metabaseScope = nock(process.env.METABASE_URL_EXT!); | ||
|
||
metabaseScope | ||
.get("/test") | ||
.reply(500, { message: "Internal Server Error" }) | ||
.get("/test") | ||
.reply(200, { data: "success" }); | ||
|
||
const client = createMetabaseClient(); | ||
const response = await client.get("/test"); | ||
|
||
expect(response.data).toEqual({ data: "success" }); | ||
expect(metabaseScope.isDone()).toBe(true); | ||
}); | ||
|
||
test("throws an error if all requests fail", async () => { | ||
const metabaseScope = nock(process.env.METABASE_URL_EXT!); | ||
|
||
metabaseScope | ||
.get("/test") | ||
.times(4) | ||
.reply(500, { message: "Internal Server Error" }); | ||
|
||
const client = createMetabaseClient(); | ||
|
||
try { | ||
await client.get("/test"); | ||
expect.fail("Should have thrown an error"); | ||
} catch (error) { | ||
expect(error).toBeInstanceOf(MetabaseError); | ||
expect((error as MetabaseError).statusCode).toBe(500); | ||
expect(metabaseScope.isDone()).toBe(true); | ||
} | ||
}); | ||
|
||
test("does not retry on non-5xx errors", async () => { | ||
const metabaseScope = nock(process.env.METABASE_URL_EXT!); | ||
|
||
metabaseScope.get("/test").once().reply(200, { data: "success" }); | ||
|
||
const client = createMetabaseClient(); | ||
const response = await client.get("/test"); | ||
|
||
expect(response.data).toEqual({ data: "success" }); | ||
|
||
// All expected requests were made | ||
expect(metabaseScope.isDone()).toBe(true); | ||
|
||
// No pending mocks left | ||
expect(metabaseScope.pendingMocks()).toHaveLength(0); | ||
|
||
// Double check that no other requests were intercepted | ||
const requestCount = metabaseScope.activeMocks().length; | ||
expect(requestCount).toBe(0); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import axios from "axios"; | ||
import type { | ||
AxiosInstance, | ||
AxiosError, | ||
InternalAxiosRequestConfig, | ||
} from "axios"; | ||
|
||
// Custom error class for Metabase-specific errors | ||
export class MetabaseError extends Error { | ||
constructor( | ||
message: string, | ||
public statusCode?: number, | ||
public response?: unknown, | ||
) { | ||
super(message); | ||
this.name = "MetabaseError"; | ||
} | ||
} | ||
|
||
interface MetabaseConfig { | ||
baseURL: string; | ||
apiKey: string; | ||
timeout?: number; | ||
retries?: number; | ||
} | ||
|
||
// Validate environment variables | ||
export const validateConfig = (): MetabaseConfig => { | ||
const baseURL = process.env.METABASE_URL_EXT; | ||
const apiKey = process.env.METABASE_API_KEY; | ||
|
||
const METABASE_TIMEOUT = 30_000; | ||
const METABASE_MAX_RETRIES = 3; | ||
|
||
assert(baseURL, "Missing environment variable 'METABASE_URL_EXT'"); | ||
assert(apiKey, "Missing environment variable 'METABASE_API_KEY'"); | ||
|
||
return { | ||
baseURL, | ||
apiKey, | ||
timeout: METABASE_TIMEOUT, | ||
retries: METABASE_MAX_RETRIES, | ||
}; | ||
}; | ||
|
||
// Extended request config to include retry count | ||
interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig { | ||
retryCount?: number; | ||
} | ||
|
||
// Create and configure Axios instance | ||
export const createMetabaseClient = (): AxiosInstance => { | ||
const config = validateConfig(); | ||
|
||
const client = axios.create({ | ||
baseURL: config.baseURL, | ||
headers: { | ||
"X-API-Key": config.apiKey, | ||
"Content-Type": "application/json", | ||
}, | ||
timeout: config.timeout, | ||
}); | ||
|
||
client.interceptors.response.use( | ||
(response) => { | ||
return response; | ||
}, | ||
async (error: AxiosError) => { | ||
const originalRequest = error.config as ExtendedAxiosRequestConfig; | ||
|
||
if (!originalRequest) { | ||
throw new MetabaseError("No request config available"); | ||
} | ||
|
||
// Initialise retry count if not present | ||
if (typeof originalRequest.retryCount === "undefined") { | ||
originalRequest.retryCount = 0; | ||
} | ||
|
||
// Handle retry logic | ||
if (error.response) { | ||
// Retry on 5xx errors | ||
if ( | ||
error.response.status >= 500 && | ||
originalRequest.retryCount < (config.retries ?? 3) | ||
) { | ||
originalRequest.retryCount++; | ||
return client.request(originalRequest); | ||
} | ||
|
||
// Transform error response | ||
const errorMessage = | ||
typeof error.response.data === "object" && | ||
error.response.data !== null && | ||
"message" in error.response.data | ||
? String(error.response.data.message) | ||
: "Metabase request failed"; | ||
|
||
throw new MetabaseError( | ||
errorMessage, | ||
error.response.status, | ||
error.response.data, | ||
); | ||
} | ||
|
||
// Handle network errors | ||
if (error.request) { | ||
throw new MetabaseError( | ||
"Network error occurred", | ||
undefined, | ||
error.request, | ||
); | ||
} | ||
|
||
// Handle other errors | ||
throw new MetabaseError(error.message); | ||
}, | ||
); | ||
|
||
return client; | ||
}; | ||
|
||
// // Export both client and instance with delayed instantiation for test purposes | ||
// export let metabaseClient: AxiosInstance; | ||
|
||
// export const initializeMetabaseClient = () => { | ||
// metabaseClient = createMetabaseClient(); | ||
// return metabaseClient; | ||
// }; |