diff --git a/frontend/src/api/base/base-api.ts b/frontend/src/api/base/base-api.ts new file mode 100644 index 0000000..785cb82 --- /dev/null +++ b/frontend/src/api/base/base-api.ts @@ -0,0 +1,17 @@ +import { Requests } from '../../lib/requests'; +// < +// TGetResult, +// TPostData, +// TPostResult, +// TPutData = TPostData, +// TPutResult = TPostResult, +// TDeleteResult = void +// > + +export class BaseAPI { + http: Requests; + + constructor(requests: Requests) { + this.http = requests; + } +} diff --git a/frontend/src/api/base/index.test.ts b/frontend/src/api/base/index.test.ts new file mode 100644 index 0000000..2f40e0c --- /dev/null +++ b/frontend/src/api/base/index.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { UrlBuilder } from '.'; + +describe('UrlBuilder', () => { + it('basic query parameter', () => { + const result = UrlBuilder('/test', { a: 'b' }); + expect(result).toBe('/api/v1/test?a=b'); + }); + + it('multiple query parameters', () => { + const result = UrlBuilder('/test', { a: 'b', c: 'd' }); + expect(result).toBe('/api/v1/test?a=b&c=d'); + }); + + it('no query parameters', () => { + const result = UrlBuilder('/test'); + expect(result).toBe('/api/v1/test'); + }); + + it('list-like query parameters', () => { + const result = UrlBuilder('/test', { a: ['b', 'c'] }); + expect(result).toBe('/api/v1/test?a=b&a=c'); + }); +}); diff --git a/frontend/src/api/base/index.ts b/frontend/src/api/base/index.ts new file mode 100644 index 0000000..12f6df5 --- /dev/null +++ b/frontend/src/api/base/index.ts @@ -0,0 +1,2 @@ +export { BaseAPI } from './base-api'; +export { UrlBuilder } from './urls'; diff --git a/frontend/src/api/base/urls.ts b/frontend/src/api/base/urls.ts new file mode 100644 index 0000000..5acf4ed --- /dev/null +++ b/frontend/src/api/base/urls.ts @@ -0,0 +1,31 @@ +export const prefix = '/api/v1'; + +export type QueryValue = + | string + | string[] + | number + | number[] + | boolean + | null + | undefined; + +export function UrlBuilder( + rest: string, + params: Record = {} +): string { + // we use a stub base URL to leverage the URL class + const url = new URL(prefix + rest, 'http://localhost.com'); + + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const item of value) { + url.searchParams.append(key, String(item)); + } + } else { + url.searchParams.append(key, String(value)); + } + } + + // we return the path only, without the base URL + return url.toString().replace('http://localhost.com', ''); +} diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts new file mode 100644 index 0000000..7cd6ff5 --- /dev/null +++ b/frontend/src/api/public.ts @@ -0,0 +1,39 @@ +import { BaseAPI, UrlBuilder } from './base'; + +export type LoginResult = { + token: string; + expiresAt: string; +}; + +export type LoginPayload = { + username: string; + password: string; +}; + +export type RegisterPayload = { + user: { + email: string; + password: string; + name: string; + }; + groupName: string; +}; + +export class PublicApi extends BaseAPI { + public login(username: string, password: string) { + return this.http.post( + UrlBuilder('/users/login'), + { + username, + password, + } + ); + } + + public register(payload: RegisterPayload) { + return this.http.post( + UrlBuilder('/users/register'), + payload + ); + } +} diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts new file mode 100644 index 0000000..a468737 --- /dev/null +++ b/frontend/src/api/user.ts @@ -0,0 +1,18 @@ +import { BaseAPI, UrlBuilder } from './base'; + +export type Result = { + item: T; +}; + +export type User = { + name: string; + email: string; + isSuperuser: boolean; + id: number; +}; + +export class UserApi extends BaseAPI { + public self() { + return this.http.get>(UrlBuilder('/users/self')); + } +} diff --git a/frontend/src/lib/requests/index.ts b/frontend/src/lib/requests/index.ts new file mode 100644 index 0000000..7bd0a14 --- /dev/null +++ b/frontend/src/lib/requests/index.ts @@ -0,0 +1 @@ +export { Requests, type TResponse } from './requests'; diff --git a/frontend/src/lib/requests/requests.ts b/frontend/src/lib/requests/requests.ts new file mode 100644 index 0000000..c0c83be --- /dev/null +++ b/frontend/src/lib/requests/requests.ts @@ -0,0 +1,95 @@ +export enum Method { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', +} + +export interface TResponse { + status: number; + error: boolean; + data: T; + response: Response; +} + +export class Requests { + private baseUrl: string; + private token: () => string; + private headers: Record = {}; + private logger?: (response: Response) => void; + + private url(rest: string): string { + return this.baseUrl + rest; + } + + constructor( + baseUrl: string, + token: string | (() => string) = '', + headers: Record = {}, + logger?: (response: Response) => void + ) { + this.baseUrl = baseUrl; + this.token = typeof token === 'string' ? () => token : token; + this.headers = headers; + this.logger = logger; + } + + public get(url: string): Promise> { + return this.do(Method.GET, url); + } + + public post(url: string, payload: T): Promise> { + return this.do(Method.POST, url, payload); + } + + public put(url: string, payload: T): Promise> { + return this.do(Method.PUT, url, payload); + } + + public delete(url: string): Promise> { + return this.do(Method.DELETE, url); + } + + private methodSupportsBody(method: Method): boolean { + return method === Method.POST || method === Method.PUT; + } + + private async do( + method: Method, + url: string, + payload: Object = {} + ): Promise> { + const args: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + ...this.headers, + }, + }; + + const token = this.token(); + if (token !== '' && args.headers !== undefined) { + // @ts-expect-error -- headers is always defined at this point + args.headers['Authorization'] = token; + } + + if (this.methodSupportsBody(method)) { + args.body = JSON.stringify(payload); + } + + const response = await fetch(this.url(url), args); + + if (this.logger) { + this.logger(response); + } + + const data = await response.json(); + + return { + status: response.status, + error: !response.ok, + data, + response, + }; + } +}