This commit is contained in:
Julian Lobbes 2023-06-14 23:26:01 +02:00
parent 4e4036896e
commit 5c0f557308
13 changed files with 371 additions and 286 deletions

View File

@ -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.
* A factory class for creating `Endpoint` instances to interact with the backend API.
*
* @throws {error} - If the current user is not logged in or if their JWT is not saved in localstorage.
* 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();
* ```
*/
function getTokenOrError(): string {
let token = getTokenFromLocalstorage();
if (token === null) {
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();
}
private _getDefaultHeaders(): StringMapping {
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
}
private _getDefaultHeadersWithAuth(): StringMapping {
if (this._jwt === null) {
throw error(401, 'You are not logged in.');
}
return token;
let headers = this._getDefaultHeaders();
headers['Authorization'] = `Bearer: ${this._jwt}`;
return headers;
}
/**
* Retrieves the user with the specified ID from the backend API.
* 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.
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
* @throws {error} If the user is not currently logged in.
*/
export async function readUser(userId: number, jwt: string = getTokenOrError()): Promise<User> {
const endpoint = `/api/users/${userId}`;
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 User;
createReadUserEndpoint(userId: number): Endpoint<User> {
return new Endpoint<User>(
`/api/users/${userId}`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Retrieves the list of all users from the backend API.
* Creates an Endpoint which 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.
* @throws {error} If the user is not currently logged in.
*/
export async function readUsers(
skip: number,
limit: number,
sortby: string,
sortorder: string,
jwt: string = getTokenOrError(),
): Promise<User[]> {
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',
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as User[];
createReadUsersEndpoint(): Endpoint<User[]> {
return new Endpoint<User[]>(
`/api/admin/users/`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Retrieves the total user count from the backend API.
* Creates an Endpoint which 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.
* @throws {error} If the user is not currently logged in.
*/
export async function readUserCount(jwt: string = getTokenOrError()): Promise<number> {
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;
createReadUserCountEndpoint(): Endpoint<ItemCount> {
return new Endpoint<ItemCount>(
`/api/admin/users/total/`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Retrieves the list of all todo-items for the user with the specified ID from the backend API.
* Creates an Endwpoint which 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.
* @param {number} userId - The ID of the user whose todo-items to retrieve.
* @throws {error} If the user is not currently logged in.
*/
export async function readTodos(
userId: number,
skip: number,
limit: number,
sortby: string,
sortorder: string,
jwt: string = getTokenOrError(),
): Promise<TodoItem[]> {
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[];
createReadTodosEndpoint(userId: number): Endpoint<TodoItem[]> {
return new Endpoint<TodoItem[]>(
`/api/todos/user/${userId}`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Retrieves the total todo-item count for the user with the specified ID from the backend API.
* Creates an Endpoint which 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.
* @param {number} userId - The ID of the user whose todo-items to retrieve.
* @throws {error} If the user is not currently logged in.
*/
export async function readTodoCount(
userId: number,
jwt: string = getTokenOrError()
): Promise<number> {
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;
createReadTodoCountEndpoint(userId: number): Endpoint<ItemCount> {
return new Endpoint<ItemCount>(
`/api/todos/user/${userId}/total/`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Retrieves the todo-item with the specified ID from the backend API.
* 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<TodoItem> {
return new Endpoint<TodoItem>(
`/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.
*
* @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<TodoItem> {
const endpoint = `/api/todos/${todoId}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
createLoginEndpoint(): Endpoint<Token> {
return new Endpoint<Token>(
`/api/auth/login/`,
'POST',
this._getDefaultHeaders(),
this.fetchFunction
);
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as TodoItem;
}

View File

@ -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<T> {
/** 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<T> {
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;
}
}

View File

@ -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<string> {
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<void> {
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,

View File

@ -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<any>;
/**
* The function which fetches the total number of items. Used for pagination.
*/
export let getItemCountEndpoint: Endpoint = {
callable: () => 0,
args: [],
}
export let getItemCountEndpoint: Endpoint<ItemCount>;
/**
* 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') {

View File

@ -6,8 +6,3 @@ export type Column = {
isLink?: boolean,
linkTarget?: string,
}
export type Endpoint = {
callable: Function,
args: any[],
}

View File

@ -0,0 +1,6 @@
/**
* A generic string-to-string mapping.
*/
export type StringMapping = {
[key: string]: string;
}

View File

@ -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
}

View File

@ -1,11 +1,15 @@
<script lang="ts">
import { error } from '@sveltejs/kit';
import type { UserProfilePage } from "./+page";
import { getUserFromLocalstorage as getUser } from '$lib/auth/session';
import { formatTime } from '$lib/utils/utils';
import { onMount } from 'svelte';
export let data: UserProfilePage;
const user = getUser();
const loggedInUser = getUser();
if (loggedInUser === null) {
throw error(401, 'You must be logged in to view this page.');
}
const userCreatedDate = Date.parse(data.user.created);
let dateNow: number;
@ -23,7 +27,7 @@
</script>
<div class="grow flex flex-col items-center justify-center gap-y-4 px-4">
{#if user.id === data.user.id}
{#if loggedInUser.id === data.user.id}
<h1>My Profile</h1>
{:else}
<h1>{`${data.user.first_name} ${data.user.last_name}'s Profile`}</h1>

View File

@ -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
}

