Initial commit

This commit is contained in:
Silas
2024-09-11 16:40:12 -04:00
commit 6bdf7a32a9
38 changed files with 11219 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { expect, describe, it, beforeAll, afterAll } from "vitest";
import { PlatformTest } from "@tsed/common";
import SuperTest from "supertest";
import { Server } from "./Server";
describe("Server", () => {
beforeAll(PlatformTest.bootstrap(Server));
afterAll(PlatformTest.reset);
it("should call GET /rest", async () => {
const request = SuperTest(PlatformTest.callback());
const response = await request.get("/rest").expect(404);
expect(response.body).toEqual({
errors: [],
message: 'Resource "/rest" not found',
name: "NOT_FOUND",
status: 404
});
});
});

68
src/Server.ts Normal file
View File

@@ -0,0 +1,68 @@
import { join } from "path";
import { Configuration, Inject } from "@tsed/di";
import { PlatformApplication } from "@tsed/common";
import "@tsed/platform-express"; // /!\ keep this import
import "@tsed/ajv";
import "@tsed/swagger";
import "@tsed/passport";
import { config } from "./config/index";
import * as rest from "./controllers/rest/index";
import * as pages from "./controllers/pages/index";
@Configuration({
...config,
acceptMimes: ["application/json"],
httpPort: process.env.PORT || 8083,
httpsPort: false, // CHANGE
disableComponentsScan: true,
ajv: {
returnsCoercedValues: true
},
mount: {
"/rest": [...Object.values(rest)],
"/": [...Object.values(pages)]
},
swagger: [
{
path: "/doc",
specVersion: "3.0.1"
}
],
middlewares: [
"cors",
"cookie-parser",
"compression",
"method-override",
"json-parser",
{ use: "urlencoded-parser", options: { extended: true } },
{
use: "express-session",
options: {
secret: process.env.PASSPORT_SESSION_KEY || "mysecretkey",
resave: true,
saveUninitialized: true,
// maxAge: 36000,
cookie: {
path: "/",
httpOnly: true,
secure: false
// maxAge: null
}
}
}
],
views: {
root: join(process.cwd(), "../views"),
extensions: {
ejs: "ejs"
}
},
exclude: ["**/*.spec.ts"]
})
export class Server {
@Inject()
protected app: PlatformApplication;
@Configuration()
protected settings: Configuration;
}

7
src/config/envs/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import dotenv from "dotenv-flow";
process.env.NODE_ENV = process.env.NODE_ENV || "development";
export const config = dotenv.config();
export const isProduction = process.env.NODE_ENV === "production";
export const envs = process.env;

10
src/config/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { readFileSync } from "fs";
import { envs } from "./envs/index";
import loggerConfig from "./logger/index";
const pkg = JSON.parse(readFileSync("./package.json", { encoding: "utf8" }));
export const config: Partial<TsED.Configuration> = {
version: pkg.version,
envs,
logger: loggerConfig
};

View File

@@ -0,0 +1,24 @@
import { $log, DILoggerOptions } from "@tsed/common";
import { isProduction } from "../envs/index";
if (isProduction) {
$log.appenders.set("stdout", {
type: "stdout",
levels: ["info", "debug"],
layout: {
type: "json"
}
});
$log.appenders.set("stderr", {
levels: ["trace", "fatal", "error", "warn"],
type: "stderr",
layout: {
type: "json"
}
});
}
export default <DILoggerOptions>{
disableRoutesSummary: isProduction
};

View File

@@ -0,0 +1,29 @@
import { Constant, Controller } from "@tsed/di";
import { HeaderParams } from "@tsed/platform-params";
import { View } from "@tsed/platform-views";
import { SwaggerSettings } from "@tsed/swagger";
import { Hidden, Get, Returns } from "@tsed/schema";
@Hidden()
@Controller("/")
export class IndexController {
@Constant("swagger", [])
private swagger: SwaggerSettings[];
@Get("/")
@View("swagger.ejs")
@(Returns(200, String).ContentType("text/html"))
get(@HeaderParams("x-forwarded-proto") protocol: string, @HeaderParams("host") host: string) {
const hostUrl = `${protocol || "http"}://${host}`;
return {
BASE_URL: hostUrl,
docs: this.swagger.map((conf) => {
return {
url: hostUrl + conf.path,
...conf
};
})
};
}
}

