From 5c0f557308e8d1c3401d11757d01656d44da56bd Mon Sep 17 00:00:00 2001 From: Julian Lobbes Date: Wed, 14 Jun 2023 23:26:01 +0200 Subject: [PATCH] squashme --- frontend/src/lib/api/endpoints.ts | 323 +++++++++--------- frontend/src/lib/api/types.ts | 105 ++++++ frontend/src/lib/auth/session.ts | 51 +-- .../lib/components/data-table/Table.svelte | 45 +-- .../src/lib/components/data-table/types.ts | 5 - frontend/src/lib/utils/types.ts | 6 + frontend/src/routes/todos/[todo]/+page.ts | 21 +- frontend/src/routes/users/[user]/+page.svelte | 8 +- frontend/src/routes/users/[user]/+page.ts | 21 +- .../routes/users/[user]/todos/+page.svelte | 29 +- .../src/routes/users/[user]/todos/+page.ts | 9 - frontend/src/routes/users/all/+page.svelte | 15 +- frontend/src/routes/users/all/+page.ts | 19 ++ 13 files changed, 371 insertions(+), 286 deletions(-) create mode 100644 frontend/src/lib/utils/types.ts delete mode 100644 frontend/src/routes/users/[user]/todos/+page.ts create mode 100644 frontend/src/routes/users/all/+page.ts diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 462460a..5d76004 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -1,182 +1,167 @@ import { error } from '@sveltejs/kit'; -import type { ItemCount, TodoItem, User } from './types'; -import { getResponseBodyOrError } from '$lib/api/utils'; +import type { ItemCount, TodoItem, Token, User } from './types'; +import { Endpoint } from './types'; +import type { StringMapping } from '$lib/utils/types'; import { getTokenFromLocalstorage } from '$lib/auth/session'; /** - * Retrieves the currently logged in user's JWT from localstorage, - * or throws an error if it is not present. - * - * @throws {error} - If the current user is not logged in or if their JWT is not saved in localstorage. - */ -function getTokenOrError(): string { - let token = getTokenFromLocalstorage(); - if (token === null) { - throw error(401, 'You are not logged in.'); + * A factory class for creating `Endpoint` instances to interact with the backend API. + * + * This class provides methods to create specific `Endpoint` instances for different API operations, + * such as retrieving user data, retrieving todo items, and more. It follows the factory pattern to + * encapsulate the creation of `Endpoint` instances with the necessary configurations and headers + * for making API requests. + * + * The `EndpointFactory` constructor accepts an optional `fetchFunction` parameter, which is the + * function used for making API requests. If no `fetchFunction` is provided, the default `fetch` + * function is used. + * + * Example usage: + * ```typescript + * const endpointFactory = new EndpointFactory(); + * + * const readUserEndpoint = endpointFactory.createReadUserEndpoint(123); + * const users = await readUserEndpoint.call(); + * + * const readTodosEndpoint = endpointFactory.createReadTodosEndpoint(123); + * const todos = await readTodosEndpoint.call(); + * ``` + */ +export class EndpointFactory { + + /** The function to use for making API requests. */ + readonly fetchFunction: Function; + + // The JSON web token to send as an authorization bearer token. + private _jwt: string | null; + + /** + * Constructs a new `EndpointFactory` instance. + * @param fetchFunction - The function Endpoints created by this factory use for making API requests. (Default: fetch) + */ + constructor(fetchFunction: Function = fetch) { + this.fetchFunction = fetchFunction; + this._jwt = getTokenFromLocalstorage(); } - return token; -} -/** - * Retrieves the user with the specified ID from the backend API. - * - * @param {number} userId - The ID of the user whom to retrieve. - * @param {string} jwt - The JWT appended as a bearer token to authorize the request. - * @throws{error} - If the request fails or is not permitted. - */ -export async function readUser(userId: number, jwt: string = getTokenOrError()): Promise { - const endpoint = `/api/users/${userId}`; - const response = await fetch(endpoint, { - method: 'GET', - headers: { + private _getDefaultHeaders(): StringMapping { + return { 'Accept': 'application/json', - 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json', } - }); + } - const responseJson = await getResponseBodyOrError(response); - return responseJson as User; -} - -/** - * Retrieves the list of all users from the backend API. - * - * @param {string} jwt - The JWT appended as a bearer token to authorize the request. - * @throws{error} - If the request fails or is not permitted. - */ -export async function readUsers( - skip: number, - limit: number, - sortby: string, - sortorder: string, - jwt: string = getTokenOrError(), -): Promise { - - const urlParameters = new URLSearchParams({ - skip: `${skip}`, - limit: `${limit}`, - sortby: `${sortby}`, - sortorder: `${sortorder}`, - }); - const endpoint = `/api/admin/users/?${urlParameters}`; - - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Authorization': `Bearer ${jwt}`, - 'Content-Type': 'application/json', + private _getDefaultHeadersWithAuth(): StringMapping { + if (this._jwt === null) { + throw error(401, 'You are not logged in.'); } - }); - const responseJson = await getResponseBodyOrError(response); - return responseJson as User[]; -} - -/** - * Retrieves the total user count from the backend API. - * - * @param {string} jwt - The JWT appended as a bearer token to authorize the request. - * @throws{error} - If the request fails or is not permitted. - */ -export async function readUserCount(jwt: string = getTokenOrError()): Promise { - const endpoint = '/api/admin/users/total/'; - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Authorization': `Bearer ${jwt}`, - 'Content-Type': 'application/json', - } - }); - - const responseJson = await getResponseBodyOrError(response); - const itemCount = responseJson as ItemCount; - return itemCount.total; -} - -/** - * Retrieves the list of all todo-items for the user with the specified ID from the backend API. - * - * @param {string} jwt - The JWT appended as a bearer token to authorize the request. - * @throws{error} - If the request fails or is not permitted. - */ -export async function readTodos( - userId: number, - skip: number, - limit: number, - sortby: string, - sortorder: string, - jwt: string = getTokenOrError(), -): Promise { - - const urlParameters = new URLSearchParams({ - skip: `${skip}`, - limit: `${limit}`, - sortby: `${sortby}`, - sortorder: `${sortorder}`, - }); - const endpoint = `/api/todos/user/${userId}?${urlParameters}`; - - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Authorization': `Bearer ${jwt}`, - 'Content-Type': 'application/json', - } - }); - - const responseJson = await getResponseBodyOrError(response); - return responseJson as TodoItem[]; -} - -/** - * Retrieves the total todo-item count for the user with the specified ID from the backend API. - * - * @param {string} jwt - The JWT appended as a bearer token to authorize the request. - * @throws{error} - If the request fails or is not permitted. - */ -export async function readTodoCount( - userId: number, - jwt: string = getTokenOrError() -): Promise { - const endpoint = `/api/todos/user/${userId}/total/`; - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Authorization': `Bearer ${jwt}`, - 'Content-Type': 'application/json', - } - }); - - const responseJson = await getResponseBodyOrError(response); - const itemCount = responseJson as ItemCount; - return itemCount.total; -} - -/** - * Retrieves the todo-item with the specified ID from the backend API. - * - * @param {string} jwt - The JWT appended as a bearer token to authorize the request. - * @throws{error} - If the request fails or is not permitted. - */ -export async function readTodo( - todoId: number, - jwt: string = getTokenOrError() -): Promise { - const endpoint = `/api/todos/${todoId}`; - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Authorization': `Bearer ${jwt}`, - 'Content-Type': 'application/json', - } - }); - - const responseJson = await getResponseBodyOrError(response); - return responseJson as TodoItem; + let headers = this._getDefaultHeaders(); + headers['Authorization'] = `Bearer: ${this._jwt}`; + + return headers; + } + + /** + * Creates an Endpoint which retrieves the user with the specified ID from the backend API. + * + * @param {number} userId - The ID of the user whom to retrieve. + * @throws {error} If the user is not currently logged in. + */ + createReadUserEndpoint(userId: number): Endpoint { + return new Endpoint( + `/api/users/${userId}`, + 'GET', + this._getDefaultHeadersWithAuth(), + this.fetchFunction + ); + } + + /** + * Creates an Endpoint which retrieves the list of all users from the backend API. + * + * @throws {error} If the user is not currently logged in. + */ + createReadUsersEndpoint(): Endpoint { + return new Endpoint( + `/api/admin/users/`, + 'GET', + this._getDefaultHeadersWithAuth(), + this.fetchFunction + ); + } + + /** + * Creates an Endpoint which retrieves the total user count from the backend API. + * + * @throws {error} If the user is not currently logged in. + */ + createReadUserCountEndpoint(): Endpoint { + return new Endpoint( + `/api/admin/users/total/`, + 'GET', + this._getDefaultHeadersWithAuth(), + this.fetchFunction + ); + } + + /** + * Creates an Endwpoint which retrieves the list of all todo-items for the user with the specified ID from the backend API. + * + * @param {number} userId - The ID of the user whose todo-items to retrieve. + * @throws {error} If the user is not currently logged in. + */ + createReadTodosEndpoint(userId: number): Endpoint { + return new Endpoint( + `/api/todos/user/${userId}`, + 'GET', + this._getDefaultHeadersWithAuth(), + this.fetchFunction + ); + } + + /** + * Creates an Endpoint which retrieves the total todo-item count for the user with the specified ID from the backend API. + * + * @param {number} userId - The ID of the user whose todo-items to retrieve. + * @throws {error} If the user is not currently logged in. + */ + createReadTodoCountEndpoint(userId: number): Endpoint { + return new Endpoint( + `/api/todos/user/${userId}/total/`, + 'GET', + this._getDefaultHeadersWithAuth(), + this.fetchFunction + ); + } + + /** + * Creates an Endpoint which retrieves the todo-item with the specified ID from the backend API. + * + * @param {number} todoId - The ID of the todo-item to be retrieved. + * @throws {error} If the user is not currently logged in. + */ + createReadTodoEndpoint(todoId: number): Endpoint { + return new Endpoint( + `/api/todos/${todoId}`, + 'GET', + this._getDefaultHeadersWithAuth(), + this.fetchFunction + ); + } + + /** + * Creates an Endpoint which sends an API request containing the specified login credentials + * to the backend, and returns the JWT retrieved from the response on sucess. + * + * @throws {error} - If the request fails or is not permitted. + */ + createLoginEndpoint(): Endpoint { + return new Endpoint( + `/api/auth/login/`, + 'POST', + this._getDefaultHeaders(), + this.fetchFunction + ); + } } diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 7ee7cc6..2c86811 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -1,7 +1,23 @@ +import type { StringMapping } from '$lib/utils/types'; +import { error } from '@sveltejs/kit'; + +/** + * An API response indicating an item count. + */ export type ItemCount = { total: number } +/** + * An API response indicating a token. + */ +export type Token = { + token: string +} + +/** + * An API response representing a User object. + */ export type User = { id: number, email: string, @@ -12,6 +28,9 @@ export type User = { is_admin: boolean, } +/** + * An API response representing a TodoItem object. + */ export type TodoItem = { id: number, title: string, @@ -21,3 +40,89 @@ export type TodoItem = { updated: Date, finished: Date, } + +/** + * Allowed API-request request types. + */ +type RequestMethod = 'GET' | 'POST' | 'UPDATE' | 'DELETE'; + +/** + * Allowed options to an Enpoint's `call()`-method + */ +type EndpointCallOptions = { + body?: any, + queryParameters?: StringMapping +} + +/** + * Represents an external API endpoint. + */ +export class Endpoint { + /** The URL of the API endpoint. */ + readonly url: string; + + /** The request method to be used for API calls. */ + readonly requestMethod: RequestMethod; + + /** The headers to be included in API requests. */ + readonly requestHeaders: StringMapping; + + /** The function to use for making API requests. */ + readonly fetchFunction: Function; + + /** + * Constructs a new `Endpoint` instance. + * @param url - The URL of the API endpoint. + * @param requestMethod - The request method to be used for API calls. (Default: 'GET') + * @param requestHeaders - The headers to be included in API requests. (Default: {}) + * @param fetchFunction - The function to use for making API requests. (Default: fetch) + */ + constructor( + url: string, + requestMethod: RequestMethod = 'GET', + requestHeaders: StringMapping = {}, + fetchFunction: Function = fetch + ) { + this.url = url; + this.requestMethod = requestMethod; + this.requestHeaders = requestHeaders; + this.fetchFunction = fetchFunction; + } + + /** + * Calls the API endpoint with optional query parameters. + * + * @param queryParameters - The query parameters to be included in the API call. (Default: new URLSearchParams({})) + * @returns A Promise that resolves to the JSON response from the API. + * @throws {error} If the API request fails or returns an error response. + */ + async call(options: EndpointCallOptions = {}): Promise { + let endpointUrl = this.url; + if ('queryParameters' in options) { + endpointUrl += `?${new URLSearchParams(options.queryParameters)}`; + } + console.log(endpointUrl) + + const response = await this.fetchFunction(endpointUrl, { + method: this.requestMethod, + headers: this.requestHeaders, + body: 'body' in options ? JSON.stringify(options.body) : null + }); + const responseJson = await response.json(); + + if (!response.ok) { + if ('detail' in responseJson) { + if (typeof responseJson.detail === 'string') { + throw error(response.status, responseJson.detail); + } else if (Array.isArray(responseJson.detail)) { + if ('msg' in responseJson.detail[0] && typeof responseJson.detail[0].msg === 'string') { + throw error(response.status, responseJson.detail[0].msg); + } + } + } + throw error(response.status, `API request failed: ${response.statusText}`); + } + + return responseJson as T; + } +} diff --git a/frontend/src/lib/auth/session.ts b/frontend/src/lib/auth/session.ts index 476e488..7df1519 100644 --- a/frontend/src/lib/auth/session.ts +++ b/frontend/src/lib/auth/session.ts @@ -1,5 +1,5 @@ import { getResponseBodyOrError } from '$lib/api/utils'; -import { readUser } from '$lib/api/endpoints'; +import { EndpointFactory } from '$lib/api/endpoints'; import jwt_decode from 'jwt-decode'; import { writable } from 'svelte/store'; @@ -70,35 +70,6 @@ function clearUserInLocalstorage(): void { localStorage.removeItem(userKey); } -/** - * Sends an API request containing the specified login credentials to the backend, - * and returns the JWT retrieved from the response on sucess. - * - * @param {string} email - The email address of the user whose JWT to retrieve. - * @param {string} password - The password used to authenticate the user whose JWT is being retrieved. - * @throws {Error} - If the API request failed or the supplied credentials were invalid. - */ -async function requestJwt(email: string, password: string): Promise { - type Token = { - token: string - } - - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: email, - password: password, - }) - }); - - const responseBody = await getResponseBodyOrError(response) as Token; - return responseBody.token; -} - /** * Loads the user currently saved in localstorage into the storedUser * svelte store. @@ -125,18 +96,30 @@ function clearUserFromStore(): void { * @throws {Error} - If any API request fails or the supplied credentials were invalid. */ export async function login(email: string, password: string): Promise { - const token = await requestJwt(email, password); + let endpointFactory = new EndpointFactory(); + const loginEndpoint = endpointFactory.createLoginEndpoint(); + const token = await loginEndpoint.call({ + body: { + email: email, + password: password, + } + }); interface Token { sub: number, exp: number } - const parsedToken = jwt_decode(token) as Token; + const parsedToken = jwt_decode(token.token) as Token; const userId = parsedToken.sub; - const user = await readUser(userId, token); + saveTokenToLocalstorage(token.token); + // recreate the factory with the jwt now in localstorage + endpointFactory = new EndpointFactory(); + + const readUserEndpoint = endpointFactory.createReadUserEndpoint(userId); + console.log(readUserEndpoint.call()) + const user = await readUserEndpoint.call(); - saveTokenToLocalstorage(token); saveUserToLocalstorage({ id: user.id, email: user.email, diff --git a/frontend/src/lib/components/data-table/Table.svelte b/frontend/src/lib/components/data-table/Table.svelte index 45b6c44..29b21c9 100644 --- a/frontend/src/lib/components/data-table/Table.svelte +++ b/frontend/src/lib/components/data-table/Table.svelte @@ -3,8 +3,9 @@ import { Icon, ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft } from 'svelte-hero-icons'; import Th from '$lib/components/data-table/Th.svelte'; import Td from '$lib/components/data-table/Td.svelte'; - import type { Column, Endpoint } from '$lib/components/data-table/types'; + import type { Column } from '$lib/components/data-table/types'; import { interpolateString as i } from '$lib/components/data-table/utils'; + import type { Endpoint, ItemCount } from '$lib/api/types'; /** * The caption for the data table. @@ -19,19 +20,21 @@ * The function which fetches rows for the data table. * Must support pagination and sorting. */ - export let getItemsEndpoint: Endpoint = { - callable: () => [], - args: [], - } + export let getItemsEndpoint: Endpoint; + /** * The function which fetches the total number of items. Used for pagination. */ - export let getItemCountEndpoint: Endpoint = { - callable: () => 0, - args: [], - } + export let getItemCountEndpoint: Endpoint; + /** + * The name of the field by which table entries are sorted by default. + */ export let defaultSortField: string = columns[0].field; + + /** + * The sort order by which table entries are sorted by default. + */ export let defaultSortOrder: 'asc' | 'desc' = 'asc'; let viewportWidth: number = 0; @@ -139,16 +142,20 @@ async function updateTable() { try { currentState = 'loading'; - totalItemCount = await getItemCountEndpoint.callable( - ...getItemCountEndpoint.args, - ); - currentItems = await getItemsEndpoint.callable( - ...getItemsEndpoint.args, - currentItemsOffset, - currentItemsPerPage, - currentSortField, - currentSortOrder - ); + + let itemCountResponse = await getItemCountEndpoint.call(); + totalItemCount = itemCountResponse.total; + + let itemsResponse = await getItemsEndpoint.call({ + queryParameters: { + skip: `${currentItemsOffset}`, + limit: `${currentItemsPerPage}`, + sortby: `${currentSortField}`, + sortorder: `${currentSortOrder}`, + } + }); + currentItems = itemsResponse; + currentState = 'finished'; } catch (error: any) { if (typeof error.body.message === 'string') { diff --git a/frontend/src/lib/components/data-table/types.ts b/frontend/src/lib/components/data-table/types.ts index b741f5c..67336bd 100644 --- a/frontend/src/lib/components/data-table/types.ts +++ b/frontend/src/lib/components/data-table/types.ts @@ -6,8 +6,3 @@ export type Column = { isLink?: boolean, linkTarget?: string, } - -export type Endpoint = { - callable: Function, - args: any[], -} diff --git a/frontend/src/lib/utils/types.ts b/frontend/src/lib/utils/types.ts new file mode 100644 index 0000000..73d3dcd --- /dev/null +++ b/frontend/src/lib/utils/types.ts @@ -0,0 +1,6 @@ +/** + * A generic string-to-string mapping. + */ +export type StringMapping = { + [key: string]: string; +} diff --git a/frontend/src/routes/todos/[todo]/+page.ts b/frontend/src/routes/todos/[todo]/+page.ts index 58c0709..b4df578 100644 --- a/frontend/src/routes/todos/[todo]/+page.ts +++ b/frontend/src/routes/todos/[todo]/+page.ts @@ -1,18 +1,19 @@ -import type { PageLoad } from './$types'; +import { EndpointFactory } from '$lib/api/endpoints'; import type { TodoItem } from '$lib/api/types'; -import { readTodo } from '$lib/api/endpoints'; -export const ssr = false; -export const load = (async ({ params }) => { - // check if user exists - const todoId = params.todo; - const todo = await readTodo(todoId); +/** @type {import('./$types').PageLoad} */ +export async function load({ fetch, params }) { + + let endpointFactory = new EndpointFactory(fetch); + let readTodoEndpoint = endpointFactory.createReadTodoEndpoint(params.todo); + return { - todo: todo + endpointFactory: endpointFactory, + todo: readTodoEndpoint.call(), }; - -}) satisfies PageLoad; +} export interface TodoDetailPage { + endpointFactory: EndpointFactory, todo: TodoItem } diff --git a/frontend/src/routes/users/[user]/+page.svelte b/frontend/src/routes/users/[user]/+page.svelte index 3ccb370..1f16acc 100644 --- a/frontend/src/routes/users/[user]/+page.svelte +++ b/frontend/src/routes/users/[user]/+page.svelte @@ -1,11 +1,15 @@
- {#if user.id === data.user.id} + {#if loggedInUser.id === data.user.id}

My Profile

{:else}

{`${data.user.first_name} ${data.user.last_name}'s Profile`}

diff --git a/frontend/src/routes/users/[user]/+page.ts b/frontend/src/routes/users/[user]/+page.ts index 93861a6..20f0016 100644 --- a/frontend/src/routes/users/[user]/+page.ts +++ b/frontend/src/routes/users/[user]/+page.ts @@ -1,18 +1,19 @@ -import type { PageLoad } from './$types'; +import { EndpointFactory } from '$lib/api/endpoints'; import type { User } from '$lib/api/types'; -import { readUser } from '$lib/api/endpoints'; -export const ssr = false; -export const load = (async ({ params }) => { - // check if user exists - const userId = params.user; - const user = await readUser(userId); +/** @type {import('./$types').PageLoad} */ +export async function load({ fetch, params }) { + + let endpointFactory = new EndpointFactory(fetch); + let readUserEndpoint = endpointFactory.createReadUserEndpoint(params.user); + return { - user: user + endpointFactory: endpointFactory, + user: readUserEndpoint.call() }; - -}) satisfies PageLoad; +} export interface UserProfilePage { + endpointFactory: EndpointFactory, user: User } diff --git a/frontend/src/routes/users/[user]/todos/+page.svelte b/frontend/src/routes/users/[user]/todos/+page.svelte index 373091e..8b308ff 100644 --- a/frontend/src/routes/users/[user]/todos/+page.svelte +++ b/frontend/src/routes/users/[user]/todos/+page.svelte @@ -1,12 +1,15 @@