From faac653fa1557cf64ef4881809e76e4cfdbdbc0d Mon Sep 17 00:00:00 2001 From: Reinaldy Rafli Date: Sun, 28 May 2023 14:07:03 +0700 Subject: [PATCH] feat: implement user client --- application/ddl/error_log.sql | 17 ++ application/ddl/project.sql | 10 + application/ddl/user.sql | 18 ++ application/interfaces/IAuthentication.ts | 2 +- .../src/clickhouse/Clickhouse.ts | 11 +- .../src/httpClient/http-fetch-client.ts | 10 +- .../clickhouse-ts/src/httpClient/interface.ts | 2 +- .../clickhouse-ts/src/utils/format.ts | 8 +- .../clickhouse-ts/tests/clickhouse.test.ts | 4 +- application/repositories/ErrorLogClient.ts | 37 ++-- application/repositories/GithubClient.ts | 6 +- application/repositories/ProjectClient.ts | 76 +++---- application/repositories/TokenClient.ts | 2 +- application/repositories/UserClient.ts | 204 +++++++++++++++++- application/services/User.ts | 2 +- primitives/UUID.ts | 11 +- 16 files changed, 320 insertions(+), 100 deletions(-) create mode 100644 application/ddl/error_log.sql create mode 100644 application/ddl/project.sql create mode 100644 application/ddl/user.sql diff --git a/application/ddl/error_log.sql b/application/ddl/error_log.sql new file mode 100644 index 0000000..0a5a8ff --- /dev/null +++ b/application/ddl/error_log.sql @@ -0,0 +1,17 @@ +-- See https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree#table_engine-mergetree-creating-a-table +CREATE TABLE error_logs +( + id UUID, + project UUID, + environment String, + level String, + title String, + status UInt8, + platform Nullable(String), + language Nullable(String), + payload String, + timestamp DateTime +) Engine = MergeTree() + ORDER BY (id, timestamp) + TTL timestamp + INTERVAL 3 MONTH + DELETE; \ No newline at end of file diff --git a/application/ddl/project.sql b/application/ddl/project.sql new file mode 100644 index 0000000..73eed00 --- /dev/null +++ b/application/ddl/project.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS project +( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + repository_url TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by UUID NOT NULL +); diff --git a/application/ddl/user.sql b/application/ddl/user.sql new file mode 100644 index 0000000..afb1b75 --- /dev/null +++ b/application/ddl/user.sql @@ -0,0 +1,18 @@ +CREATE TABLE human_users +( + id UUID PRIMARY KEY, + github_id INTEGER NOT NULL, + github_node_id VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + avatar_url TEXT DEFAULT NULL, + profile_url TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by UUID NOT NULL +); + +CREATE UNIQUE INDEX human_users_github_id_unique ON human_users (github_id); + +CREATE INDEX human_users_username_search ON human_users (username NULLS LAST); \ No newline at end of file diff --git a/application/interfaces/IAuthentication.ts b/application/interfaces/IAuthentication.ts index 2bb8822..d2b71bf 100644 --- a/application/interfaces/IAuthentication.ts +++ b/application/interfaces/IAuthentication.ts @@ -1,6 +1,6 @@ import {Project} from "~/primitives/Project"; import {Token} from "~/primitives/Token"; -import { User } from "~~/primitives/User"; +import {User} from "~~/primitives/User"; export interface IAuthentication { validateProjectToken(token: string): Promise; diff --git a/application/internal/clickhouse-ts/src/clickhouse/Clickhouse.ts b/application/internal/clickhouse-ts/src/clickhouse/Clickhouse.ts index cb3ad52..3668671 100644 --- a/application/internal/clickhouse-ts/src/clickhouse/Clickhouse.ts +++ b/application/internal/clickhouse-ts/src/clickhouse/Clickhouse.ts @@ -3,15 +3,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { - JSONFormatRow, - Connection, - Options, - QueryOptions -} from './interface' +import {Connection, JSONFormatRow, Options, QueryOptions} from './interface' -import { HttpClientResponse } from '../httpClient' -import { jsonInsertFormatToSqlValues, jsonRowsToInsertFormat } from '../utils' +import {HttpClientResponse} from '../httpClient' +import {jsonInsertFormatToSqlValues, jsonRowsToInsertFormat} from '../utils' import {HttpFetchClient} from "../httpClient/http-fetch-client"; /** diff --git a/application/internal/clickhouse-ts/src/httpClient/http-fetch-client.ts b/application/internal/clickhouse-ts/src/httpClient/http-fetch-client.ts index 8dd6ae4..b4edbe9 100644 --- a/application/internal/clickhouse-ts/src/httpClient/http-fetch-client.ts +++ b/application/internal/clickhouse-ts/src/httpClient/http-fetch-client.ts @@ -1,10 +1,6 @@ -import { URL, URLSearchParams } from "node:url"; -import { - HttpClientConstructor, - HttpClientRequest, - HttpClientResponse -} from './interface' -import { HttpClickhouseAxiosError } from '../errors' +import {URL, URLSearchParams} from "node:url"; +import {HttpClientConstructor, HttpClientRequest, HttpClientResponse} from './interface' +import {HttpClickhouseAxiosError} from '../errors' /** * HttpClient wraps fetch and provides transparent data transferring between your code and clickhouse server diff --git a/application/internal/clickhouse-ts/src/httpClient/interface.ts b/application/internal/clickhouse-ts/src/httpClient/interface.ts index f718028..4efd05f 100644 --- a/application/internal/clickhouse-ts/src/httpClient/interface.ts +++ b/application/internal/clickhouse-ts/src/httpClient/interface.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { QueryOptions } from '../clickhouse' +import {QueryOptions} from '../clickhouse' export interface RequestParams { query: string diff --git a/application/internal/clickhouse-ts/src/utils/format.ts b/application/internal/clickhouse-ts/src/utils/format.ts index fd1f49e..7a90b40 100644 --- a/application/internal/clickhouse-ts/src/utils/format.ts +++ b/application/internal/clickhouse-ts/src/utils/format.ts @@ -1,9 +1,9 @@ /* eslint-disable no-tabs */ import ss from 'sqlstring' -import { JSONFormatRow } from '../clickhouse' -import { PreprocessInsertQueryError } from '../errors' -import { isNull, isObject } from './common' -import { OptimizedJSONInsertFormat } from './interface' +import {JSONFormatRow} from '../clickhouse' +import {PreprocessInsertQueryError} from '../errors' +import {isNull, isObject} from './common' +import {OptimizedJSONInsertFormat} from './interface' /** * Get optimized and validated insert format for http insert request diff --git a/application/internal/clickhouse-ts/tests/clickhouse.test.ts b/application/internal/clickhouse-ts/tests/clickhouse.test.ts index 5a81bf8..92bb453 100644 --- a/application/internal/clickhouse-ts/tests/clickhouse.test.ts +++ b/application/internal/clickhouse-ts/tests/clickhouse.test.ts @@ -1,5 +1,5 @@ -import {describe, it, expect, beforeAll, afterAll} from "vitest"; -import { Clickhouse } from '../src/clickhouse' +import {afterAll, beforeAll, describe, expect, it} from "vitest"; +import {Clickhouse} from '../src/clickhouse' const instance = new Clickhouse({ url: process.env.CLICKHOUSE_URL ?? "localhost", diff --git a/application/repositories/ErrorLogClient.ts b/application/repositories/ErrorLogClient.ts index b4cfb51..acbc464 100644 --- a/application/repositories/ErrorLogClient.ts +++ b/application/repositories/ErrorLogClient.ts @@ -4,26 +4,27 @@ import {UUID} from "~/primitives/UUID"; import {IErrorLogRepository} from "~/application/interfaces/IErrorLogRepository"; export class ErrorLogClient implements IErrorLogRepository { - constructor(private readonly client: Clickhouse) { } + constructor(private readonly client: Clickhouse) { + } async migrate(): Promise { await this.client.query( - `CREATE TABLE IF NOT EXISTS error_logs ( - id UUID NOT NULL, - project UUID NOT NULL, - environment String NOT NULL, - level String NOT NULL, - title String NOT NULL, - status UInt8 NOT NULL, - platform Nullable(String), - language Nullable(String), - payload String NOT NULL, - timestamp DateTime NOT NULL, - PRIMARY KEY(timestamp) - ) - Engine = MergeTree - ORDER BY timestamp - TTL timestamp + INTERVAL 3 MONTH DELETE`, + `CREATE TABLE error_logs + ( + id UUID, + project UUID, + environment String, + level String, + title String, + status UInt8, + platform Nullable(String), + language Nullable(String), + payload String, + timestamp DateTime + ) Engine = MergeTree() + ORDER BY (id, timestamp) + TTL timestamp + INTERVAL 3 MONTH + DELETE;`, ); } @@ -44,4 +45,4 @@ export class ErrorLogClient implements IErrorLogRepository { }], ); } - } \ No newline at end of file +} \ No newline at end of file diff --git a/application/repositories/GithubClient.ts b/application/repositories/GithubClient.ts index 1868233..0e70489 100644 --- a/application/repositories/GithubClient.ts +++ b/application/repositories/GithubClient.ts @@ -1,11 +1,15 @@ import {type GithubOrganization, type GithubUser, type IGithub} from "~/application/interfaces/IGithub"; import {GithubApiError} from "~/errors/GithubApiError"; +import {InvalidArgumentError} from "~/errors/InvalidArgumentError"; export class GithubClient implements IGithub { constructor( private readonly clientId: string, private readonly clientSecret: string - ) {} + ) { + if (clientId === "") throw new InvalidArgumentError("clientId is empty"); + if (clientSecret === "") throw new InvalidArgumentError("clientSecret is empty"); + } async accessToken(code: string): Promise { const response = await fetch("https://github.com/login/oauth/access_token", { diff --git a/application/repositories/ProjectClient.ts b/application/repositories/ProjectClient.ts index 8c16498..0d10962 100644 --- a/application/repositories/ProjectClient.ts +++ b/application/repositories/ProjectClient.ts @@ -15,17 +15,14 @@ export class ProjectClient implements IProjectRepository { try { await conn.query("BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED"); - await conn.query(`INSERT INTO - project - ( - id, - name, - repository_url, - created_at, - created_by, - updated_at, - updated_by - )`, + await conn.query(`INSERT INTO project + (id, + name, + repository_url, + created_at, + created_by, + updated_at, + updated_by)`, [ project.id.toString(), project.name, @@ -53,7 +50,7 @@ export class ProjectClient implements IProjectRepository { const conn = await this.client.connect(); try { - const queryResult = await conn.query(`SELECT EXISTS(SELECT * FROM project WHERE id = ?) AS exists`,[id.toString()]); + const queryResult = await conn.query(`SELECT EXISTS(SELECT * FROM project WHERE id = ?) AS exists`, [id.toString()]); return queryResult.rows[0].exists; @@ -68,17 +65,14 @@ export class ProjectClient implements IProjectRepository { const conn = await this.client.connect(); try { - const queryResult = await conn.query(`SELECT - id, - name, - repository_url, - created_at, - created_by - FROM - project - WHERE - id = $1 - LIMIT 1`, + const queryResult = await conn.query(`SELECT id, + name, + repository_url, + created_at, + created_by + FROM project + WHERE id = $1 + LIMIT 1`, [id.toString()], ); @@ -125,14 +119,12 @@ export class ProjectClient implements IProjectRepository { const conn = await this.client.connect(); try { - const queryResult = conn.query(new Cursor(`SELECT - id, - name, - repository_url, - created_at, - created_by - FROM - project`)); + const queryResult = conn.query(new Cursor(`SELECT id, + name, + repository_url, + created_at, + created_by + FROM project`)); const rows = await queryResult.read(500); @@ -145,7 +137,8 @@ export class ProjectClient implements IProjectRepository { for (const row of rows) { if (row === undefined) continue; - let createdAt: Date = new Date(0);; + let createdAt: Date = new Date(0); + if (row.created_at !== undefined) { if (typeof row.created_at === "string") { createdAt = new Date(row.created_at); @@ -179,15 +172,16 @@ export class ProjectClient implements IProjectRepository { try { await conn.query("BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;"); - await conn.query(`CREATE TABLE IF NOT EXISTS project ( - id UUID PRIMARY KEY, - name VARCHAR(255) NOT NULL, - repository_url TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - created_by UUID NOT NULL, - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_by UUID NOT NULL - )`); + await conn.query(`CREATE TABLE IF NOT EXISTS project + ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + repository_url TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by UUID NOT NULL + )`); await conn.query("COMMIT;"); } catch (error: unknown) { diff --git a/application/repositories/TokenClient.ts b/application/repositories/TokenClient.ts index f20fd50..fa010a9 100644 --- a/application/repositories/TokenClient.ts +++ b/application/repositories/TokenClient.ts @@ -15,7 +15,7 @@ export class TokenClient implements ITokenRepository { generate(user: User): Token { // Generate a UUID. - const id = new UUID(); + const id = UUID.v7(); // Check if the id is being used if (this.store.has(id.toString())) { diff --git a/application/repositories/UserClient.ts b/application/repositories/UserClient.ts index ed74d5e..2b255db 100644 --- a/application/repositories/UserClient.ts +++ b/application/repositories/UserClient.ts @@ -1,29 +1,211 @@ import pg from "pg"; -import Cursor from "pg-cursor"; import {IUserRepository} from "~/application/interfaces/IUserRepository"; import {User} from "~/primitives/User"; +import {InvalidArgumentError} from "~/errors/InvalidArgumentError"; +import Cursor from "pg-cursor"; +import {NotFoundError} from "~/errors/NotFoundError"; +import {UUID} from "~/primitives/UUID"; export class UserClient implements IUserRepository { constructor(private readonly database: pg.Pool) { + if (database == null) throw new InvalidArgumentError("database is null or undefined"); } - create(user: User): Promise { - return Promise.resolve(undefined); + async create(user: User): Promise { + const userId = UUID.v4(); + const connection = await this.database.connect(); + try { + await connection.query("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ WRITE"); + + await connection.query( + `INSERT INTO human_users (id, + github_id, + github_node_id, + username, + name, + avatar_url, + profile_url, + created_at, + created_by, + updated_at, + updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8)`, + [ + userId, + user.id, + user.nodeId, + user.username, + user.name, + user.avatarUrl, + user.profileUrl, + new Date(), + userId, + ], + ); + + await connection.query("COMMIT"); + } catch (error: unknown) { + await connection.query("ROLLBACK"); + + throw error; + } finally { + connection.release(); + } } - getByUsername(username: string): Promise { - return Promise.resolve(undefined); + async getByUsername(username: string): Promise { + const connection = await this.database.connect(); + + try { + await connection.query("BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED READ ONLY"); + + const queryResult = connection.query(new Cursor( + `SELECT github_id, + github_node_id, + username, + name, + avatar_url, + profile_url + FROM human_users + WHERE username = $1 + LIMIT 1`, + [username] + )); + + const rows = await queryResult.read(1); + + if (rows.length === 0) { + throw new NotFoundError(`${username} was not found`); + } + + let user: User; + for (const row of rows) { + if (row === undefined) throw new NotFoundError(`${username} returned null`); + + user = new User( + row.github_id, + row.github_node_id, + row.username, + row.name, + row.avatar_url, + row.profile_url); + } + + await connection.query("COMMIT"); + + return user; + } catch (error: unknown) { + await connection.query("ROLLBACK"); + + throw error; + } finally { + connection.release(); + } } - listAll(): Promise { - return Promise.resolve([]); + async listAll(): Promise { + const connection = await this.database.connect(); + + try { + await connection.query("BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED READ ONLY"); + + const queryResult = connection.query(new Cursor( + `SELECT github_id, + github_node_id, + username, + name, + avatar_url, + profile_url + FROM human_users` + )); + + const rows = await queryResult.read(Number.MAX_SAFE_INTEGER); + + if (rows.length === 0) { + return []; + } + + const users: User[] = []; + for (const row of rows) { + if (row === undefined) throw new NotFoundError(`${username} returned null`); + + const user = new User( + row.github_id, + row.github_node_id, + row.username, + row.name, + row.avatar_url, + row.profile_url); + + users.push(user); + } + + await connection.query("COMMIT"); + + return users; + } catch (error: unknown) { + await connection.query("ROLLBACK"); + + throw error; + } finally { + connection.release(); + } } - migrate(): Promise { - return Promise.resolve(undefined); + async migrate(): Promise { + const connection = await this.database.connect(); + + try { + await connection.query("BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE"); + + await connection.query(`CREATE TABLE IF NOT EXISTS human_users + ( + id UUID PRIMARY KEY, + github_id INTEGER NOT NULL, + github_node_id VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + avatar_url TEXT DEFAULT NULL, + profile_url TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by UUID NOT NULL + )`); + + await connection.query("CREATE UNIQUE INDEX IF NOT EXISTS human_users_github_id_unique ON human_users (github_id)"); + + await connection.query("CREATE INDEX IF NOT EXISTS human_users_username_search ON human_users (username NULLS LAST)"); + + await connection.query("COMMIT"); + } catch (error: unknown) { + await connection.query("ROLLBACK"); + + throw error; + } finally { + connection.release(); + } } - remove(loginUsername: string): Promise { - return Promise.resolve(undefined); + async remove(loginUsername: string): Promise { + const connection = await this.database.connect(); + try { + await connection.query("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ WRITE"); + + await connection.query( + `DELETE + FROM human_users + WHERE username = $1`, + [loginUsername], + ); + + await connection.query("COMMIT"); + } catch (error: unknown) { + await connection.query("ROLLBACK"); + + throw error; + } finally { + connection.release(); + } } } \ No newline at end of file diff --git a/application/services/User.ts b/application/services/User.ts index 8d712de..38b72a2 100644 --- a/application/services/User.ts +++ b/application/services/User.ts @@ -50,7 +50,7 @@ export class UserService implements IUser { } // Rethrow error to upper layer - return; + throw error; } } diff --git a/primitives/UUID.ts b/primitives/UUID.ts index 4b640f5..ac47d92 100644 --- a/primitives/UUID.ts +++ b/primitives/UUID.ts @@ -15,8 +15,8 @@ export class UUID { constructor(str?: string) { if (str === undefined) { - this.m_str = this.newUuid(); - this.version = 7; + this.m_str = uuidv4(); + this.version = 4; } else { this.m_str = str; this.version = 4; @@ -35,8 +35,11 @@ export class UUID { toString() { return this.m_str; } + public static v4(): UUID { + return new UUID(uuidv4()); + } - private newUuid(): string { - return uuidv7(); + public static v7(): UUID { + return new UUID(uuidv7()); } } \ No newline at end of file