View File

@@ -0,0 +1,5 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./IndexController";

View File

@@ -0,0 +1,39 @@
import { expect, describe, it, afterAll, beforeAll } from "vitest";
import { PlatformTest } from "@tsed/common";
import SuperTest from "supertest";
import { LinkController } from "./LinkController";
import { Server } from "../../Server";
import { randomUUID } from "crypto";
describe("LinkController", () => {
beforeAll(
PlatformTest.bootstrap(Server, {
mount: {
"/rest": [LinkController]
}
})
);
afterAll(PlatformTest.reset);
it("should call POST /rest/links and GET /rest/links/:id", async () => {
const request = SuperTest(PlatformTest.callback());
const username = `silentsilas-${randomUUID()}`;
const response = await request
.post("/rest/links")
.send({
service: "github",
serviceUsername: username
})
.expect(201);
const response2 = await request.get(`/rest/users/${response.body.userId}`).expect(200);
expect(response.body.id).toBeTruthy();
expect(response.body.service).toEqual("github");
expect(response.body.serviceUsername).toEqual(username);
expect(response2.body.id).toEqual(response.body.userId);
expect(response2.body.service).toEqual("github");
expect(response2.body.serviceUsername).toEqual(username);
});
});

View File

@@ -0,0 +1,67 @@
import { BodyParams, PathParams } from "@tsed/platform-params";
import { Description, Get, Post, Returns, Summary } from "@tsed/schema";
import { Controller, Inject } from "@tsed/di";
import { Link } from "../../entities/link/Link";
import { SqliteDatasource } from "../../datasources/SqliteDatasource";
import { DataSource } from "typeorm";
import { User } from "../../entities/User";
import { CreateLinkDto } from "../../entities/link/CreateLinkDTO";
import { executeWithRetry } from "../../datasources/SqliteDatasource";
@Controller("/links")
export class LinkController {
constructor(@Inject(SqliteDatasource) private sqliteDataSource: DataSource) {}
@Post("/")
@Summary("Create a new link")
@Description("Creates a new link and associates it with a user")
@Returns(201, Link)
async create(@BodyParams() linkData: CreateLinkDto): Promise<Link> {
return executeWithRetry(async (queryRunner) => {
const userRepository = queryRunner.manager.getRepository(User);
const linkRepository = queryRunner.manager.getRepository(Link);
let user = await userRepository.findOne({
where: {
service: linkData.service,
serviceUsername: linkData.serviceUsername
}
});
if (!user) {
user = userRepository.create({
service: linkData.service,
serviceUsername: linkData.serviceUsername
});
user = await queryRunner.manager.save(User, user);
}
const link = linkRepository.create({
...linkData,
user
});
return queryRunner.manager.save(Link, link);
}, this.sqliteDataSource);
}
@Get("/")
@Summary("Get all links")
@(Returns(200, Array).Of(Link))
async getList(): Promise<Link[]> {
return executeWithRetry(async (queryRunner) => {
const linkRepository = queryRunner.manager.getRepository(Link);
return linkRepository.find({ relations: ["user"] });
}, this.sqliteDataSource);
}
@Get("/:id")
@Summary("Get a link by ID")
@Returns(200, Link)
async getOne(@PathParams("id") id: string): Promise<Link | null> {
return executeWithRetry(async (queryRunner) => {
const linkRepository = queryRunner.manager.getRepository(Link);
return linkRepository.findOne({ where: { id }, relations: ["user"] });
}, this.sqliteDataSource);
}
}

View File