View File

@ -1,12 +1,15 @@
<script lang="ts">
import { error } from '@sveltejs/kit';
import Table from '$lib/components/data-table/Table.svelte'
import type { Column } from '$lib/components/data-table/types';
import type { UserTodosPage } from "./+page";
import { readTodos, readTodoCount } from '$lib/api/endpoints';
import type { UserProfilePage } from "../+page";
import { getUserFromLocalstorage as getUser } from '$lib/auth/session';
export let data: UserTodosPage;
const user = getUser();
export let data: UserProfilePage;
const loggedInUser = getUser();
if (loggedInUser === null) {
throw error(401, 'You must be logged in to view this page.');
}
const columns: Column[] = [
{
@ -43,20 +46,10 @@
];
const table = {
caption: user.id === data.user.id ? 'My TODOs' : `${data.user.first_name} ${data.user.last_name}'s TODOs`,
columns: user.isAdmin ? columns : columns.slice(1, -1),
getItemsEndpoint: {
callable: readTodos,
args: [
data.user.id
]
},
getItemCountEndpoint: {
callable: readTodoCount,
args: [
data.user.id
]
},
caption: loggedInUser.id === data.user.id ? 'My TODOs' : `${data.user.first_name} ${data.user.last_name}'s TODOs`,
columns: loggedInUser.isAdmin ? columns : columns.slice(1, -1),
getItemsEndpoint: data.endpointFactory.createReadTodosEndpoint(data.user.id),
getItemCountEndpoint: data.endpointFactory.createReadTodoCountEndpoint(data.user.id),
defaultSortField: 'updated',
defaultSortOrder: 'desc',
};

View File

@ -1,9 +0,0 @@
import type { User } from '$lib/api/types';
import { load as defaultLoad } from '../+page';
export const ssr = false;
export const load = defaultLoad;
export interface UserTodosPage {
user: User
}

View File

@ -1,8 +1,9 @@
<script lang="ts">
import Table from '$lib/components/data-table/Table.svelte';
import type { Column } from '$lib/components/data-table/types';
import { readUsers, readUserCount } from '$lib/api/endpoints';
import { onMount } from 'svelte';
import type { UserListPage } from "./+page";
export let data: UserListPage;
const columns: Column[] = [
{
@ -44,14 +45,8 @@
const table = {
caption: "List of users",
columns: columns,
getItemsEndpoint: {
callable: readUsers,
args: []
},
getItemCountEndpoint: {
callable: readUserCount,
args: []
},
getItemsEndpoint: data.endpointFactory.createReadUsersEndpoint(),
getItemCountEndpoint: data.endpointFactory.createReadUserCountEndpoint(),
defaultSortField: 'id',
defaultSortOrder: 'asc',
};

View File

@ -0,0 +1,19 @@
import { EndpointFactory } from '$lib/api/endpoints';
import type { User } from '$lib/api/types';
/** @type {import('./$types').PageLoad} */
export async function load({ fetch }) {
let endpointFactory = new EndpointFactory(fetch);
let readUsersEndpoint = endpointFactory.createReadUsersEndpoint();
return {
endpointFactory: endpointFactory,
users: readUsersEndpoint.call()
};
}
export interface UserListPage {
endpointFactory: EndpointFactory,
users: User[]
}