diff --git a/frontend/README.md b/frontend/README.md index f8453a4f98..da29be734c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,10 +25,17 @@ The frontend for Chronon. ``` 3. Install dependencies: + ```bash npm install ``` +4. Create `.env` file in frontend directory with the follow + + ``` + API_BASE_URL=http://localhost:9000 + ``` + ### Development To start the development server: diff --git a/frontend/src/lib/api/api.test.ts b/frontend/src/lib/api/api.test.ts index 495503f636..9cbc21a3dc 100644 --- a/frontend/src/lib/api/api.test.ts +++ b/frontend/src/lib/api/api.test.ts @@ -1,11 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { get } from './api'; +import { Api } from './api'; import { error } from '@sveltejs/kit'; // Mock the fetch function const mockFetch = vi.fn(); global.fetch = mockFetch; +const api = new Api({ fetch }); + // Mock the error function from @sveltejs/kit vi.mock('@sveltejs/kit', () => ({ error: vi.fn() @@ -28,11 +30,13 @@ describe('API module', () => { text: () => Promise.resolve(JSON.stringify(mockResponse)) }); - const result = await get('test-path'); + const result = await api.getModels(); - expect(mockFetch).toHaveBeenCalledWith(`http://localhost:9000/api/v1/test-path`, { + expect(mockFetch).toHaveBeenCalledWith(`/api/v1/models`, { method: 'GET', - headers: {} + headers: { + 'Content-Type': 'application/json' + } }); expect(result).toEqual(mockResponse); }); @@ -43,7 +47,7 @@ describe('API module', () => { text: () => Promise.resolve('') }); - const result = await get('empty-path'); + const result = await api.getModels(); expect(result).toEqual({}); }); @@ -54,7 +58,7 @@ describe('API module', () => { status: 404 }); - await get('error-path'); + await api.getModels(); expect(error).toHaveBeenCalledWith(404); }); diff --git a/frontend/src/lib/api/api.ts b/frontend/src/lib/api/api.ts index b7a585b22a..4f0232f1b4 100644 --- a/frontend/src/lib/api/api.ts +++ b/frontend/src/lib/api/api.ts @@ -1,113 +1,158 @@ +import { error } from '@sveltejs/kit'; import type { FeatureResponse, JoinsResponse, JoinTimeSeriesResponse, ModelsResponse } from '$lib/types/Model/Model'; -import { error } from '@sveltejs/kit'; -import { browser } from '$app/environment'; -const apiBaseUrl = !browser - ? process?.env?.API_BASE_URL || 'http://localhost:9000' - : 'http://localhost:9000'; +export type ApiOptions = { + base?: string; + fetch?: typeof fetch; + accessToken?: string; +}; -const base = `${apiBaseUrl}/api/v1`; +export type ApiRequestOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: Record; + headers?: Record; +}; -async function send({ method, path }: { method: string; path: string }) { - const opts = { method, headers: {} }; +export class Api { + #base: string; + #fetch: typeof fetch; + #accessToken: string | undefined; - const res = await fetch(`${base}/${path}`, opts); - if (res.ok) { - const text = await res.text(); - return text ? JSON.parse(text) : {}; + constructor(opts: ApiOptions = {}) { + this.#base = opts.base ?? '/api/v1'; + this.#fetch = opts.fetch ?? fetch; // default to global fetch (browser and node) + this.#accessToken = opts.accessToken; } - error(res.status); -} + // TODO: eventually move this to a model-specific file/decide on a good project structure for organizing api calls + async getModels() { + return this.#send('models'); + } -export function get(path: string) { - return send({ method: 'GET', path }); -} + async getJoins(offset: number = 0, limit: number = 10) { + const params = new URLSearchParams({ + offset: offset.toString(), + limit: limit.toString() + }); + return this.#send(`joins?${params.toString()}`); + } -// todo: eventually move this to a model-specific file/decide on a good project structure for organizing api calls -export async function getModels(): Promise { - return get('models'); -} + async search(term: string, limit: number = 20) { + const params = new URLSearchParams({ + term, + limit: limit.toString() + }); + return this.#send(`search?${params.toString()}`); + } -export async function getJoins(offset: number = 0, limit: number = 10): Promise { - const params = new URLSearchParams({ - offset: offset.toString(), - limit: limit.toString() - }); - return get(`joins?${params.toString()}`); -} + async getJoinTimeseries({ + joinId, + startTs, + endTs, + metricType = 'drift', + metrics = 'null', + offset = '10h', + algorithm = 'psi' + }: { + joinId: string; + startTs: number; + endTs: number; + metricType?: string; + metrics?: string; + offset?: string; + algorithm?: string; + }) { + const params = new URLSearchParams({ + startTs: startTs.toString(), + endTs: endTs.toString(), + metricType, + metrics, + offset, + algorithm + }); -export async function search(term: string, limit: number = 20): Promise { - const params = new URLSearchParams({ - term, - limit: limit.toString() - }); - return get(`search?${params.toString()}`); -} + return this.#send(`join/${joinId}/timeseries?${params.toString()}`); + } + + async getFeatureTimeseries({ + joinId, + featureName, + startTs, + endTs, + metricType = 'drift', + metrics = 'null', + offset = '10h', + algorithm = 'psi', + granularity = 'aggregates' + }: { + joinId: string; + featureName: string; + startTs: number; + endTs: number; + metricType?: string; + metrics?: string; + offset?: string; + algorithm?: string; + granularity?: string; + }) { + const params = new URLSearchParams({ + startTs: startTs.toString(), + endTs: endTs.toString(), + metricType, + metrics, + offset, + algorithm, + granularity + }); + return this.#send( + `join/${joinId}/feature/${featureName}/timeseries?${params.toString()}` + ); + } -export async function getJoinTimeseries({ - joinId, - startTs, - endTs, - metricType = 'drift', - metrics = 'null', - offset = '10h', - algorithm = 'psi' -}: { - joinId: string; - startTs: number; - endTs: number; - metricType?: string; - metrics?: string; - offset?: string; - algorithm?: string; -}): Promise { - const params = new URLSearchParams({ - startTs: startTs.toString(), - endTs: endTs.toString(), - metricType, - metrics, - offset, - algorithm - }); + async #send(resource: string, options?: ApiRequestOptions) { + let url = `${this.#base}/${resource}`; - return get(`join/${joinId}/timeseries?${params.toString()}`); -} + const method = options?.method ?? 'GET'; -export async function getFeatureTimeseries({ - joinId, - featureName, - startTs, - endTs, - metricType = 'drift', - metrics = 'null', - offset = '10h', - algorithm = 'psi', - granularity = 'aggregates' -}: { - joinId: string; - featureName: string; - startTs: number; - endTs: number; - metricType?: string; - metrics?: string; - offset?: string; - algorithm?: string; - granularity?: string; -}): Promise { - const params = new URLSearchParams({ - startTs: startTs.toString(), - endTs: endTs.toString(), - metricType, - metrics, - offset, - algorithm, - granularity - }); - return get(`join/${joinId}/feature/${featureName}/timeseries?${params.toString()}`); + if (method === 'GET' && options?.data) { + url += `?${new URLSearchParams(options.data)}`; + } + + return this.#fetch(url, { + method: options?.method ?? 'GET', + headers: { + 'Content-Type': 'application/json', + ...(this.#accessToken && { Authorization: `Bearer ${this.#accessToken}` }) + }, + ...(method === 'POST' && + options?.data && { + body: JSON.stringify(options.data) + }) + }).then(async (response) => { + if (response.ok) { + const text = await response.text(); + try { + if (text) { + return JSON.parse(text) as Data; + } else { + // TODO: Should we return `null` here and require users to handle + return {} as Data; + } + } catch (e) { + console.error(`Unable to parse: "${text}" for url: ${url}`); + throw e; + } + } else { + const text = (await response.text?.()) ?? ''; + console.error(`Failed request: "${text}" for url: ${url}`); + error(response.status); + } + }); + } } diff --git a/frontend/src/lib/components/NavigationBar/NavigationBar.svelte b/frontend/src/lib/components/NavigationBar/NavigationBar.svelte index 61a14aa69e..435c430334 100644 --- a/frontend/src/lib/components/NavigationBar/NavigationBar.svelte +++ b/frontend/src/lib/components/NavigationBar/NavigationBar.svelte @@ -10,7 +10,7 @@ CommandItem, CommandEmpty } from '$lib/components/ui/command/'; - import { search } from '$lib/api/api'; + import { Api } from '$lib/api/api'; import type { Model } from '$lib/types/Model/Model'; import debounce from 'lodash/debounce'; import { onDestroy, onMount } from 'svelte'; @@ -46,9 +46,11 @@ let searchResults: Model[] = $state([]); let isMac: boolean | undefined = $state(undefined); + const api = new Api(); + const debouncedSearch = debounce(async () => { if (input.length > 0) { - const response = await search(input); + const response = await api.search(input); searchResults = response.items; } else { searchResults = []; diff --git a/frontend/src/lib/types/Model/Model.test.ts b/frontend/src/lib/types/Model/Model.test.ts index 66aad9fdf3..f230b3b156 100644 --- a/frontend/src/lib/types/Model/Model.test.ts +++ b/frontend/src/lib/types/Model/Model.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest'; -import * as api from '$lib/api/api'; +import { Api } from '$lib/api/api'; import type { ModelsResponse, Model, JoinTimeSeriesResponse } from '$lib/types/Model/Model'; +const api = new Api({ base: 'http://localhost:9000/api/v1' }); + describe('Model types', () => { it('should match ModelsResponse type', async () => { const result = (await api.getModels()) as ModelsResponse; diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts new file mode 100644 index 0000000000..636728c934 --- /dev/null +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -0,0 +1,17 @@ +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +const API_BASE_URL = env.API_BASE_URL ?? 'http://localhost:9000'; + +/** + * Proxy calls from frontend server (ex. `http://app.zipline.ai/api`) to Scala backend (ex. `http://app:9000/api`), resolving: + * - CORS issues + * - Consistent URL handling whether issuing requests from browser (ex. `+page.svelte`) or frontend server (ex `+page.server.ts`) + */ +export const GET: RequestHandler = ({ params, url, request }) => { + return fetch(`${API_BASE_URL}/api/${params.path + url.search}`, request); +}; + +export const POST: RequestHandler = ({ params, url, request }) => { + return fetch(`${API_BASE_URL}/api/${params.path + url.search}`, request); +}; diff --git a/frontend/src/routes/joins/+page.server.ts b/frontend/src/routes/joins/+page.server.ts index ad59aa4dae..8c67418f3e 100644 --- a/frontend/src/routes/joins/+page.server.ts +++ b/frontend/src/routes/joins/+page.server.ts @@ -1,10 +1,11 @@ import type { PageServerLoad } from './$types'; import type { JoinsResponse } from '$lib/types/Model/Model'; -import * as api from '$lib/api/api'; +import { Api } from '$lib/api/api'; -export const load: PageServerLoad = async (): Promise<{ joins: JoinsResponse }> => { +export const load: PageServerLoad = async ({ fetch }): Promise<{ joins: JoinsResponse }> => { const offset = 0; const limit = 100; + const api = new Api({ fetch }); return { joins: await api.getJoins(offset, limit) }; diff --git a/frontend/src/routes/joins/[slug]/+page.server.ts b/frontend/src/routes/joins/[slug]/+page.server.ts index fb9c82ce8d..de15dc3d6b 100644 --- a/frontend/src/routes/joins/[slug]/+page.server.ts +++ b/frontend/src/routes/joins/[slug]/+page.server.ts @@ -1,5 +1,5 @@ import type { PageServerLoad } from './$types'; -import * as api from '$lib/api/api'; +import { Api } from '$lib/api/api'; import type { JoinTimeSeriesResponse, Model } from '$lib/types/Model/Model'; import { parseDateRangeParams } from '$lib/util/date-ranges'; import { getMetricTypeFromParams, type MetricType } from '$lib/types/MetricType/MetricType'; @@ -10,7 +10,8 @@ const FALLBACK_END_TS = 1677628800000; // 2023-03-01 export const load: PageServerLoad = async ({ params, - url + url, + fetch }): Promise<{ joinTimeseries: JoinTimeSeriesResponse; model?: Model; @@ -22,6 +23,7 @@ export const load: PageServerLoad = async ({ isUsingFallback: boolean; }; }> => { + const api = new Api({ fetch }); const requestedDateRange = parseDateRangeParams(url.searchParams); const joinName = params.slug; const metricType = getMetricTypeFromParams(url.searchParams); @@ -30,6 +32,7 @@ export const load: PageServerLoad = async ({ // Try with requested date range first try { const { joinTimeseries, model } = await fetchInitialData( + api, joinName, requestedDateRange.startTimestamp, requestedDateRange.endTimestamp, @@ -50,6 +53,7 @@ export const load: PageServerLoad = async ({ console.error('Error fetching data:', error); // If the requested range fails, fall back to the known working range const { joinTimeseries, model } = await fetchInitialData( + api, joinName, FALLBACK_START_TS, FALLBACK_END_TS, @@ -72,6 +76,7 @@ export const load: PageServerLoad = async ({ }; async function fetchInitialData( + api: Api, joinName: string, startTs: number, endTs: number, diff --git a/frontend/src/routes/joins/[slug]/+page.svelte b/frontend/src/routes/joins/[slug]/+page.svelte index 7e95cad423..b986276990 100644 --- a/frontend/src/routes/joins/[slug]/+page.svelte +++ b/frontend/src/routes/joins/[slug]/+page.svelte @@ -16,7 +16,7 @@ import IntersectionObserver from 'svelte-intersection-observer'; import { fade } from 'svelte/transition'; import { Button } from '$lib/components/ui/button'; - import { getFeatureTimeseries } from '$lib/api/api'; + import { Api } from '$lib/api/api'; import InfoTooltip from '$lib/components/InfoTooltip/InfoTooltip.svelte'; import { Table, TableBody, TableCell, TableRow } from '$lib/components/ui/table/index.js'; import TrueFalseBadge from '$lib/components/TrueFalseBadge/TrueFalseBadge.svelte'; @@ -32,6 +32,8 @@ import { page } from '$app/stores'; import { getSortDirection, sortDistributions, type SortContext } from '$lib/util/sort'; + const api = new Api(); + const { data } = $props(); let scale = $derived(METRIC_SCALES[data.metricType]); const joinTimeseries = $derived(data.joinTimeseries); @@ -199,7 +201,7 @@ if (seriesName) { try { const [featureData, nullFeatureData] = await Promise.all([ - getFeatureTimeseries({ + api.getFeatureTimeseries({ joinId: joinTimeseries.name, featureName: seriesName, startTs: data.dateRange.startTimestamp, @@ -210,7 +212,7 @@ offset: '1D', algorithm: 'psi' }), - getFeatureTimeseries({ + api.getFeatureTimeseries({ joinId: joinTimeseries.name, featureName: seriesName, startTs: data.dateRange.startTimestamp, @@ -419,7 +421,7 @@ // Fetch percentile data for each feature const distributionsPromises = allFeatures.map((featureName) => - getFeatureTimeseries({ + api.getFeatureTimeseries({ joinId: joinTimeseries.name, featureName, startTs: data.dateRange.startTimestamp,