@@ -0,0 +1,44 @@
import { expect, describe, it, beforeEach, afterEach, beforeAll } from "vitest";
import { PlatformTest } from "@tsed/common";
import { UserController } from "./UserController";
import { User } from "../../entities/User";
import { DataSource } from "typeorm";
import { v4 as uuidv4 } from "uuid";
import { Server } from "../../Server";
import { sqliteDatasource } from "src/datasources/SqliteDatasource";
describe("UserController", () => {
let controller: UserController;
beforeAll(PlatformTest.bootstrap(Server));
beforeEach(async () => {
controller = await PlatformTest.invoke(UserController, [
{
token: DataSource,
use: sqliteDatasource
}
]);
});
afterEach(() => {
PlatformTest.reset();
});
it("should get a user by ID", async () => {
const userId = uuidv4();
const user = new User();
user.id = userId;
user.service = "github";
user.serviceUsername = `silentsilas-${userId}`;
const repo = sqliteDatasource.getRepository(User);
await repo.save(user);
const result = await controller.getOne(userId);
expect(result).toEqual(user);
expect(result?.id).toEqual(userId);
expect(result?.service).toEqual("github");
expect(result?.serviceUsername).toEqual(`silentsilas-${userId}`);
});
});

View File

@@ -0,0 +1,23 @@
import { expect, describe, it, beforeEach, afterEach } from "vitest";
import { PlatformTest } from "@tsed/common";
import { UserController } from "./UserController";
describe("UserController", () => {
beforeEach(PlatformTest.create);
afterEach(PlatformTest.reset);
it("works", () => {
const instance = PlatformTest.get<UserController>(UserController);
// const instance = PlatformTest.invoke<HelloWorldController>(HelloWorldController); // get fresh instance
expect(instance).toBeInstanceOf(UserController);
});
it("gets a list of users", async () => {
const instance = PlatformTest.get<UserController>(UserController);
const users = await instance.getList();
expect(users).toBeInstanceOf(Array);
});
});

View File

@@ -0,0 +1,44 @@
import { PathParams } from "@tsed/platform-params";
import { Description, Get, Post, Returns, Summary } from "@tsed/schema";
import { Controller, Inject } from "@tsed/di";
import { User } from "../../entities/User";
import { SqliteDatasource } from "../../datasources/SqliteDatasource";
import { DataSource } from "typeorm";
import { executeWithRetry } from "../../datasources/SqliteDatasource";
import { Forbidden } from "@tsed/exceptions";
@Controller("/users")
export class UserController {
constructor(@Inject(SqliteDatasource) private sqliteDataSource: DataSource) {}
// disable the create method and endpoint
@Post("/")
@Summary("Create a new user")
@Description("This endpoint can not be used. Users are created automatically when a new link is created.")
@Returns(403)
async create(): Promise<void> {
throw new Forbidden("Users are created automatically when a new link is created.");
}
@Get("/")
@Summary("Get all users")
@(Returns(200, Array).Of(User))
async getList(): Promise<User[]> {
return executeWithRetry(async (queryRunner) => {
return queryRunner.manager.find(User);
}, this.sqliteDataSource);
}
@Get("/:id")
@Summary("Get a user by ID")
@Returns(200, User)
async getOne(@PathParams("id") id: string): Promise<User | null> {
return executeWithRetry(async (queryRunner) => {
return queryRunner.manager.findOne(User, {
where: {
id
}
});
}, this.sqliteDataSource);
}
}

View File

@@ -0,0 +1,6 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./LinkController";
export * from "./UserController";

View File

@@ -0,0 +1,16 @@
import { expect, describe, it, beforeEach, afterEach } from "vitest";
import { PlatformTest } from "@tsed/common";
import { SqliteDatasource } from "./SqliteDatasource";
import { DataSource } from "typeorm";
describe("SqliteDatasource", () => {
beforeEach(PlatformTest.create);
afterEach(PlatformTest.reset);
it("should do something", () => {
const instance = PlatformTest.get<SqliteDatasource>(SqliteDatasource);
// const instance = PlatformTest.invoke<SqliteDatasource>(SqliteDatasource); // get fresh instance
expect(instance).toBeInstanceOf(DataSource);
});
});

View File

@@ -0,0 +1,84 @@
import { registerProvider } from "@tsed/di";
import { DataSource } from "typeorm";
import { Logger } from "@tsed/logger";
import { User } from "../entities/User";
import { Link } from "../entities/link/Link";
import { QueryRunner } from "typeorm";
export const SqliteDatasource = Symbol.for("SqliteDatasource");
export type SqliteDatasource = DataSource;
export const sqliteDatasource =
process.env.NODE_ENV === "test"
? new DataSource({
type: "sqlite",
entities: [User, Link],
database: ":memory:",
synchronize: true,
extra: {
connectionLimit: 1,
busyTimeout: 4000,
busyErrorRetry: 10
}
})
: new DataSource({
type: "sqlite",
entities: [User, Link],
database: "database.sqlite",
synchronize: true,
extra: {
connectionLimit: 1,
busyTimeout: 4000,
busyErrorRetry: 10
}
});
registerProvider<DataSource>({
provide: SqliteDatasource,
type: "typeorm:datasource",
deps: [Logger],
async useAsyncFactory(logger: Logger) {
await sqliteDatasource.initialize();
logger.info("Connected with typeorm to database: Sqlite");
return sqliteDatasource;
},
hooks: {
$onDestroy(dataSource) {
return dataSource.isInitialized && dataSource.destroy();
}
}
});
export async function executeWithRetry<T>(
operation: (queryRunner: QueryRunner) => Promise<T>,
dataSource: DataSource,
maxRetries = 10,
delay = 1000
): Promise<T> {
let retries = 0;
while (true) {
const queryRunner = dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const result = await operation(queryRunner);
await queryRunner.commitTransaction();
return result;
} catch (error) {
await queryRunner.rollbackTransaction();
if (error.code === "SQLITE_BUSY" && retries < maxRetries) {
retries++;
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
} finally {
await queryRunner.release();
}
}
}

32
src/entities/User.ts Normal file
View File

@@ -0,0 +1,32 @@
import { CollectionOf, MaxLength, Property, Required } from "@tsed/schema";
import { BeforeInsert, Column, Entity, OneToMany, PrimaryColumn } from "typeorm";
import { Link } from "./link/Link";
import { v4 as uuidv4 } from "uuid";
@Entity()
export class User {
@PrimaryColumn("uuid")
@Property()
id: string;
@Column({ length: 100 })
@MaxLength(100)
@Required()
service: string;
@Column({ length: 100 })
@MaxLength(100)
@Required()
serviceUsername: string;
@OneToMany(() => Link, (link) => link.user)
@CollectionOf(() => Link)
links: Link[];
@BeforeInsert()
generateId() {
if (!this.id) {
this.id = uuidv4();
}
}
}

View File

@@ -0,0 +1,13 @@
import { Property, Required, MaxLength } from "@tsed/schema";
export class CreateLinkDto {
@Property()
@Required()
@MaxLength(100)
service: string;
@Property()
@Required()
@MaxLength(100)
serviceUsername: string;
}

36
src/entities/link/Link.ts Normal file
View File

@@ -0,0 +1,36 @@
import { MaxLength, Property, Required } from "@tsed/schema";
import { Column, Entity, ManyToOne, PrimaryColumn, JoinColumn, BeforeInsert } from "typeorm";
import { User } from "../User";
import { v4 as uuidv4 } from "uuid";
@Entity()
export class Link {
@PrimaryColumn("uuid")
@Property()
id: string;
@Column({ length: 100 })
@MaxLength(100)
@Required()
service: string;
@Column({ length: 100 })
@MaxLength(100)
@Required()
serviceUsername: string;
@ManyToOne(() => User, (user) => user.links, { onDelete: "SET NULL", onUpdate: "CASCADE" })
@JoinColumn({ name: "userId" })
user: User;
@Column({ type: "uuid", nullable: true })
@Property()
userId: string | null;
@BeforeInsert()
generateId() {
if (!this.id) {
this.id = uuidv4();
}
}
}

18
src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { $log } from "@tsed/common";
import { PlatformExpress } from "@tsed/platform-express";
import { Server } from "./Server";
async function bootstrap() {
try {
const platform = await PlatformExpress.bootstrap(Server);
await platform.listen();
process.on("SIGINT", () => {
platform.stop();
});
} catch (error) {
$log.error({ event: "SERVER_BOOTSTRAP_ERROR", message: error.message, stack: error.stack });
}
}
bootstrap();