From 1f200b886b9996027579d375391d8d404a2016a6 Mon Sep 17 00:00:00 2001 From: Tim Fabian Date: Tue, 9 Dec 2025 23:02:48 +0100 Subject: [PATCH 1/2] improved docs, added roles to open api, added link to homepage to open api --- CONTRIBUTING.md | 32 +++ README.md | 15 +- docs/assets-explorer.png | Bin 0 -> 41852 bytes docs/assets.md | 8 + ...ntication-and-authorization.md => auth.md} | 6 +- docs/backup.md | 74 ++++++ docs/creating-endpoints.md | 36 ++- docs/cron.md | 66 ++++++ docs/data-source.md | 212 ++++++++++++++++++ docs/di.md | 107 +++++++++ docs/email.md | 66 ++++++ docs/http-client.md | 135 +++++++++++ docs/logging.md | 54 +++++ docs/metrics.md | 57 +++++ docs/multithreading.md | 11 +- docs/plugin.md | 1 + docs/websocket.md | 92 ++++++++ package-lock.json | 12 +- package.json | 2 +- sandbox/assets/public/open-api/custom.css | 37 +++ sandbox/src/controllers/metrics.controller.ts | 4 +- sandbox/src/controllers/test.controller.ts | 1 + sandbox/src/create-default-data.function.ts | 15 +- sandbox/src/data-sources/db/db.data-source.ts | 7 +- sandbox/src/models/test.model.ts | 3 - src/application-options.model.ts | 4 +- src/auth/auth.service.ts | 11 +- src/auth/decorators/user-repo.decorator.ts | 2 +- .../strategies/jwt/jwt-refresh-token.model.ts | 2 +- src/backup/backup-service.test.ts | 14 +- src/backup/backup.service.ts | 4 +- src/cron/cron-job.model.ts | 20 +- src/data-source/cascade-delete.test.ts | 13 +- src/data-source/data-source.service.ts | 6 +- .../data-sources/data-source.interface.ts | 80 +++++++ src/data-source/data-sources/index.ts | 2 + .../postgres-data-source.model.ts} | 193 +++++++++++----- .../decorators/data-source.decorator.ts | 4 +- src/data-source/index.ts | 2 +- src/data-source/migration/migration.model.ts | 119 +--------- src/data-source/migration/migration.test.ts | 26 +-- src/data-source/query-failed.error.ts | 2 +- src/data-source/repository.ts | 2 +- .../transaction/transaction.test.ts | 13 +- .../many-to-many-property-metadata.model.ts | 2 +- src/global/global-registry.ts | 4 +- src/http-client/http-client-response.model.ts | 17 +- src/logging/transport/log-to-db.function.ts | 2 +- .../transport/logger-transport.model.ts | 4 +- src/metrics/metrics-service.interface.ts | 2 +- .../models/thread-job-data.model.ts | 2 +- .../multithreading-service.interface.ts | 4 +- src/open-api/open-api.service.ts | 110 ++++++++- .../peppol-conformance.service.test.ts | 12 +- .../x-rechnung-conformance.service.test.ts | 12 +- .../services/invoice-number.service.test.ts | 12 +- .../services/invoice-pdf.service.test.ts | 12 +- .../validate-entities-registered.function.ts | 4 +- .../services/websocket-service.interface.ts | 2 +- typedoc.json | 4 +- 60 files changed, 1440 insertions(+), 337 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/assets-explorer.png create mode 100644 docs/assets.md rename docs/{authentication-and-authorization.md => auth.md} (94%) create mode 100644 docs/backup.md create mode 100644 docs/cron.md create mode 100644 docs/data-source.md create mode 100644 docs/di.md create mode 100644 docs/email.md create mode 100644 docs/http-client.md create mode 100644 docs/logging.md create mode 100644 docs/metrics.md create mode 100644 docs/plugin.md create mode 100644 docs/websocket.md create mode 100644 src/data-source/data-sources/data-source.interface.ts create mode 100644 src/data-source/data-sources/index.ts rename src/data-source/{base-data-source.model.ts => data-sources/postgres-data-source.model.ts} (76%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..579485d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to Zibri +Thank you for considering to contribute to this project! + +It is open for discussion to change anything regarding contributing, linting, workflow etc. at any point in time. + +All development is done using github. + +# Table of Contents +- [Contributing to Zibri](#contributing-to-zibri) +- [Table of Contents](#table-of-contents) +- [Create an issue](#create-an-issue) + - [Special Notes for bugs](#special-notes-for-bugs) +- [License](#license) + +# Create an issue +If you want to ask a question, need a new feature, found gaps in the documentation, found a bug, found code that can be refactored etc. you first have to start with creating an Issue. + +Please check if there already is an issue for your problem. + +Right now there are no specific guidelines for Issues, other than that their name and description should include enough details so that everyone knows what the issue is about. + +## Special Notes for bugs +Great Bug Reports tend to have: + +- A quick summary +- Steps to reproduce (be specific and try to give sample code if possible) +- What you expected would happen +- What actually happens +- Additional info (eg. why you think this might be happening, or workarounds you tried that did/did not work) + +# License +By contributing to this project, you agree that your contributions will be licensed under its MIT License. \ No newline at end of file diff --git a/README.md b/README.md index d0a613c..784a9e4 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,14 @@ Zibri aims to be an ***actual*** batteries included framework. Meaning if you need something like an email service it's already there. And, in contrast to most other node backend frameworks:
-Such a service is not just the most basic implementation, but one with built in templating, queuing support for thousands of mails, the option to save mails in the database, priority handling etc. +Such a service is not just the most basic implementation, but one with built in templating, queuing support for thousands of mails, the option to save mails in a data source, priority handling etc. Due to it's dependency injection system you can always swap things out if they are not required or you need even more configuration options. But there will most likely always be a reasonable default you can rely on. ### 🧩 Features - default jwt auth strategy with advanced features like automatic reuse detection - builtin ORM with transaction and migration support -- controller based route definition +- controller based route definition, including websocket controllers - OmitClass, PickClass, PartialClass, IntersectionClass helpers to extend from and easily build new entity or even controller classes - automatic open api generation - body parsers for json and form-data @@ -37,7 +37,8 @@ Due to it's dependency injection system you can always swap things out if they a - email service out of the box, with templating, mail queue, persistence and priority handling - multithreading service with builtin support for worker files or function - change sets and soft delete functionality -- +- backup architecture builtin +- a http client for speaking to external APIs that actually validates responses ## ✨ Ease of use Zibri aims to be as easy to use as possible, with reasonable defaults and by the use of decorators and dependency injection. @@ -55,17 +56,19 @@ With Zibri you can rely on a strong foundation of battle tested libraries that h - nodemailer for sending emails - handlebars for templating - busboy for file uploads (the foundation of multer) +- socket.io for websockets +- node-cron for cron jobs ### 🪖 Making "shooting your foot" as hard as possible -All the public facing classes, interfaces and functions are as strictly typed as possible. Zibri even includes a custom parser for handlebar files, that automtically infer the type of the data that is needed to render the template. +All the public facing classes, interfaces and functions are as strictly typed as possible. Zibri even includes a custom parser for handlebar files, that automtically infers the type of the data that is needed to render the template. -In addition to that, you will get warnings whenever there is something off, like an endpoint that is marked to skip auth, although that is not required. +In addition to that, you will get warnings whenever there is something off. Like an endpoint that is marked to skip auth, although there isn't anything auth related to skip for it. Fixing these warnings will keep your code base cleaner and less prone to errors in the future. ### 🛟 Making "shooting your foot" the most pleasant experience Zibri gives you useful errors, stack traces and debug logs. -It includes source maps and handles even complex cases like giving you the specifics on why a dependency injection failed. +It includes source maps and handles even complex cases like giving you the specifics on *why* a dependency injection failed. In a lot of cases it also directly gives you a possible fix. diff --git a/docs/assets-explorer.png b/docs/assets-explorer.png new file mode 100644 index 0000000000000000000000000000000000000000..b1bc26ee4405fddd0abf6d9f54c6b35eca832d45 GIT binary patch literal 41852 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[] = [ + BackupEntity, + BackupResourceEntity + //... + ]; +} +``` + +Notice that we don't need to implement `BackupResourceInterface` here, as `PostgresDataSource` already does that for us. What we did need to do was add two new entities and the root credentials. But you will get speaking error messages telling you exactly what is missing. + +The `FsBackupTransport` simply writes the backup data for this resource to the local file system, so it should only be used for illustration/testing purposes. + +Now we can use the backup service to create a new backup: + +```ts +import { BackupServiceInterface, inject, ZIBRI_DI_TOKENS } from 'zibri'; +// ... +const backupService: BackupServiceInterface = inject(ZIBRI_DI_TOKENS.BACKUP_SERVICE); +await backupService.createBackup(); +// ... +``` + +Which you can later restore using: + +```ts +import { BackupServiceInterface, inject, ZIBRI_DI_TOKENS, repositoryTokenFor, BackupEntity } from 'zibri'; +// ... +const backupRepository = inject(repositoryTokenFor(BackupEntity)); +const backup: BackupEntity = await backupRepository.findById('42', { relations: ['resources'] }); +await backupService.restore(backup); +// ... +``` \ No newline at end of file diff --git a/docs/creating-endpoints.md b/docs/creating-endpoints.md index 121c85d..fdac950 100644 --- a/docs/creating-endpoints.md +++ b/docs/creating-endpoints.md @@ -5,6 +5,7 @@ Zibri uses controller classes for registering endpoints. You can define any class to be a controller by using the `@Controller` decorator: ```ts +// src/controllers/user.controller.ts import { Controller } from 'zibri'; @Controller('/users') @@ -18,7 +19,7 @@ The `/users` provided to the decorator is the base route of the controller. Mean You then need to register the controller in the application: ```ts -// index.ts +// src/index.ts // ... const app: ZibriApplication = new ZibriApplication({ @@ -36,6 +37,7 @@ You then need to register the controller in the application: To actual define your first endpoint, you need to create a method and decorate it with the respective decorators. The example below shows a simple endpoint for getting some arbitrary users: ```ts +// src/controllers/user.controller.ts import { Controller, Get, Property } from 'zibri'; class User { @@ -62,7 +64,7 @@ export class UserController { By default, a value you return in your method is sent as json to the client. If you want to return a different content type you can take a look at the [changing the return type section](#changing-the-return-type) -You can try the example above and navigate to your applications open api explorer (`explorer` by default). +You can try the example above and navigate to your applications open api explorer (`http://localhost:3000/explorer` by default). You should now see a new route get `/users` with the correctly defined return type. @@ -70,7 +72,7 @@ You should now see a new route get `/users` with the correctly defined return ty You can use inheritance for controllers using generics and even with the provided helper types like "OmitType", "PickType" etc. Zibri additionally provides a helper type for defining base crud endpoints that you can also extend from: ```ts -// example +// src/controllers/test-crud.controller.ts import { CombinedType, Controller, CrudController, OmitType, PickType } from 'zibri'; import { Test, TestCreateDTO, TestUpdateDTO } from '../models'; @@ -88,7 +90,7 @@ The above will expose the endpoints - `GET /tests-crud` (coming from CrudController, which returns a PaginatedResponse of Test) - `GET /tests-crud/:id` (coming from CrudController, which returns a single Test) - `PATCH /tests-crud/:id` (coming from CrudController, which updates a single Test with TestUpdateDTO) -- `GET /tests-crud` (coming from MetricsController, which in this example returns a html dashboard) +- `GET /metrics` (coming from MetricsController, which in this example returns a html dashboard) ## Accessing request data @@ -97,6 +99,7 @@ The above will expose the endpoints To access the request body, you need to use the `@Body()` decorator with the class of the request body. This also automatically handles validation for you: ```ts +// src/controllers/user.controller.ts import { Body, Controller, Post, OmitType } from 'zibri'; class User { @@ -110,7 +113,7 @@ class User { email!: string; } -class UserCreateDto extends OmitType(User, ['id']) {} +class UserCreateDTO extends OmitType(User, ['id']) {} @Controller('/users') export class UserController { @@ -129,7 +132,7 @@ export class UserController { For the special case of uploading files we have a [separate section](#handling-file-uploads). -You can try the example above and navigate to your applications open api explorer (`explorer` by default). +You can try the example above and navigate to your applications open api explorer (`http://localhost:3000/explorer` by default). You should now see a new route post `/users` with the correctly defined request body and return type. @@ -138,6 +141,7 @@ You should now see a new route post `/users` with the correctly defined request To access path, query and header params you can use the respective decorators on your route: ```ts +// src/controllers/newsletter.controller.ts import { Controller, Post, Param } from 'zibri'; @Controller('/newsletters') @@ -160,7 +164,7 @@ export class NewsletterController { These decorators also handle validation for you. -You can try the example above and navigate to your applications open api explorer (`explorer` by default). +You can try the example above and navigate to your applications open api explorer (`http://localhost:3000/explorer` by default). You should now see a new route post `/newsletters/:id/signup` with the correctly defined parameters. @@ -169,6 +173,7 @@ You should now see a new route post `/newsletters/:id/signup` with the correctly Zibri provides an easy way to inject the currently logged in user by leveraging its auth framework. We won't go into the details of how that works here, but you can inject a user as follows: ```ts +// src/controllers/user.controller.ts import { Auth, Controller, CurrentUser, Get } from 'zibri'; @Controller('/users') @@ -189,6 +194,7 @@ export class UserController { In the example above it's required to be logged in to access the route. You can also omit the `@Auth` decorator and pass `false` to the `@CurrentUser` decorator. This makes the user parameter optional: ```ts +// src/controllers/article.controller.ts import { Controller, CurrentUser, Get } from 'zibri'; @Controller('/articles') @@ -210,7 +216,7 @@ export class ArticleController { } ``` -You can try the example above and navigate to your applications open api explorer (`explorer` by default). +You can try the example above and navigate to your applications open api explorer (`http://localhost:3000/explorer` by default). You should now see a new route get `/articles` which can return different results based on whether you are logged in or not. @@ -218,6 +224,7 @@ You should now see a new route get `/articles` which can return different result Zibri supports the uploading of one or multiple files together with some optional request data by using form-data: ```ts +// src/controllers/file.controller.ts import { Body, Controller, File, FormData, MimeType, Post, Property } from 'zibri'; class FileUploadDTO { @@ -229,7 +236,7 @@ class FileUploadDTO { } @Controller('/files') -export class FilesController { +export class FileController { @Post() async upload( @Body(FileUploadDTO, { type: MimeType.FORM_DATA }) @@ -240,6 +247,8 @@ export class FilesController { } ``` +> Notice that `FormData` is being imported from Zibri. + ## Securing endpoints To secure and protect your endpoints Zibri provides the `@Auth` namespace which has a lot of differnt decorators that handle most use cases. These decorators can be used either on the whole controller, securing every endpoint on it or on a single endpoint, depending on your needs. @@ -249,6 +258,7 @@ Every decorator also comes with a `skip` property, so you can easily secure a co Checks if there is a logged in requesting user. ```ts +// src/controllers/auth.controller.ts import { Auth, Controller, Get } from 'zibri'; @Auth.isLoggedIn() @@ -273,6 +283,7 @@ export class AuthController { Checks if there is no requesting user. ```ts +// src/controllers/auth.controller.ts import { Auth, Controller, Post } from 'zibri'; @Controller('/auth') @@ -290,6 +301,7 @@ export class AuthController { Determines if the currently logged in user has a certain role: ```ts +// src/controllers/auth.controller.ts import { Auth, Controller, Get } from 'zibri'; @Auth.isLoggedIn() @@ -313,10 +325,11 @@ Probably the most complex auth decorator of Zibri is `@Auth.belongsTo`. It automatically checks if the ressource being requested belongs to the currently logged in user. ```ts +// src/controllers/auth.controller.ts import { Auth, Controller, Get, Property } from 'zibri'; // !IMPORTANT: unless you are using a custom auth strategy this needs to be an entity -// that is registered in a database. More on that can be found on the page "handling-databases". +// that is registered in a data soource. More on that can be found on the page "data-sources". class PersonalDocument { @Property.string({ primary: true }) id!: string; @@ -345,6 +358,7 @@ export class AuthController { For defining responses, Zibri offers the `@Response` namespace which has a lot of decorators you can use on your endpoints: ```ts +// src/controllers/user.controller.ts import { Controller, Get, HttpStatus, PaginationResult, Response } from 'zibri'; @Controller('/users') @@ -372,6 +386,7 @@ By default, Zibri sends back the returned value from your endpoints as json. In To return files instead of json, Zibri offers the `FileResponse` class: ```ts +// src/controllers/file.controller.ts import { Controller, FileResponse, Get, Response } from 'zibri'; @Controller('/files') @@ -389,6 +404,7 @@ export class FileController { To return html instead of json, Zibri offers the `HtmlResponse` class: ```ts +// src/controllers/html.controller.ts import { Controller, HtmlResponse, Get, Response } from 'zibri'; @Controller('/html') diff --git a/docs/cron.md b/docs/cron.md new file mode 100644 index 0000000..678683c --- /dev/null +++ b/docs/cron.md @@ -0,0 +1,66 @@ +# Cron +Cron jobs in Zibri use the node-cron package and its cron syntax under the hood. + +## Defining a cron job +To define a job it needs to extend the `CronJob` class. Below is a simple example that just logs to the console each second: + +```ts +// src/cron/status.cron-job.ts +import { CronJob, inject, Injectable, LoggerInterface, ZIBRI_DI_TOKENS, InitialCronConfig } from 'zibri'; + +@Injectable() +export class StatusCronJob extends CronJob { + readonly initialConfig: InitialCronConfig = { + name: 'Status', + cron: '* * * * * *', + active: false + }; + + async onTick(): Promise { + await inject(ZIBRI_DI_TOKENS.LOGGER).info(`is running ${this.name}`); + } +} +``` + +## Registering the cron job +For Zibri to be actual able to pickup the cron job start it automatically you need to provide its class at the `index.ts`: + +```ts +// src/index.ts +import { StatusCronJob } from './cron'; +// ... + +const app: ZibriApplication = new ZibriApplication({ + name: 'Api', + baseUrl: 'http://localhost:3000', + plugins: [ + // ... + ], + controllers: [ + // ... + ], + websocketControllers: [ + // ... + ], + dataSources: [ + // ... + ], + cronJobs: [StatusCronJob], + version, + providers +}); +// ... +``` + +## Behaviour + +The job is first initialized with the given initial config. +Which in this case means that +- its name is 'Status' +- it *would* run every second +- but is not active yet. + +At that first startup, the cron job is saved as a cron job entity in a data source. +After that you can programmatically change its configuration, like setting active to true or changing the cron schedule. + +While this is pretty useful, it might not always be what you want. A downside of this is for example that changing the initial config after the cron job has already been saved in the data source does not have any effect. If you want the hardcoded configuration inside the cron job to be the single source of truth you can set the `syncToDataSource` flag to false in the initial config. \ No newline at end of file diff --git a/docs/data-source.md b/docs/data-source.md new file mode 100644 index 0000000..2bb389c --- /dev/null +++ b/docs/data-source.md @@ -0,0 +1,212 @@ +# Data Sources +Data sources in Zibri are used to connect eg. to databases. + +They provide a lot of functionality out of the box, including: +- defining entity classes that are mapped to the data source +- repositories used to access the items of an entity in the data source +- migrations +- transactions +- using multiple data sources simultaneously + +## Defining a data source +A data source needs too implement DataSourceInterface. Zibri also provides more specific, predefined classes, like the PostgresDataSource that you can extend from instead. + +The data source then needs to be decorated with `@DataSource`. + +The example below illustrates how a data source might be setup: + +```ts +// src/data-sources/db/db.data-source.ts +import { PostgresDataSource, PostgresOptions, BaseEntity, DataSource, Newable } from 'zibri'; + +import { Test } from '../../models'; + +@DataSource() +export class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'password', + database: 'db', + synchronize: true + }; + + entities: Newable[] = [Test]; +} +``` + +## Defining entities + +As you can see, the configuration of a data source is pretty straightforward. We also added our first entity to the data source, `Test`: + +```ts +// src/models/test.model.ts +import { BaseEntity, Entity, Property } from 'zibri'; + +@Entity() +export class Test extends BaseEntity { + @Property.string() + value!: string; +} +``` + +An entity to be included in a data source needs to extend `BaseEntity`, which defines an id, createdAt and updatedAt properties. + +In order for the data source to map the properties, you need to decorate them with the `@Property` decorator. This is also used for validation. + +## Accessing the data source +To access data from the data source, you use repositories for specific entities. They can simply be injected without needing you to define them: + +```ts +// src/services/test.service.ts +import { InjectRepository, Repository } from 'zibri'; + +import { Test } from '../models'; + +export class TestService { + constructor( + @InjectRepository(Test) // The last 2 generics are not required + private readonly testRepository: Repository, + ) {} +} +``` +A repository always exposes the methods: +- findAll +- findAllPaginated +- findOne +- findById +- createAll +- create +- updateAll +- updateById +- deleteAll +- deleteById + +The full, detailed definition can be found under the [repository typedoc](./generated/classes/data-source_repository.Repository.html). + +### Filtering data +In most cases you probably don't want to get all entities from a data source, but a filtered selection. For that all retrieving methods have an optional where filter property: + +```ts +// returns all test entities where value is exactly '42' +await this.testRepository.findAll({ where: { value: '42' } }); +// returns all test entities where value ends with '42' +await this.testRepository.findAll({ where: { value: { iLike: '%42' } } }); +// returns all test entities where value ends with '42' AND is not '42' +await this.testRepository.findAll({ where: { value: { iLike: '%42', not: '42' } } }); +// returns all test entities where value either: +// - ends with '42' AND is not '42' +// - OR is '43' +await this.testRepository.findAll({ where: { value: [{ iLike: '%42', not: '42' }, '43'] } }); +``` + +### Using transactions +A transaction can be started from a data source: + +```ts +// src/services/test.service.ts +import { InjectRepository, Repository } from 'zibri'; + +import { Test } from '../models'; + +export class TestService { + constructor( + @InjectRepository(Test) + private readonly testRepository: Repository, + private readonly dataSource: DbDataSource + ) {} + + async doSomething(): Promise { + const transaction: Transaction = await this.dataSource.startTransaction(); + try { + const test1: Test = await this.testRepository.create({ value: '42' }, { transaction }); + const test2: Test = await this.testRepository.create({ value: '43' }, { transaction }); + await transaction.commit(); + } + catch (error) { + await transaction.rollback(); + throw error; + } + } +} +``` + +## Handling migrations +Migrations need to be provided on the data source: + +```ts +// src/data-sources/db/db.data-source.ts +import { PostgresDataSource, PostgresOptions, BaseEntity, DataSource, Newable } from 'zibri'; + +import { Test } from '../../models'; +import { RenameValuePropertyMigration } from './migrations'; + +@DataSource() +export class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'password', + database: 'db', + synchronize: true + }; + + entities: Newable[] = [Test]; + migrations: Newable[] = [RenameValuePropertyMigration]; +} +``` + +Let's take a look at what the `RenameValuePropertyMigration` does. + +For this we assume that the entity `Test` had a property before that was called `oldValue`, which has been renamend to the current property `value`. The migration handles that rename: + +```ts +// src/data-sources/db/migrations/rename-value-property.migration.ts +import { Injectable, Migration, Transaction, Version } from 'zibri'; + +import { Test } from '../../../models'; +import { DbDataSource } from '../db.data-source.ts'; + +@Injectable() +class RenameValuePropertyMigration extends Migration { + version: Version = '0.0.1'; + + constructor() { + super(DbDataSource); + } + + override async up(transaction: Transaction): Promise { + await this.dataSource.changePropertyOfEntity(Test, 'oldValue', { type: 'string', name: 'value' }, transaction); + } + + // eslint-disable-next-line typescript/require-await + override async down(): Promise { + throw new Error('Not implemented yet.'); + } +} +``` + +### Versioning +As you can see, each migration needs to have a version for which it should run. That version is compared against the global one provided to zibri, which is the package.json version by default. + +## ChangeSetRepository +The `ChangeSetRepository` is an extended version of a Repository, that automatically tracks who changed what & when. +It is automatically chosen with no further configuration on your end when the entity provided to `@InjectRepository` has a property called changeSets which is an array. + +If you want to remove a property from tracking, you can set the `excludeFromChangeSets` flag: + +```ts +// src/models/test.model.ts +import { BaseEntity, Entity, Property } from 'zibri'; + +@Entity() +export class Test extends BaseEntity { + @Property.string({ excludeFromChangeSets: true }) + value!: string; +} +``` + +## SoftDeleteRepository +The `SoftDeleteRepository` is an extended version of a `ChangeSetRepository`, which adds soft delete functionality. \ No newline at end of file diff --git a/docs/di.md b/docs/di.md new file mode 100644 index 0000000..ef7dfa1 --- /dev/null +++ b/docs/di.md @@ -0,0 +1,107 @@ +# Dependency Injection +The Dependency Injection system is one of the most fundamental building blocks of Zibri. + +## Adding new injectables +To make something injectable there are 2 ways: +1. decorate a class with `@Injectable()` +2. add the value to your providers array + +In most cases, you will probably just have to decorate a class: + +### With @Injectable +```ts +// src/services/test.service.ts +import { Injectable } from 'zibri'; + +@Injectable() +export class TestService { + // ... +} +``` + +Now you can easily inject the TestService either by adding it to a constructor or by using the `inject` helper: + +```ts +// src/controllers/test.controller.ts +import { Controller, inject } from 'zibri'; + +import { TestService } from '../services'; + +@Controller('/tests') +export class TestController { + constructor(private readonly testService: TestService) { + const alternative: TestService = inject(TestService); + } + // ... +} +``` + +### With the providers array +Alternatively, you can also add them to the providers array: + +```ts +// src/providers.ts +import { DiProvider, ZIBRI_DI_TOKENS } from 'zibri'; + +export const providers: DiProvider[] = [ + // ... + { + token: 'some-token', + useFactory: () => '42' + } + // ... +] +``` + +And then inject them the same way before, with the constructor approach needing an additional decorator: + +```ts +// src/controllers/test.controller.ts +import { Controller, inject } from 'zibri'; + +@Controller('/tests') +export class TestController { + constructor( + @Inject('some-token') + private readonly value: string + ) { + const alternative: string = inject('some-token'); + } + // ... +} +``` + +## Overriding existing injectables +Bascially every service/functionality of the framework is registered for dependency injection. That makes it really easy for you to replace something with your own implementation if you need more functionality than the builtin solutions. + +Let's say that you want for example to replace the default error handler with the following one: + +```ts +// src/my-error-handler.ts +import { NextFunction } from 'express'; +import { GlobalErrorHandler, HttpRequest, HttpResponse } from 'zibri'; + +export const myErrorHandler: GlobalErrorHandler = async (error: unknown, req: HttpRequest, res: HttpResponse, next: NextFunction) => { + // ...your custom logic +} +``` + +For that you can simply add it to your providers array: + +```ts +// src/providers.ts +import { DiProvider, ZIBRI_DI_TOKENS } from 'zibri'; + +import { myErrorHandler } from './my-error-handler.ts'; + +export const providers: DiProvider[] = [ + // ... + { + token: ZIBRI_DI_TOKENS.GLOBAL_ERROR_HANDLER, + useFactory: () => myErrorHandler + } + // ... +] +``` + +That's it! From now on, your custom error handler will be used instead of the default one provided by Zibri. \ No newline at end of file diff --git a/docs/email.md b/docs/email.md new file mode 100644 index 0000000..38284c9 --- /dev/null +++ b/docs/email.md @@ -0,0 +1,66 @@ +# Handling emails +Zibri provides an out of the box email service with with templating, mail queue, persistence and priority handling. + +In order for you to use it, you first have to provide your email server configuration to the providers array: + +```ts +// src/providers.ts +import { ZIBRI_DI_TOKENS, EmailConfigInput } from 'zibri'; + +// ... +{ + token: ZIBRI_DI_TOKENS.EMAIL_CONFIG, + useFactory: (): EmailConfigInput => { + return { + defaultSender: 'Test Test', + host: 'mailserver-host', + port: 587, + auth: { + user: 'mail-user', + pass: 'mail-password' + } + }; + } +} +// ... +``` + +Then you can use the email service: + +```ts +import { EmailServiceInterface, ZIBRI_DI_TOKENS, inject, EmailPriority, renderEmailTemplate } from 'zibri'; +import renderBaseEmail from '../templates/emails/base-email.hbs'; +import renderEmailContent from '../templates/emails/my-email.hbs'; + +// ... + +const emailService: EmailServiceInterface = inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE); +const content: string = renderEmailContent({ + some: 'data' +}); +await emailService.queue({ + recipients: ['test@test.com'], + subject: 'test', + priority: EmailPriority.HIGH, // defaults to NORMAL + html: renderBaseEmail({ + content, + base: { + title: 'Test Email', + baseUrl: 'http://localhost:3000' + } + }); +}); +``` + +## About rendering templates +You might be wondering how we can import a ts function from a file ending with `.hbs`. + +That's due to our handlebars compiler that automatically creates ts definitions based on your templates. It is actually smart enough to detect any variables that you use in your templates, as well as infer their type. While being extremly helpful, this type safety comes with the downside that any variables you use are restricted in their typing for the compiler to infer their type. They need to be either: +- string +- string[] +- an object with its values being either string, string[] or another object + +These are also hot reloaded when you change your templates and eg. introduce or remove a new variable. + +> Caveat:
+> If you don't run `npm run start` then the compiler won't be able to generate the .ts files. So if you have any problems with eg. a property of your template not being recognized, check that first. \ No newline at end of file diff --git a/docs/http-client.md b/docs/http-client.md new file mode 100644 index 0000000..d2d9adf --- /dev/null +++ b/docs/http-client.md @@ -0,0 +1,135 @@ +# Http Client +When building an API, you most likely will need to integrate some third party API. For that, Zibri provides a http client. In contrast to other packages in this space, response types are not just assumed but actually validated using the same procedure that is also used for incoming http requests. See the [creating endpoints guide](./creating-endpoints.md#accessing-request-data) for more details. + +## Making a request +```ts +import { HttpClientInterface, inject, ZIBRI_DI_TOKENS } from 'zibri'; + +// ... +const http: HttpClientInterface = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); +// undefined is assumed for the response body because we did not define anything +const response: HttpClientResponse = await http.get( + 'https://some-api.com/tests', + { + + } +); +// ... +``` + +### Setting request body, query and header params +To set the request body, you need to provide it as the second argument to a method that supports it (`get` has no request body for example). If you have an endpoint that uses eg. `post` but does not expect a request body then you need to pass undefined instead. + +To set header or query params on the request, you can use the respective option: + +```ts +import { HttpClientInterface, inject, ZIBRI_DI_TOKENS, HttpClientResponse } from 'zibri'; + +// ... +const http: HttpClientInterface = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); +const response: HttpClientResponse = await http.post( + 'https://some-api.com/some-endpoint-with-query-and-header-params', + { + some: 'data' + }, + { + headers: { apiKey: '42' }, + query: { startYear: 2025 } + } +); +// ... +``` + +### Setting retries and timeout +By default, the http client: +- waits for 5 seconds before throwing a timeout error +- tries the request exactly once + +You can override this by specifying the respective option: + +```ts +import { HttpClientInterface, inject, ZIBRI_DI_TOKENS, HttpClientResponse } from 'zibri'; + +// ... +const http: HttpClientInterface = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); +const response: HttpClientResponse = await http.get( + 'https://some-api.com/some-endpoint', + { + retries: 3, + timeoutMs: 10000 + } +); +// ... +``` + +## Defining response data +Response data only consists of the body and possible headers that have been sent. + +### body +The body works by providing a class that has properties with the `@Property` decorator, same as an incoming body. In the following, we validate that the response body has the properties name and value, which are both strings. If that is not the case, a validation error will be thrown. + +```ts +import { HttpClientInterface, inject, ZIBRI_DI_TOKENS, HttpClientResponse } from 'zibri'; + +class Item { + @Property.string() + name!: string; + + @Property.number() + value!: number; +} + +// ... +const http: HttpClientInterface = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); +const response: HttpClientResponse = await http.get( + 'https://some-api.com/item', + { responseBody: Item } +); +// ... +``` + +If you want something other than the default json then you can instead define an object with the expected mime type of the response body: + +```ts +import { HttpClientInterface, inject, ZIBRI_DI_TOKENS, HttpClientResponse, MimeType, FormData } from 'zibri'; + +// ... +class FormDataItem { + @Property.file({ allowedMimeTypes: [MimeType.JSON] }) + file!: File; + + @Property.array({ items: { type: 'file' }, totalMaxSize: '5mb' }) + files!: File[]; +} + +const http: HttpClientInterface = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); +const response: HttpClientResponse> = await http.get( + 'https://some-api.com/form-data', + { + responseBody: { + modelClass: FormDataItem, + type: MimeType.FORM_DATA + } + } +); +// ... +``` + +### headers +Response headers can be defined by setting the respective option. In the following case, we validate that the response has a content length header and that it is of type number. If it's not, a validation error will be thrown. + +```ts +import { HttpClientInterface, inject, ZIBRI_DI_TOKENS, HttpClientResponse, KnownHeader } from 'zibri'; + +// ... +const http: HttpClientInterface = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); +const response: HttpClientResponse = await http.get( + 'https://some-api.com/item', + { + responseHeaders: { + [KnownHeader.CONTENT_LENGTH]: { type: 'number' } + } + } +); +// ... +``` \ No newline at end of file diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..9caff45 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,54 @@ +# Logging +The logging system in Zibri consists of: +- the logger itself +- one or multiple logger transports + +A logger transport defines WHERE a logged message goes. +Zibri provides some by default, but you can of course create your own transports: +- console logger transport (prints the log to the console) +- db logger transport (saves a log entry in a data source) +- email logger transport (sends an email with the log to a specified email) + +## Using the logger +You can inject the logger by its token: + +```ts +// src/controllers/test.controller.ts +import { Controller, inject, Inject, LoggerInterface, ZIBRI_DI_TOKENS } from 'zibri'; + +@Controller('/tests') +export class TestController { + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface + ) { + const alternative: LoggerInterface = inject(ZIBRI_DI_TOKENS.LOGGER); + } + + async logStuff(): Promise { + await this.logger.debug('debug message'); + await this.logger.info('info message'); + await this.logger.warn('warn message'); + await this.logger.error('error message'); + await this.logger.critical('critical message'); + } + // ... +} +``` + +## Configuring Logger Transports +You can configure the transports that should be used by adding them to your providers array: + +```ts +// src/providers.ts +import { ZIBRI_DI_TOKENS, LoggerTransport, LogLevel } from 'zibri'; + +[ZIBRI_DI_TOKENS.LOGGER_TRANSPORTS]: { + useFactory: () => [ + LoggerTransport.console(LogLevel.INFO), + LoggerTransport.db(LogLevel.INFO) + ] +}, +``` + +As you can see we provide a log level for each transport here. This is especially useful if you want to use something like the email transport, which should probably only activate for critical errors. \ No newline at end of file diff --git a/docs/metrics.md b/docs/metrics.md new file mode 100644 index 0000000..ea2d81f --- /dev/null +++ b/docs/metrics.md @@ -0,0 +1,57 @@ +# Metrics +Zibri contains a pretty basic metrics service. It collects up to 60 snapshots. If you need more than that you should use an external service, like prometheus. + +## Collecting data +For collecting data the service provides a collect method: + +```ts +import { MetricsServiceInterface, inject, ZIBRI_DI_TOKENS } from 'zibri'; +// ... +const metricsService: MetricsServiceInterface = inject(ZIBRI_DI_TOKENS.METRICS_SERVICE); +await metricsService.collect(); +// ... +``` + +You will however most likely use the provided `ScrapeMetricsCronJob` which collects a snapshot every 5 seconds. +See [registering cron jobs](./cron.md#registering-the-cron-job). + +## Exposing a basic dashboard +By default, your project contains a `metrics.hbs` file that is used to display the data of the metrics service. + +You can simply create a new controller and expose it there: + +```ts +// src/controllers/metrics.controller.ts +import { Controller, Inject, ZIBRI_DI_TOKENS, Metric, Get, Response, HtmlResponse, MetricsSnapshot, GlobalRegistry, MetricsServiceInterface } from 'zibri'; + +import renderBasePageTemplate from '../templates/pages/base-page.hbs'; +import renderMetricsTemplate from '../templates/pages/metrics.hbs'; + +@Controller('/metrics') +export class MetricsController { + constructor( + @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) + private readonly metricsService: MetricsServiceInterface + ) {} + + // optional, to be used with an external tool like prometheus + @Get() + @Response.array(Metric) + get(): MetricsSnapshot[] { + return this.metricsService.getMetricSnapshots(); + } + + @Response.html() + @Get('/dashboard') + dashboard(): HtmlResponse { + const content: string = renderMetricsTemplate({ + name: GlobalRegistry.getAppData('name') ?? '-', + version: GlobalRegistry.getAppData('version') ?? '-' + }); + const html: string = renderBasePageTemplate({ base: { title: 'Metrics Dashboard' }, content }); + return HtmlResponse.fromString(html); + } +} +``` + +Now you can navigate to `http://localhost:3000/metrics/dashboard` and see it in action. \ No newline at end of file diff --git a/docs/multithreading.md b/docs/multithreading.md index 232e02d..e35f918 100644 --- a/docs/multithreading.md +++ b/docs/multithreading.md @@ -5,7 +5,7 @@ Zibri aims to take care of most of your multi threading concerns, including: - support for typescript out of the box - a way to run worker files, being really close to the original implementation - a simple way to run a function in a separate thread -- storing data about your thread jobs like status, error etc. inside the database +- storing data about your thread jobs like status, error etc. inside a data source - utility functions to easily update the progress, status, error or result of the job - configurable timeouts for jobs and self healing capabilities of the worker pool @@ -81,14 +81,15 @@ This returns the result of the function call or rejects with an error. > **Restrictions** > - It is expected that only known and trusted functions are passed to this method, as `eval` is used under the hood > - Imports won't be resolved when the code is executed on the thread, which means that your function should only use things that are globally available (eg. console.log) or passed via the second argument -> - The run will not be stored inside a database, and the utility functions like `reportProgress` will not work +> - The run will not be stored inside a data source, and the utility functions like `reportProgress` will not work By default this is also run with priority. This is because the execution time will probably be not that long. (Because you can await the result.)
You can however also add a fourth parameter to define whether or not it should run with priority. ```ts -import { Inject, ZIBRI_DI_TOKENS } from 'zibri'; +// src/services/fibonacci.service.ts +import { Inject, ZIBRI_DI_TOKENS, MultithreadingService } from 'zibri'; function fibonacci(n: number): number { if (n <= 1) { @@ -98,13 +99,13 @@ function fibonacci(n: number): number { } //... -export class MyClass { +export class FibonacciService { constructor( @Inject(ZIBRI_DI_TOKENS.MULTITHREADING_SERVICE) private readonly multithreadingService: MultithreadingService ) {} - runFibonacci(): number { + async runFibonacci(): Promise { const res: number = await this.multithreadingService.run(fibonacci, 20); return res; } diff --git a/docs/plugin.md b/docs/plugin.md new file mode 100644 index 0000000..f87f5c1 --- /dev/null +++ b/docs/plugin.md @@ -0,0 +1 @@ +# TODO \ No newline at end of file diff --git a/docs/websocket.md b/docs/websocket.md new file mode 100644 index 0000000..ed223ea --- /dev/null +++ b/docs/websocket.md @@ -0,0 +1,92 @@ +# Handling websocket connections +The way Zibri handles websocket connections is pretty similar to http. It also supports recovering and replaying a prior connection. + +## Establishing a connection +By default, the websocket service accepts all connection attempts. You can override this behaviour by providing the following in your providers array: + +```ts +// ... +[ZIBRI_DI_TOKENS.WEBSOCKET_OPTIONS]: { + useFactory: () => ({ + timeoutInMs: Ms.SECOND * 5, + isAllowedToConnect: () => false + }) +} +// ... +``` + +## Receiving messages +For handling incomig websocket messages, you need to create a websocket controller: + +```ts +// src/controllers/test.websocket-controller.ts +import { WebsocketController, WebsocketBody, WebsocketRoute, Property, BaseWebsocketConnection, CurrentWebsocketConnection } from 'zibri'; + +class Test { + @Property.string() + value!: string; +} + +@WebsocketController() +export class TestWebsocketController { + + @WebsocketRoute('chat message') + async receiveChatMessage( + @WebsocketBody(Test) + message: Test, + @CurrentWebsocketConnection() + connection: BaseWebsocketConnection + ): Promise { + // ... + } +} +``` + +As you can see it is basically exactly the same as a "normal" controller. With all the parsing/validation etc. builtin. + +## Sending messages +Messages can be send by using the websocket service. It provides methods for: +- sending to a specific connection +- sending to all connections +- sending to a channel + +```ts +import { WebsocketController, WebsocketBody, WebsocketRoute, Property, Inject, ZIBRI_DI_TOKENS, WebsocketService, BaseWebsocketConnection, CurrentWebsocketConnection } from 'zibri'; + +export class CreateWebsocketChatMessageDTO { + @Property.string() + message!: string; +} + +@WebsocketController() +export class TestWebsocketController { + constructor( + @Inject(ZIBRI_DI_TOKENS.WEBSOCKET_SERVICE) + private readonly websocketService: WebsocketService + ) {} + + @WebsocketRoute('chat message') + async receiveChatMessage( + @WebsocketBody(CreateWebsocketChatMessageDTO) + message: CreateWebsocketChatMessageDTO, + @CurrentWebsocketConnection() + connection: BaseWebsocketConnection + ): Promise { + await this.websocketService.sendToAll({ + event: 'chat message', + message: { + ok: true, + data: message, + senderUserId: connection.userId, + senderConnectionId: connection.id + } + }); + } +} +``` + +## Handling channels +Channels are created by using a repository with the `WebsocketChannel` entity. See [accessing a data source](./data-source.md#accessing-the-data-source) for more information. + +A websocket connection can join or leave them by using the respective methods on the websocket service. +This will also be completely restored when connecting again in the future. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c727db5..02f821a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zibri", - "version": "2.0.2", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zibri", - "version": "2.0.2", + "version": "2.1.1", "license": "MIT", "dependencies": { "@fastify/busboy": "^3.2.0", @@ -12873,13 +12873,13 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "peer": true, "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, diff --git a/package.json b/package.json index dcb6ca6..b2f2848 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zibri", - "version": "2.1.0", + "version": "2.1.1", "main": "./dist/index.js", "module": "./dist/index.mjs", "exports": { diff --git a/sandbox/assets/public/open-api/custom.css b/sandbox/assets/public/open-api/custom.css index 16c2ffa..b2960bf 100644 --- a/sandbox/assets/public/open-api/custom.css +++ b/sandbox/assets/public/open-api/custom.css @@ -1,3 +1,40 @@ +#zibri-openapi-logo { + display: flex; + gap: 20px; + justify-content: center; + align-items: center; + position: absolute; + margin-top: -25px; + color: whitesmoke; + transition: 0.3s ease; + font-size: 28px; +} + +#zibri-openapi-logo:hover { + color: #00b4d8; +} + +.zibri-roles-container { + flex: 1; + display: flex; + justify-content: end; +} + +.zibri-role-badge { + background-color: #1a1a26; + border: 1px solid whitesmoke; + padding-left: 8px; + padding-right: 8px; + padding-bottom: 2px; + border-radius: 10px; + font-size: 14px; +} + +.swagger-ui .opblock .opblock-summary .view-line-link { + margin: 0px !important; + width: 30px !important; +} + body, .swagger-ui .dialog-ux .modal-ux { background-color: #2a2a35;; diff --git a/sandbox/src/controllers/metrics.controller.ts b/sandbox/src/controllers/metrics.controller.ts index 368f629..ea7c775 100644 --- a/sandbox/src/controllers/metrics.controller.ts +++ b/sandbox/src/controllers/metrics.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Inject, ZIBRI_DI_TOKENS, Metric, Get, PrometheusMetricsService, Response, HtmlResponse, MetricsSnapshot, GlobalRegistry } from 'zibri'; +import { Controller, Inject, ZIBRI_DI_TOKENS, Metric, Get, Response, HtmlResponse, MetricsSnapshot, GlobalRegistry, MetricsServiceInterface } from 'zibri'; import renderBasePageTemplate from '../templates/pages/base-page.hbs'; import renderMetricsTemplate from '../templates/pages/metrics.hbs'; @@ -7,7 +7,7 @@ import renderMetricsTemplate from '../templates/pages/metrics.hbs'; export class MetricsController { constructor( @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) - private readonly metricsService: PrometheusMetricsService + private readonly metricsService: MetricsServiceInterface ) {} @Get() diff --git a/sandbox/src/controllers/test.controller.ts b/sandbox/src/controllers/test.controller.ts index 341ff89..0576e27 100644 --- a/sandbox/src/controllers/test.controller.ts +++ b/sandbox/src/controllers/test.controller.ts @@ -48,6 +48,7 @@ export class TestController { @Body(Test) data: Test ): Promise { + await this.testRepository.findAll({ where: { value: [{ iLike: '%42', not: '42' }, '43'] } }); return await this.testRepository.updateById(id, data); } diff --git a/sandbox/src/create-default-data.function.ts b/sandbox/src/create-default-data.function.ts index e11bd56..79af4c3 100644 --- a/sandbox/src/create-default-data.function.ts +++ b/sandbox/src/create-default-data.function.ts @@ -1,18 +1,18 @@ -import { BaseDataSource, HashUtilities, inject, JwtCredentials, JwtCredentialsCreateData, Newable, Repository, repositoryTokenFor, Transaction } from 'zibri'; +import { DataSourceInterface, HashUtilities, inject, JwtCredentials, JwtCredentialsCreateData, Newable, Repository, repositoryTokenFor, Transaction } from 'zibri'; import { logger } from '.'; import { Roles, User } from './models'; import { UserRepository } from './repositories'; -export async function createDefaultData(dataSourceClass: Newable): Promise { - const dataSource: BaseDataSource = inject(dataSourceClass); +export async function createDefaultData(dataSourceClass: Newable): Promise { + const dataSource: DataSourceInterface = inject(dataSourceClass); await logger.info('Creates default data if missing'); await createDefaultAdmin(dataSource); await logger.info('Finished creating default data'); } -async function createDefaultAdmin(dataSource: BaseDataSource): Promise { +async function createDefaultAdmin(dataSource: DataSourceInterface): Promise { const userRepository: UserRepository = inject(UserRepository); const credentialsRepository: Repository = inject(repositoryTokenFor(JwtCredentials)); @@ -24,8 +24,11 @@ async function createDefaultAdmin(dataSource: BaseDataSource): Promise { await logger.info(' - default admin'); const transaction: Transaction = await dataSource.startTransaction(); try { - const user: User = await userRepository.create({ email: 'admin@test.com', roles: [Roles.ADMIN] }); - await credentialsRepository.create({ email: user.email, password: await HashUtilities.hash('password'), userId: user.id }); + const user: User = await userRepository.create({ email: 'admin@test.com', roles: [Roles.ADMIN] }, { transaction }); + await credentialsRepository.create( + { email: user.email, password: await HashUtilities.hash('password'), userId: user.id }, + { transaction } + ); await transaction.commit(); } catch (error) { diff --git a/sandbox/src/data-sources/db/db.data-source.ts b/sandbox/src/data-sources/db/db.data-source.ts index 622b28e..7e485b0 100644 --- a/sandbox/src/data-sources/db/db.data-source.ts +++ b/sandbox/src/data-sources/db/db.data-source.ts @@ -1,4 +1,4 @@ -import { BaseDataSource, BaseEntity, DataSource, Newable, DataSourceOptions, MigrationEntity, JwtRefreshToken, JwtCredentials, PasswordResetToken, MailingList, MailingListSubscriber, MailingListSubscriptionConfirmationToken, Log, Change, ChangeSet, Invoice, NumberInvoices, Entity, OmitClass, OtpCredentials, BackupResourceEntity, BackupEntity } from 'zibri'; +import { PostgresDataSource, PostgresOptions, BaseEntity, DataSource, Newable, MigrationEntity, JwtRefreshToken, JwtCredentials, PasswordResetToken, MailingList, MailingListSubscriber, MailingListSubscriptionConfirmationToken, Log, Change, ChangeSet, Invoice, NumberInvoices, Entity, OmitClass, OtpCredentials, BackupResourceEntity, BackupEntity } from 'zibri'; import { Company, Test, User } from '../../models'; @@ -6,12 +6,11 @@ import { Company, Test, User } from '../../models'; class Test2 extends OmitClass(MigrationEntity, ['ranAt']) {} @DataSource() -export class DbDataSource extends BaseDataSource { +export class DbDataSource extends PostgresDataSource { rootPw: string = 'password'; rootUsername: string = 'postgres'; - options: DataSourceOptions = { - type: 'postgres', + options: PostgresOptions = { host: 'localhost', port: 5432, username: 'postgres', diff --git a/sandbox/src/models/test.model.ts b/sandbox/src/models/test.model.ts index 589fef0..813ce7f 100644 --- a/sandbox/src/models/test.model.ts +++ b/sandbox/src/models/test.model.ts @@ -4,9 +4,6 @@ import { BaseEntity, Entity, File, MimeType, OmitClass, PartialClass, Property } export class Test extends BaseEntity { @Property.string({ minLength: 28 }) value!: string; - - // @Property.array({ items: { type: 'file' } }) - // files!: File[]; } export class TestCreateDTO extends OmitClass(Test, ['id']) { diff --git a/src/application-options.model.ts b/src/application-options.model.ts index b3b3a91..f9544a7 100644 --- a/src/application-options.model.ts +++ b/src/application-options.model.ts @@ -1,6 +1,6 @@ import { AuthStrategies, TwoFactorMethods } from './auth'; import { CronJob } from './cron'; -import { BaseDataSource } from './data-source'; +import { DataSourceInterface } from './data-source'; import { DiProvider } from './di'; import { BodyParserInterface } from './parsing'; import { ZibriPlugin } from './plugin'; @@ -36,7 +36,7 @@ export type ZibriApplicationOptions = { /** * The data sources to register in the app. */ - dataSources?: Newable[], + dataSources?: Newable[], /** * The DI providers to register/override. */ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index bf6cdc8..af2fa4a 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -240,12 +240,11 @@ export class AuthService implements AuthServiceInterface { } // require2fa - const user: BaseUser = await this.getCurrentUser(request, this.strategies, true); - if ( - require2faMetadata - && !await this.twoFactorService.has2fa(user, request, require2faMetadata.allowedMethods) - ) { - throw new UnauthorizedError('You need to provide a second factor to access this route.'); + if (require2faMetadata) { + const user: BaseUser = await this.getCurrentUser(request, this.strategies, true); + if (!await this.twoFactorService.has2fa(user, request, require2faMetadata.allowedMethods)) { + throw new UnauthorizedError('You need to provide a second factor to access this route.'); + } } // belongsTo diff --git a/src/auth/decorators/user-repo.decorator.ts b/src/auth/decorators/user-repo.decorator.ts index 1dc678e..0312890 100644 --- a/src/auth/decorators/user-repo.decorator.ts +++ b/src/auth/decorators/user-repo.decorator.ts @@ -9,7 +9,7 @@ import { BaseUser, UserRepositories } from '../models'; * Marks the given class as a user repository. * This registers it to be injected directly, without using "@InjectRepository". * - * If you store your user in a database, you probably want to extend "Repository" and implement the constructor, so that everything works:. + * If you store your user in a data source, you probably want to extend "Repository" and implement the constructor, so that everything works:. * * ```ts * \@UserRepo(User) diff --git a/src/auth/strategies/jwt/jwt-refresh-token.model.ts b/src/auth/strategies/jwt/jwt-refresh-token.model.ts index a786178..6063de2 100644 --- a/src/auth/strategies/jwt/jwt-refresh-token.model.ts +++ b/src/auth/strategies/jwt/jwt-refresh-token.model.ts @@ -2,7 +2,7 @@ import { Entity, OmitClass, Property } from '../../../entity'; import { BaseEntity } from '../../../entity/base-entity.model'; /** - * The jwt refresh token that gets stored in the database. + * The jwt refresh token that gets stored in the data source. */ @Entity() export class JwtRefreshToken extends BaseEntity { diff --git a/src/backup/backup-service.test.ts b/src/backup/backup-service.test.ts index f6f2c49..bd5eee0 100644 --- a/src/backup/backup-service.test.ts +++ b/src/backup/backup-service.test.ts @@ -3,15 +3,11 @@ import path from 'path'; import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; -import { DataSourceOptions } from 'typeorm'; -import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; import { BackupService } from './backup.service'; import { POSTGRES_TEST_IMAGE, testFileFolder } from '../__testing__'; import { BackupEntity } from './backup-entity.model'; import { BackupResourceEntity } from './backup-resource-entity.model'; -import { BackupResourceInterface } from './backup-resource.interface'; -import { BaseDataSource } from '../data-source/base-data-source.model'; import { DataSource } from '../data-source/decorators/data-source.decorator'; import { MigrationEntity } from '../data-source/migration/migration-entity.model'; import { Repository } from '../data-source/repository'; @@ -21,6 +17,7 @@ import { BaseEntity } from '../entity/base-entity.model'; import { Newable } from '../types'; import { Backup } from './decorators/backup-resource.decorator'; import { FsBackupTransport } from './transports'; +import { PostgresDataSource, PostgresOptions } from '../data-source'; const backupFsFolder: string = path.join(testFileFolder, 'backups'); @@ -42,11 +39,10 @@ class Item { ) ] }) -class DbDataSource extends BaseDataSource implements BackupResourceInterface { +class DbDataSource extends PostgresDataSource { rootPw: string = 'password'; rootUsername: string = 'postgres'; - options: DataSourceOptions = { - type: 'postgres', + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -72,8 +68,8 @@ describe('Create and restore postgres backup', () => { .start(); dataSource = inject(DbDataSource); - (dataSource.options as PostgresConnectionCredentialsOptions) = { - ...dataSource.options as PostgresConnectionCredentialsOptions, + dataSource.options = { + ...dataSource.options, port: container.getMappedPort(5432) }; await dataSource.init(); diff --git a/src/backup/backup.service.ts b/src/backup/backup.service.ts index d4a4b55..e4483d8 100644 --- a/src/backup/backup.service.ts +++ b/src/backup/backup.service.ts @@ -2,7 +2,7 @@ import { Readable } from 'node:stream'; import { BehaviorSubject } from 'rxjs'; -import { BaseDataSource, Repository } from '../data-source'; +import { PostgresDataSource, Repository } from '../data-source'; import { inject, repositoryTokenFor, ZIBRI_DI_TOKENS } from '../di'; import { GlobalRegistry } from '../global'; import { LoggerInterface } from '../logging'; @@ -50,7 +50,7 @@ export class BackupService implements BackupServiceInterface { if (resource.createBackupData == undefined || resource.restoreBackup == undefined) { throw new Error(`Invalid resource marked with @Backup: ${resourceClass.name} needs to implement BackupResourceInterface`); } - if (resource instanceof BaseDataSource && (!resource.rootPw || !resource.rootUsername)) { + if (resource instanceof PostgresDataSource && (!resource.rootPw || !resource.rootUsername)) { throw new Error(`Invalid data source marked with @Backup: ${resourceClass.name} needs to provide rootPw and rootUsername`); } if (!MetadataUtilities.getBackupResourceMetadata(resourceClass)?.transports.length) { diff --git a/src/cron/cron-job.model.ts b/src/cron/cron-job.model.ts index e1bf463..b2e5453 100644 --- a/src/cron/cron-job.model.ts +++ b/src/cron/cron-job.model.ts @@ -15,10 +15,10 @@ import { Ms, UUIDUtilities } from '../utilities'; */ export type CronConfig = OmitStrict & { /** - * Whether or not the cron job should be synced to a database. (With status, lastRun etc.). + * Whether or not the cron job should be synced to a data source. (With status, lastRun etc.). * Defaults to true. */ - syncToDb: boolean + syncToDataSource: boolean }; /** @@ -65,7 +65,7 @@ export abstract class CronJob { return { active: true, runOnInit: true, - syncToDb: true, + syncToDataSource: true, stopOnError: true, ...this.initialConfig, name: this.overrideName ?? this.initialConfig.name @@ -136,7 +136,7 @@ export abstract class CronJob { * @returns The cron job entity. */ protected async resolveEntity(): Promise { - if (!this.fullInitialConfig.syncToDb) { + if (!this.fullInitialConfig.syncToDataSource) { return { id: UUIDUtilities.generate(), lastRun: undefined, @@ -166,7 +166,7 @@ export abstract class CronJob { } finally { this.entity.lastRun = new Date(); - if (this.fullInitialConfig.syncToDb) { + if (this.fullInitialConfig.syncToDataSource) { await this.cronJobRepository.updateAll({ name: this.fullInitialConfig.name }, { lastRun: this.entity.lastRun }); } } @@ -193,7 +193,7 @@ export abstract class CronJob { await this.logger.info(`Stopping cron job "${this.name}"`); await this.disable(); } - if (this.fullInitialConfig.syncToDb) { + if (this.fullInitialConfig.syncToDataSource) { await this.cronJobRepository.updateAll({ name: this.fullInitialConfig.name }, { errorMessage: this.entity.errorMessage }); } @@ -209,7 +209,7 @@ export abstract class CronJob { } this.entity.active = true; - if (this.fullInitialConfig.syncToDb) { + if (this.fullInitialConfig.syncToDataSource) { await this.cronJobRepository.updateAll({ name: this.fullInitialConfig.name }, { active: this.entity.active }); } await this.task.start(); @@ -225,7 +225,7 @@ export abstract class CronJob { await this.task.stop(); this.entity.active = false; - if (this.fullInitialConfig.syncToDb) { + if (this.fullInitialConfig.syncToDataSource) { await this.cronJobRepository.updateAll({ name: this.fullInitialConfig.name }, { active: this.entity.active }); } } @@ -243,7 +243,7 @@ export abstract class CronJob { } this.entity.cron = cronExpression; - if (this.fullInitialConfig.syncToDb) { + if (this.fullInitialConfig.syncToDataSource) { await this.cronJobRepository.updateAll({ name: this.fullInitialConfig.name }, { cron: this.entity.cron }); } await this.task.stop(); @@ -263,7 +263,7 @@ export abstract class CronJob { ...this.entity, ...data }; - if (this.fullInitialConfig.syncToDb) { + if (this.fullInitialConfig.syncToDataSource) { await this.cronJobRepository.updateAll({ name: this.fullInitialConfig.name }, { ...data }); } await this.task.stop(); diff --git a/src/data-source/cascade-delete.test.ts b/src/data-source/cascade-delete.test.ts index 4712e9b..f2b002e 100644 --- a/src/data-source/cascade-delete.test.ts +++ b/src/data-source/cascade-delete.test.ts @@ -1,21 +1,18 @@ import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; -import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; -import { BaseDataSource } from './base-data-source.model'; import { DataSource } from './decorators'; import { inject } from '../di'; import { BaseEntity } from '../entity/base-entity.model'; import { Newable } from '../types'; import { MigrationEntity } from './migration'; -import { DataSourceOptions } from './models'; import { Repository } from './repository'; import { Child, Company, mockCreateUserData, Parent, POSTGRES_TEST_IMAGE, Profile, Role, User, UserCreateData } from '../__testing__'; +import { PostgresDataSource, PostgresOptions } from './data-sources'; @DataSource() -class TestDataSource extends BaseDataSource { - options: DataSourceOptions = { - type: 'postgres', +class TestDataSource extends PostgresDataSource { + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -36,8 +33,8 @@ describe('cascade delete', () => { .withPassword('password') .start(); ds = inject(TestDataSource); - (ds.options as PostgresConnectionCredentialsOptions) = { - ...(ds.options as PostgresConnectionCredentialsOptions), + ds.options = { + ...ds.options, port: container.getMappedPort(5432) }; await ds.init(); diff --git a/src/data-source/data-source.service.ts b/src/data-source/data-source.service.ts index dda619f..40e92e0 100644 --- a/src/data-source/data-source.service.ts +++ b/src/data-source/data-source.service.ts @@ -1,10 +1,9 @@ import { GlobalRegistry } from '../global'; import { DataSourceServiceInterface } from './data-source-service.interface'; -import { inject, ZIBRI_DI_TOKENS } from '../di'; -import { BaseDataSource } from './base-data-source.model'; import { JwtCredentials, PasswordResetToken, JwtRefreshToken, OtpCredentials } from '../auth'; import { BackupEntity, BackupResourceEntity } from '../backup'; import { CronJobEntity } from '../cron'; +import { inject, ZIBRI_DI_TOKENS } from '../di'; import { Email, MailingList, MailingListSubscriber } from '../email'; import { BaseEntity } from '../entity/base-entity.model'; import { Log, LoggerInterface } from '../logging'; @@ -12,6 +11,7 @@ import { ThreadJobEntity } from '../multithreading'; import { Newable } from '../types'; import { validateEntitiesRegistered } from '../utilities'; import { WebsocketChannel, WebsocketMessage } from '../websocket'; +import { DataSourceInterface } from './data-sources/data-source.interface'; /** * Default data source service implementation of Zibri. @@ -50,7 +50,7 @@ export class DataSourceService implements DataSourceServiceInterface { } for (const dataSourceClass of GlobalRegistry.dataSourceClasses) { - const dataSource: BaseDataSource = inject(dataSourceClass); + const dataSource: DataSourceInterface = inject(dataSourceClass); for (const entity of this.defaultEntities) { if (!dataSource.entities.includes(entity)) { dataSource.entities.push(entity); diff --git a/src/data-source/data-sources/data-source.interface.ts b/src/data-source/data-sources/data-source.interface.ts new file mode 100644 index 0000000..608afa8 --- /dev/null +++ b/src/data-source/data-sources/data-source.interface.ts @@ -0,0 +1,80 @@ +import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel'; + +import { BackupResourceInterface } from '../../backup'; +import { PropertyMetadata, PropertyMetadataInput, RelationMetadata } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { FilePropertyMetadata } from '../../entity/models/file-property-metadata.model'; +import { ExcludeStrict, Newable } from '../../types'; +import { Migration } from '../migration'; +import { Repository } from '../repository'; +import { Transaction } from '../transaction'; + +/** + * Definition for a data source. + */ +export interface DataSourceInterface extends BackupResourceInterface { + /** + * The entities of this data source. + */ + readonly entities: Newable[], + + /** + * All migrations that belong to this data source. + */ + readonly migrations: Newable[], + + /** + * Initializes the data source. + */ + init: () => Promise, + + /** + * Gets a repository to manage the provided entity class in the data source. + * @param cls - The entity class to get the repository for. + * @returns A repository for the provided entity class. + * @throws When the data source has not been initialized yet or the provided entity does not belong to this data source. + */ + getRepository: (cls: Newable) => Repository, + + /** + * Starts a new transaction. + * @param isolationLevel - The isolation level of the transaction. + * @returns A new transaction that can be passed to any repository methods. + */ + startTransaction: (isolationLevel?: IsolationLevel) => Promise, + + /** + * Runs migrations for the data source. + */ + runMigrations: () => Promise, + + /** + * Adds a new property to the given entity. + * + * Mostly needed in the context of migrations. + */ + addPropertyToEntity: ( + entity: Newable, + key: keyof T, + transaction: Transaction + ) => Promise, + + /** + * Changes the given oldProperty to the newProperty on the given entity. + */ + changePropertyOfEntity: ( + entity: Newable, + oldProperty: keyof T | string & {}, + newProperty: PropertyMetadataInput & { + /** + * The name of the new column. + */ + name?: keyof T, + /** + * The type of the new column. + */ + type: ExcludeStrict | FilePropertyMetadata>['type'] + }, + transaction: Transaction + ) => Promise +} \ No newline at end of file diff --git a/src/data-source/data-sources/index.ts b/src/data-source/data-sources/index.ts new file mode 100644 index 0000000..8167f88 --- /dev/null +++ b/src/data-source/data-sources/index.ts @@ -0,0 +1,2 @@ +export * from './data-source.interface'; +export * from './postgres-data-source.model'; \ No newline at end of file diff --git a/src/data-source/base-data-source.model.ts b/src/data-source/data-sources/postgres-data-source.model.ts similarity index 76% rename from src/data-source/base-data-source.model.ts rename to src/data-source/data-sources/postgres-data-source.model.ts index 20855fb..a32d443 100644 --- a/src/data-source/base-data-source.model.ts +++ b/src/data-source/data-sources/postgres-data-source.model.ts @@ -1,28 +1,29 @@ import { ChildProcessByStdio, spawn } from 'node:child_process'; import { PassThrough, Readable, Writable } from 'node:stream'; -import { DataSource as TODataSource, Repository as TORepository, EntityMetadata as TOEntityMetadata, EntitySchema, EntitySchemaColumnOptions, QueryRunner, EntitySchemaRelationOptions, Table, TableColumnOptions } from 'typeorm'; +import { DataSource as TODataSource, Repository as TORepository, EntityMetadata as TOEntityMetadata, EntitySchema, EntitySchemaColumnOptions, QueryRunner, EntitySchemaRelationOptions, Table, TableColumnOptions, TableColumn, EntityTarget } from 'typeorm'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions'; import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel'; import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; import { OnDeleteType } from 'typeorm/metadata/types/OnDeleteType'; import { OnUpdateType } from 'typeorm/metadata/types/OnUpdateType'; -import { inject, repositoryTokenFor, ZIBRI_DI_TOKENS } from '../di'; -import { EntityMetadata, PropertyMetadata, Relation, RelationMetadata, StringPropertyMetadata } from '../entity'; -import { BaseEntity } from '../entity/base-entity.model'; -import { ExcludeStrict, Newable, OmitStrict, Version } from '../types'; -import { compareVersion, MetadataUtilities } from '../utilities'; -import { Migration, MigrationEntity } from './migration'; -import { ColumnType, DataSourceOptions } from './models'; -import { Repository } from './repository'; -import { Transaction } from './transaction'; -import { BackupResourceInterface } from '../backup'; -import { ChangeSetEntity, ChangeSetRepository, isChangeSetEntityNewable, isSoftDeleteEntityNewable, SoftDeleteEntity, SoftDeleteRepository } from '../change-sets'; -import { register } from '../di/register.function'; -import { GlobalRegistry } from '../global'; -import { LoggerInterface } from '../logging'; -import { TypeOrmTransaction } from './transaction/typeorm-transaction.model'; +import { DataSourceInterface } from '.'; +import { ChangeSetEntity, ChangeSetRepository, isChangeSetEntityNewable, isSoftDeleteEntityNewable, SoftDeleteEntity, SoftDeleteRepository } from '../../change-sets'; +import { inject, repositoryTokenFor, ZIBRI_DI_TOKENS } from '../../di'; +import { register } from '../../di/register.function'; +import { EntityMetadata, PropertyMetadata, PropertyMetadataInput, Relation, RelationMetadata, StringPropertyMetadata } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { FilePropertyMetadata } from '../../entity/models/file-property-metadata.model'; +import { GlobalRegistry } from '../../global'; +import { LoggerInterface } from '../../logging'; +import { ExcludeStrict, Newable, OmitStrict, Version } from '../../types'; +import { compareVersion, MetadataUtilities } from '../../utilities'; +import { Migration, MigrationEntity } from '../migration'; +import { ColumnType, DataSourceOptions } from '../models'; +import { Repository } from '../repository'; +import { Transaction } from '../transaction'; +import { TypeOrmTransaction } from '../transaction/typeorm-transaction.model'; // eslint-disable-next-line jsdoc/require-jsdoc type ToColumnMappableTypes = ExcludeStrict>['type']; @@ -31,10 +32,14 @@ type ToColumnMappableTypes = ExcludeStrict; + +/** + * A base postgres data source definition. + */ +export abstract class PostgresDataSource implements DataSourceInterface { /** * Mapping from a Zibri property type to a typeorm column type. */ @@ -54,7 +59,7 @@ export abstract class BaseDataSource implements BackupResourceInterface { }; } - abstract readonly options: OmitStrict; + abstract readonly options: PostgresOptions; abstract readonly entities: Newable[]; /** * The optional root password. @@ -64,9 +69,8 @@ export abstract class BaseDataSource implements BackupResourceInterface { * The optional root username. */ readonly rootUsername: string | undefined; - /** - * All migrations that belong to this data source. - */ + + // eslint-disable-next-line jsdoc/require-jsdoc readonly migrations: Newable[] = []; /** @@ -85,7 +89,7 @@ export abstract class BaseDataSource implements BackupResourceInterface { // eslint-disable-next-line jsdoc/require-jsdoc createBackupData(): Readable { const dumpCommand: string = 'pg_dumpall'; - const { host, port } = this.options as PostgresConnectionOptions; + const { host, port } = this.options; if (!this.rootUsername || !host || !port) { throw new Error('Could not create a backup, missing this.rootUsername, this.options.host or this.options.port'); } @@ -115,7 +119,7 @@ export abstract class BaseDataSource implements BackupResourceInterface { // eslint-disable-next-line jsdoc/require-jsdoc async restoreBackup(backupData: Readable): Promise { - const { host, port, database } = this.options as PostgresConnectionOptions; + const { host, port, database } = this.options; if (!this.rootUsername || !host || !port || !database) { throw new Error('Missing rootUsername, host, port, or database for restore'); } @@ -141,12 +145,10 @@ export abstract class BaseDataSource implements BackupResourceInterface { }); } - /** - * Initializes the data source. - */ + // eslint-disable-next-line jsdoc/require-jsdoc async init(): Promise { if (this.ds) { - throw new Error(`The ${this.options.type} data source has already been initialized.`); + throw new Error('The postgres data source has already been initialized.'); } for (const entityClass of this.entities) { @@ -160,6 +162,7 @@ export abstract class BaseDataSource implements BackupResourceInterface { this.ds = new TODataSource({ entities: schemas, poolSize: 100, + type: 'postgres', ...this.options, synchronize: false } as DataSourceOptions); @@ -362,15 +365,10 @@ export abstract class BaseDataSource implements BackupResourceInterface { } } - /** - * Gets a repository to access the table of the provided entity class. - * @param cls - The entity class to get the repository for. - * @returns A repository for the provided entity class. - * @throws When the data source has not been initialized yet or the provided entity does not belong to this data source. - */ + // eslint-disable-next-line jsdoc/require-jsdoc getRepository(cls: Newable): Repository { if (!this.ds) { - throw new Error(`The ${this.options.type} data source needs to be initialized before it can be used.`); + throw new Error('The postgres data source needs to be initialized before it can be used.'); } if (!this.entities.find(e => e === cls)) { throw new Error(`The entity "${cls.name}" is not in this database. Did you forget to include it in the entities array?`); @@ -386,14 +384,10 @@ export abstract class BaseDataSource implements BackupResourceInterface { return new Repository(cls, repo); } - /** - * Starts a new transaction. - * @param isolationLevel - The isolation level of the transaction. - * @returns A new transaction that can be passed to any repository methods. - */ + // eslint-disable-next-line jsdoc/require-jsdoc async startTransaction(isolationLevel?: IsolationLevel): Promise { if (!this.ds) { - throw new Error(`The ${this.options.type} data source needs to be initialized before it can be used.`); + throw new Error('The postgres data source needs to be initialized before it can be used.'); } const runner: QueryRunner = this.createQueryRunner(); @@ -409,20 +403,18 @@ export abstract class BaseDataSource implements BackupResourceInterface { } /** - * Creates a typeorm query runner. - * @returns A typeorm query runner. + * Creates a query runner. + * @returns A query runner. * @throws When the data source has not been initialized yet. */ createQueryRunner(): QueryRunner { if (!this.ds) { - throw new Error(`The ${this.options.type} data source needs to be initialized before it can be used.`); + throw new Error('The postgres data source needs to be initialized before it can be used.'); } return this.ds.createQueryRunner(); } - /** - * Runs migrations for the data source. - */ + // eslint-disable-next-line jsdoc/require-jsdoc async runMigrations(): Promise { await this.createMigrationTableIfNotExists(); @@ -456,12 +448,102 @@ export abstract class BaseDataSource implements BackupResourceInterface { } } + // eslint-disable-next-line jsdoc/require-jsdoc + async addPropertyToEntity( + entity: Newable, + key: keyof T, + transaction: Transaction + ): Promise { + const col: TableColumnOptions = this.propertyToTableColumnOptions(entity, key); + await transaction.queryRunner.addColumn( + this.getEntityMetadata(entity, transaction).tableName, + new TableColumn({ ...col, isNullable: true }) + ); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async changePropertyOfEntity( + entity: Newable, + oldColumn: keyof T | string & {}, + newColumn: PropertyMetadataInput & { + /** + * The name of the new column. + */ + name?: keyof T, + /** + * The type of the new column. + */ + type: ExcludeStrict | FilePropertyMetadata>['type'] + }, + transaction: Transaction + ): Promise { + const entityMetadata: TOEntityMetadata = this.getEntityMetadata(entity, transaction); + const columnMetadata: ColumnMetadata = this.getColumnMetadata(entity, oldColumn, transaction); + + const col: TableColumnOptions = { + ...columnMetadata, + ...newColumn, + enum: 'enum' in newColumn && newColumn.enum + ? Object.values(newColumn.enum).map(v => String(v)) + : columnMetadata.enum + ? columnMetadata.enum.map(v => String(v)) + : undefined, + name: String(newColumn.name ?? oldColumn), + type: this.normalizeColumnType({ + precision: undefined, + scale: undefined, + ...columnMetadata, + ...newColumn, + type: this.columnTypeMapping[newColumn.type] + }) + }; + + await transaction.queryRunner.changeColumn(entityMetadata.tableName, String(oldColumn), new TableColumn(col)); + } + + /** + * Gets the typeorm metadata for a given entity. + * @param target - The target entity. + * @param transaction - The transaction to run this command with. + * @returns The typeorm metadata. + */ + protected getEntityMetadata(target: EntityTarget, transaction: Transaction): TOEntityMetadata { + return transaction.queryRunner.connection.getMetadata(target); + } + + /** + * Gets the metadata for a typeorm column. + * @param target - The entity. + * @param propertyName - The name of the property to get the column metadata for. + * @param transaction - The transaction to use to get the column metadata. + * @returns The typeorm column metadata. + * @throws When the provided propertyName could not be found as a column. + */ + protected getColumnMetadata( + target: EntityTarget, + propertyName: keyof T | string & {}, + transaction: Transaction + ): ColumnMetadata { + const metadata: TOEntityMetadata = this.getEntityMetadata(target, transaction); + const column: ColumnMetadata | undefined = metadata.columns.find( + (col) => col.propertyName === propertyName + ); + + if (!column) { + throw new Error( + `Column ${propertyName.toString()} not found in model` + ); + } + + return column; + } + /** * Creates a table for migrations if it does not exist already. */ protected async createMigrationTableIfNotExists(): Promise { if (!this.ds) { - throw new Error(`The ${this.options.type} data source needs to be initialized before it can be used.`); + throw new Error('The postgres data source needs to be initialized before it can be used.'); } const runner: QueryRunner = this.createQueryRunner(); @@ -494,9 +576,9 @@ export abstract class BaseDataSource implements BackupResourceInterface { * @returns Typeorm column options. * @throws When no data source has been provided or no column metadata could be found. */ - propertyToTableColumnOptions(entity: Newable, property: keyof T): TableColumnOptions { + protected propertyToTableColumnOptions(entity: Newable, property: keyof T): TableColumnOptions { if (!this.ds) { - throw new Error(`The ${this.options.type} data source needs to be initialized before it can be used.`); + throw new Error('The postgres data source needs to be initialized before it can be used.'); } const schema: EntitySchema = this.createSchemaForEntity(entity); const metadata: TOEntityMetadata = this.ds.getMetadata(schema); @@ -520,12 +602,7 @@ export abstract class BaseDataSource implements BackupResourceInterface { }; } - // eslint-disable-next-line jsdoc/require-returns, jsdoc/require-param - /** - * Normalizes the provided column. - * @throws When the data source has not been initialized yet. - */ - normalizeColumnType( + private normalizeColumnType( column: { // eslint-disable-next-line jsdoc/require-jsdoc type: ColumnType | string & {} | undefined, @@ -540,7 +617,7 @@ export abstract class BaseDataSource implements BackupResourceInterface { } ): string { if (!this.ds) { - throw new Error(`The ${this.options.type} data source needs to be initialized before it can be used.`); + throw new Error('The postgres data source needs to be initialized before it can be used.'); } return this.ds.driver.normalizeType(column); } diff --git a/src/data-source/decorators/data-source.decorator.ts b/src/data-source/decorators/data-source.decorator.ts index 4e15e13..8a1aa43 100644 --- a/src/data-source/decorators/data-source.decorator.ts +++ b/src/data-source/decorators/data-source.decorator.ts @@ -1,7 +1,7 @@ import { GlobalRegistry } from '../../global'; import { Newable } from '../../types'; import { MetadataUtilities } from '../../utilities'; -import { BaseDataSource } from '../base-data-source.model'; +import { DataSourceInterface } from '../data-sources'; // eslint-disable-next-line jsdoc/require-returns /** @@ -16,6 +16,6 @@ export function DataSource(): ClassDecorator { token: target as unknown as Newable, useClass: target as unknown as Newable }); - GlobalRegistry.dataSourceClasses.push(target as unknown as Newable); + GlobalRegistry.dataSourceClasses.push(target as unknown as Newable); }; } \ No newline at end of file diff --git a/src/data-source/index.ts b/src/data-source/index.ts index 6afd009..d275541 100644 --- a/src/data-source/index.ts +++ b/src/data-source/index.ts @@ -1,7 +1,7 @@ export * from './data-source.service'; export * from './data-source-service.interface'; export * from './decorators'; -export * from './base-data-source.model'; +export * from './data-sources'; export * from './decorators'; export * from './repository'; export * from './transaction'; diff --git a/src/data-source/migration/migration.model.ts b/src/data-source/migration/migration.model.ts index 20aca23..9ceb445 100644 --- a/src/data-source/migration/migration.model.ts +++ b/src/data-source/migration/migration.model.ts @@ -1,31 +1,25 @@ -import { EntityMetadata as TOEntityMetadata, EntityTarget, TableColumn, TableColumnOptions } from 'typeorm'; -import { ColumnMetadata as TOColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; - import { MigrationEntity } from './migration-entity.model'; import { inject, repositoryTokenFor } from '../../di'; -import { PropertyMetadata, PropertyMetadataInput, RelationMetadata } from '../../entity'; -import { BaseEntity } from '../../entity/base-entity.model'; -import { FilePropertyMetadata } from '../../entity/models/file-property-metadata.model'; -import { ExcludeStrict, Newable, Version } from '../../types'; -import { BaseDataSource } from '../base-data-source.model'; +import { Newable, Version } from '../../types'; +import { DataSourceInterface } from '../data-sources'; import { Repository } from '../repository'; import { Transaction } from '../transaction'; /** - * Base class for a database migration. + * Base class for a data source migration. */ export abstract class Migration { abstract readonly version: Version; /** * The data source that the migration is for. */ - protected readonly dataSource: BaseDataSource; + protected readonly dataSource: DataSourceInterface; /** - * The repository that syncs migrations back and forth to the db. + * The repository that syncs migrations back and forth to the data source. */ protected readonly migrationRepository: Repository; - constructor(dataSourceClass: Newable) { + constructor(dataSourceClass: Newable) { this.dataSource = inject(dataSourceClass); this.migrationRepository = inject(repositoryTokenFor(MigrationEntity)); } @@ -71,105 +65,4 @@ export abstract class Migration { protected abstract up(transaction: Transaction): Promise; protected abstract down(transaction: Transaction): Promise; - - /** - * Adds a column to the table of the given entity. - * @param entity - The entity for which the column should be added. - * @param key - The key of the entity for which a column should be added. - * @param transaction - The transaction to run this inside of. - */ - protected async addColumn( - entity: Newable, - key: keyof T, - transaction: Transaction - ): Promise { - const col: TableColumnOptions = this.dataSource.propertyToTableColumnOptions(entity, key); - await transaction.queryRunner.addColumn( - this.getEntityMetadata(entity, transaction).tableName, - new TableColumn({ ...col, isNullable: true }) - ); - } - - /** - * Changes a column of the provided entity to the new column value. - * @param entity - The entity that the column belongs to which should be changed. - * @param oldColumn - The old column key. - * @param newColumn - The new data that should replace the provided old column. - * @param transaction - The transaction that should be used. - */ - protected async changeColumn( - entity: Newable, - oldColumn: keyof T | string & {}, - newColumn: PropertyMetadataInput & { - /** - * The name of the new column. - */ - name?: keyof T, - /** - * The type of the new column. - */ - type: ExcludeStrict | FilePropertyMetadata>['type'] - }, - transaction: Transaction - ): Promise { - const entityMetadata: TOEntityMetadata = this.getEntityMetadata(entity, transaction); - const columnMetadata: TOColumnMetadata = this.getColumnMetadata(entity, oldColumn, transaction); - - const col: TableColumnOptions = { - ...columnMetadata, - ...newColumn, - enum: 'enum' in newColumn && newColumn.enum - ? Object.values(newColumn.enum).map(v => String(v)) - : columnMetadata.enum - ? columnMetadata.enum.map(v => String(v)) - : undefined, - name: String(newColumn.name ?? oldColumn), - type: this.dataSource.normalizeColumnType({ - precision: undefined, - scale: undefined, - ...columnMetadata, - ...newColumn, - type: this.dataSource['columnTypeMapping'][newColumn.type] - }) - }; - - await transaction.queryRunner.changeColumn(entityMetadata.tableName, String(oldColumn), new TableColumn(col)); - } - - /** - * Gets the metadata for a typeorm column. - * @param target - The entity. - * @param propertyName - The name of the property to get the column metadata for. - * @param transaction - The transaction to use to get the column metadata. - * @returns The typeorm column metadata. - * @throws When the provided propertyName could not be found as a column. - */ - protected getColumnMetadata( - target: EntityTarget, - propertyName: keyof T | string & {}, - transaction: Transaction - ): TOColumnMetadata { - const metadata: TOEntityMetadata = this.getEntityMetadata(target, transaction); - const column: TOColumnMetadata | undefined = metadata.columns.find( - (col) => col.propertyName === propertyName - ); - - if (!column) { - throw new Error( - `Column ${propertyName.toString()} not found in model` - ); - } - - return column; - } - - /** - * Gets the typeorm metadata for a given entity. - * @param target - The target entity. - * @param transaction - The transaction to run this command with. - * @returns The typeorm metadata. - */ - protected getEntityMetadata(target: EntityTarget, transaction: Transaction): TOEntityMetadata { - return transaction.queryRunner.connection.getMetadata(target); - } } \ No newline at end of file diff --git a/src/data-source/migration/migration.test.ts b/src/data-source/migration/migration.test.ts index 54dfc2a..d3dfc12 100644 --- a/src/data-source/migration/migration.test.ts +++ b/src/data-source/migration/migration.test.ts @@ -1,13 +1,11 @@ import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; -import { DataSourceOptions, Table, TableColumn } from 'typeorm'; -import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; +import { Table, TableColumn } from 'typeorm'; import { inject, Injectable, InjectRepository } from '../../di'; import { Entity, Property } from '../../entity'; import { BaseEntity } from '../../entity/base-entity.model'; import { Newable, Version } from '../../types'; -import { BaseDataSource } from '../base-data-source.model'; import { Transaction } from '../transaction'; import { Migration } from './migration.model'; import { DataSource } from '../decorators'; @@ -15,6 +13,7 @@ import { Repository } from '../repository'; import { MigrationEntity } from './migration-entity.model'; import { POSTGRES_TEST_IMAGE } from '../../__testing__'; import { GlobalRegistry } from '../../global'; +import { PostgresDataSource, PostgresOptions } from '../data-sources'; @Entity('item') class LegacyItem { @@ -23,9 +22,8 @@ class LegacyItem { } @DataSource() -class LegacyDbDataSource extends BaseDataSource { - options: DataSourceOptions = { - type: 'postgres', +class LegacyDbDataSource extends PostgresDataSource { + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -45,9 +43,8 @@ class Item { } @DataSource() -class DbDataSource extends BaseDataSource { - options: DataSourceOptions = { - type: 'postgres', +class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -70,7 +67,8 @@ class AddTestValueMigration extends Migration { } override async up(transaction: Transaction): Promise { - await this.addColumn(Item, 'value', transaction); + await this.dataSource.addPropertyToEntity(Item, 'value', transaction); + await this.dataSource.changePropertyOfEntity(Item, 'oldValue', { type: 'string', name: 'value' }, transaction); const existingItems: Item[] = await this.itemRepository.findAll({ transaction }); await Promise.all(existingItems.map(t => this.itemRepository.updateById(t.id, { value: '42' }, { transaction }))); } @@ -95,8 +93,8 @@ describe('AddTestValueMigration', () => { GlobalRegistry['appData'].version = '0.0.1'; const legacyDataSource: LegacyDbDataSource = inject(LegacyDbDataSource); - (legacyDataSource.options as PostgresConnectionCredentialsOptions) = { - ...legacyDataSource.options as PostgresConnectionCredentialsOptions, + legacyDataSource.options = { + ...legacyDataSource.options, port: container.getMappedPort(5432) }; await legacyDataSource.init(); @@ -109,8 +107,8 @@ describe('AddTestValueMigration', () => { it('should add non-nullable value column with backfilled defaults', async () => { const dataSource: DbDataSource = inject(DbDataSource); - (dataSource.options as PostgresConnectionCredentialsOptions) = { - ...dataSource.options as PostgresConnectionCredentialsOptions, + dataSource.options = { + ...dataSource.options, port: container.getMappedPort(5432) }; await dataSource.init(); diff --git a/src/data-source/query-failed.error.ts b/src/data-source/query-failed.error.ts index d5684d3..691b9c4 100644 --- a/src/data-source/query-failed.error.ts +++ b/src/data-source/query-failed.error.ts @@ -1,7 +1,7 @@ import { QueryFailedError as TOQueryFailedError } from 'typeorm'; /** - * An error for a failed database query. + * An error for a failed sql query. */ export class QueryFailedError extends Error { constructor(error: TOQueryFailedError) { diff --git a/src/data-source/repository.ts b/src/data-source/repository.ts index 8e48bb1..f659a94 100644 --- a/src/data-source/repository.ts +++ b/src/data-source/repository.ts @@ -14,7 +14,7 @@ import { whereFilterToFindOptionsWhere } from './models/where/where-filter-to-fi import { QueryFailedError } from './query-failed.error'; /** - * A repository that handles database related things for its entity. + * A repository that handles data source related things for its entity. */ export class Repository< T extends BaseEntity, diff --git a/src/data-source/transaction/transaction.test.ts b/src/data-source/transaction/transaction.test.ts index dbbf5b4..8959919 100644 --- a/src/data-source/transaction/transaction.test.ts +++ b/src/data-source/transaction/transaction.test.ts @@ -1,18 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; import { PostgreSqlContainer } from '@testcontainers/postgresql'; import { StartedTestContainer } from 'testcontainers'; -import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; import { POSTGRES_TEST_IMAGE } from '../../__testing__'; import { Entity, Property } from '../../entity'; import { BaseEntity } from '../../entity/base-entity.model'; import { Newable } from '../../types'; -import { BaseDataSource } from '../base-data-source.model'; +import { PostgresDataSource, PostgresOptions } from '../data-sources'; import { DataSource } from '../decorators'; import { Repository } from '../repository'; import { Transaction } from './transaction.model'; import { MigrationEntity } from '../migration'; -import { DataSourceOptions } from '../models'; @Entity() class Item { @@ -24,9 +22,8 @@ class Item { } @DataSource() -class DbDataSource extends BaseDataSource { - options: DataSourceOptions = { - type: 'postgres', +class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -48,8 +45,8 @@ describe('transaction', () => { .withPassword('password') .start(); dataSource = new DbDataSource(); - (dataSource.options as PostgresConnectionCredentialsOptions) = { - ...dataSource.options as PostgresConnectionCredentialsOptions, + dataSource.options = { + ...dataSource.options, port: container.getMappedPort(5432) }; await dataSource.init(); diff --git a/src/entity/models/many-to-many-property-metadata.model.ts b/src/entity/models/many-to-many-property-metadata.model.ts index 9e95ed9..0cc6ac3 100644 --- a/src/entity/models/many-to-many-property-metadata.model.ts +++ b/src/entity/models/many-to-many-property-metadata.model.ts @@ -18,7 +18,7 @@ export type ManyToManyPropertyMetadata = BaseRelationMetad /** * Indicates if persistence is enabled for the relation. * By default its enabled, but if you want to avoid any changes - * in the relation to be reflected in the database you can disable it. + * in the relation to be reflected in the data source you can disable it. * If its disabled you can only change a relation from inverse side * of a relation or using relation query builder functionality. * This is useful for performance optimization since its disabling avoid diff --git a/src/global/global-registry.ts b/src/global/global-registry.ts index fe84ffe..134dd5e 100644 --- a/src/global/global-registry.ts +++ b/src/global/global-registry.ts @@ -1,7 +1,7 @@ import { ZibriApplicationOptions } from '../application-options.model'; import { UserRepositories } from '../auth'; import { BackupResourceInterface } from '../backup'; -import { BaseDataSource } from '../data-source'; +import { DataSourceInterface } from '../data-source'; import { DiProvider } from '../di'; import { BaseEntity } from '../entity/base-entity.model'; import { BodyParserInterface } from '../parsing'; @@ -61,7 +61,7 @@ export abstract class GlobalRegistry { /** * All datasources registered with \@DataSource. */ - static readonly dataSourceClasses: Newable[] = []; + static readonly dataSourceClasses: Newable[] = []; /** * All entities registered with \@Entity. */ diff --git a/src/http-client/http-client-response.model.ts b/src/http-client/http-client-response.model.ts index b7c4beb..2baf415 100644 --- a/src/http-client/http-client-response.model.ts +++ b/src/http-client/http-client-response.model.ts @@ -1,9 +1,6 @@ -import { AxiosResponse } from 'axios'; - -import { MimeType, KnownHeader } from '../http'; +import { MimeType, KnownHeader, HttpStatus } from '../http'; import { FormData } from '../parsing'; import { BodyMetadata } from '../routing'; -import { OmitStrict } from '../types'; /** * Definition for a response from using the http client. @@ -11,13 +8,21 @@ import { OmitStrict } from '../types'; export type HttpClientResponse< T = undefined, HeaderParamsObject extends Record = Partial> -> = OmitStrict & { +> = { + /** + * + */ + status: HttpStatus, + /** + * + */ + statusText: string, /** * The headers of the response. */ headers: HeaderParamsObject, /** - * The raw body of the response as returned by. No parsing or validation has happened here. + * The raw body of the response. No parsing or validation has happened here. */ rawBody: unknown, /** diff --git a/src/logging/transport/log-to-db.function.ts b/src/logging/transport/log-to-db.function.ts index 7ade153..51810d2 100644 --- a/src/logging/transport/log-to-db.function.ts +++ b/src/logging/transport/log-to-db.function.ts @@ -3,7 +3,7 @@ import { inject, repositoryTokenFor } from '../../di'; import { Log } from '../log.model'; /** - * Saves the given log in the database. + * Saves the given log in the data source. * @param log - The log to save. */ export async function logToDb(log: Log): Promise { diff --git a/src/logging/transport/logger-transport.model.ts b/src/logging/transport/logger-transport.model.ts index 865ac26..66e5224 100644 --- a/src/logging/transport/logger-transport.model.ts +++ b/src/logging/transport/logger-transport.model.ts @@ -19,7 +19,7 @@ export type BaseLoggerTransportConfig = { /** * Whether to register the transport as soon as possible or after the startup of the app. * - * This is useful if the transport eg. Depends on the database being available. + * This is useful if the transport eg. Depends on a data source being available. */ register: 'directly' | 'afterStartup' }; @@ -63,7 +63,7 @@ export class LoggerTransport { } /** - * Creates a new logger transport that saves the log into the database. + * Creates a new logger transport that saves the log into the data source. * @param level - The log level at which the transport should be active. * @returns The newly created transport. */ diff --git a/src/metrics/metrics-service.interface.ts b/src/metrics/metrics-service.interface.ts index 12fa51c..f69e13f 100644 --- a/src/metrics/metrics-service.interface.ts +++ b/src/metrics/metrics-service.interface.ts @@ -63,7 +63,7 @@ export interface MetricsServiceInterface { * Collect a snapshot of every metric’s current samples. * This will usually be called in regular intervals, eg. From a cron job. * - * To retrieve the metrics use the getMetrics method. + * To retrieve the metrics use the getMetricSnapshots method. */ collect: () => Promise, /** diff --git a/src/multithreading/models/thread-job-data.model.ts b/src/multithreading/models/thread-job-data.model.ts index a38749b..1fd3534 100644 --- a/src/multithreading/models/thread-job-data.model.ts +++ b/src/multithreading/models/thread-job-data.model.ts @@ -2,7 +2,7 @@ import { BaseThreadJobWorkerData } from './base-thread-job-worker-data.model'; /** * The function values of the thread job data. - * This is separated because this cannot be persisted in a database. + * This is separated because this cannot be persisted in a data source. */ export type ThreadJobDataFunctions = { /** diff --git a/src/multithreading/services/multithreading-service.interface.ts b/src/multithreading/services/multithreading-service.interface.ts index 8ee996f..63fe55d 100644 --- a/src/multithreading/services/multithreading-service.interface.ts +++ b/src/multithreading/services/multithreading-service.interface.ts @@ -13,7 +13,7 @@ export interface MultithreadingServiceInterface { /** * Creates and queues a thread job with the given data. * @param threadJobData - The data to create the thread job from. - * @returns The id of the created thread job in the database and queue. + * @returns The id of the created thread job in the data source and queue. * * **This differs from the threadId, which is created by the os and set when the thread actually starts.**. */ @@ -29,7 +29,7 @@ export interface MultithreadingServiceInterface { threadJobData: ThreadJobData ) => Promise> | ThreadJobEntity, /** - * Runs the given function on a separate thread. This will not persist the state in the database. + * Runs the given function on a separate thread. This will not persist the state in the data source. * ***IMPORTANT**: This uses "eval" in the thread worker, so make sure that the data passed is not malicious. * @param func - The function that should be run in a separate thread. diff --git a/src/open-api/open-api.service.ts b/src/open-api/open-api.service.ts index 56a4dc1..6cfd8d7 100644 --- a/src/open-api/open-api.service.ts +++ b/src/open-api/open-api.service.ts @@ -133,6 +133,111 @@ export class OpenApiService implements OpenApiServiceInterface { } }); + await app.router.register({ + httpMethod: HttpMethod.GET, + route: `${this.openApiRoute}/custom.js`, + handler: (_, res) => { + res.type('.js').send([ + '(function waitForTopbar() {', + ' const topbar = document.querySelector(\'.information-container\');', + ' if (!topbar) {', + ' return setTimeout(waitForTopbar, 50);', + ' }', + ' if (document.getElementById(\'zibri-openapi-logo\')) {', + ' return;', + ' }', + '', + ' const a = document.createElement(\'a\');', + ' a.id = \'zibri-openapi-logo\';', + ' a.href = \'/\';', + '', + ' const img = document.createElement(\'img\');', + ` img.src = '${this.assetService.assetsRoute}/logo.jpg';`, + ' img.height = 100;', + ' img.width = 100;', + '', + ' a.appendChild(img);', + ` a.append('${GlobalRegistry.getAppData('name')}')`, + ' topbar.insertBefore(a, topbar.firstChild);', + '})();', + '', + '(function waitForSwagger() {', + ' if (!window.ui || typeof window.ui.specSelectors !== \'object\') {', + ' return setTimeout(waitForSwagger, 50);', + ' }', + // eslint-disable-next-line stylistic/max-len + ' const spec = window.ui.specSelectors.specJson().toJS ? window.ui.specSelectors.specJson().toJS() : window.ui.specSelectors.specJson();', + ' if (!spec || !spec.paths) {', + ' return setTimeout(waitForSwagger, 50);', + ' }', + '', + ' function normalizeMethod(m) {', + ' return String(m).toLowerCase();', + ' }', + '', + // eslint-disable-next-line cspell/spellchecker + ' const opblocks = Array.from(document.querySelectorAll(\'.opblock\'));', + // eslint-disable-next-line cspell/spellchecker + ' opblocks.forEach((op) => {', + ' try {', + // eslint-disable-next-line cspell/spellchecker + ' const methodEl = op.querySelector(\'.opblock-summary-method\');', + // eslint-disable-next-line cspell/spellchecker + ' const pathEl = op.querySelector(\'.opblock-summary-path\');', + ' if (!methodEl || !pathEl) {', + ' return;', + ' }', + ' const method = normalizeMethod(methodEl.textContent?.trim() ?? \'\');', + ' const path = (pathEl.textContent?.trim() ?? \'\');', + '', + ' const pathObj = spec.paths && spec.paths[path];', + ' if (!pathObj) {', + ' return;', + ' }', + ' const operationObj = pathObj[method];', + ' if (!operationObj) {', + ' return;', + ' }', + ' const roles = operationObj[\'x-roles\'];', + ' if (!roles || !Array.isArray(roles) || roles.length === 0) {', + ' return;', + ' }', + '', + ' if (op.querySelector(\'.zibri-roles-container\')) {', + ' return;', + ' }', + ' const container = document.createElement(\'div\');', + ' container.className = \'zibri-roles-container\';', + ' container.setAttribute(\'aria-hidden\', \'true\');', + '', + ' roles.forEach((r) => {', + ' const badge = document.createElement(\'span\');', + ' badge.className = \'zibri-role-badge\';', + ' badge.textContent = String(r);', + ' container.appendChild(badge);', + ' });', + '', + // eslint-disable-next-line cspell/spellchecker + ' const summary = op.querySelector(\'.opblock-summary-path-description-wrapper\');', + ' summary.appendChild(container);', + // ' if (summary) {', + // ' const lock = summary.querySelector(\'.authorization__btn\');', + // ' if (lock) {', + // ' summary.insertBefore(container, lock);', + // ' } else {', + // ' summary.appendChild(container);', + // ' }', + // ' }', + ' } catch (e) {', + ' console.warn(\'zibri openapi role injection failed\', e);', + ' }', + ' });', + '})();' + + ].join('\n')); + } + }); + app.use(this.openApiRoute, swaggerUi.serve); await app.router.register({ httpMethod: HttpMethod.GET, @@ -140,10 +245,11 @@ export class OpenApiService implements OpenApiServiceInterface { handler: swaggerUi.setup( definition, { - // eslint-disable-next-line cspell/spellchecker + // eslint-disable-next-line cspell/spellchecker customfavIcon: `${this.assetService.assetsRoute}/favicon.png`, customSiteTitle: definition.info.title, - customCssUrl: `${this.assetService.assetsRoute}/open-api/custom.css` + customCssUrl: `${this.assetService.assetsRoute}/open-api/custom.css`, + customJs: `${this.openApiRoute}/custom.js` } ) as RouteHandler, Record, Record> }); diff --git a/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts b/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts index aa6b7ab..7c82ed5 100644 --- a/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts +++ b/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts @@ -5,11 +5,10 @@ import path from 'path'; import { afterAll, beforeAll, describe, it } from '@jest/globals'; import { PostgreSqlContainer } from '@testcontainers/postgresql'; import { StartedTestContainer } from 'testcontainers'; -import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; import { PeppolConformanceService } from './peppol-conformance.service'; import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../../../__testing__'; -import { BaseDataSource, DataSource, DataSourceOptions, MigrationEntity, Repository } from '../../../../../data-source'; +import { DataSource, MigrationEntity, Repository, PostgresDataSource, PostgresOptions } from '../../../../../data-source'; import { XML } from '../../../../../document'; import { BaseEntity } from '../../../../../entity/base-entity.model'; import { Newable, OmitStrict } from '../../../../../types'; @@ -75,9 +74,8 @@ const invoiceCalcService: InvoiceCalcService = new InvoiceCalcService(); const conformanceService: PeppolConformanceService = new PeppolConformanceService(invoicingOptions, invoiceCalcService); @DataSource() -class DbDataSource extends BaseDataSource { - options: DataSourceOptions = { - type: 'postgres', +class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -100,8 +98,8 @@ describe('generateXml', () => { .withPassword('password') .start(); dataSource = new DbDataSource(); - (dataSource.options as PostgresConnectionCredentialsOptions) = { - ...dataSource.options as PostgresConnectionCredentialsOptions, + dataSource.options = { + ...dataSource.options, port: container.getMappedPort(5432) }; await dataSource.init(); diff --git a/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts b/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts index 77cfcb4..c4953ec 100644 --- a/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts +++ b/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts @@ -5,11 +5,10 @@ import path from 'path'; import { afterAll, beforeAll, describe, it } from '@jest/globals'; import { PostgreSqlContainer } from '@testcontainers/postgresql'; import { StartedTestContainer } from 'testcontainers'; -import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; import { XRechnungConformanceService } from './x-rechnung-conformance.service'; import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../../../__testing__'; -import { BaseDataSource, DataSource, DataSourceOptions, MigrationEntity, Repository } from '../../../../../data-source'; +import { DataSource, MigrationEntity, Repository, PostgresDataSource, PostgresOptions } from '../../../../../data-source'; import { XML } from '../../../../../document'; import { BaseEntity } from '../../../../../entity/base-entity.model'; import { Newable, OmitStrict } from '../../../../../types'; @@ -75,9 +74,8 @@ const invoiceCalcService: InvoiceCalcService = new InvoiceCalcService(); const conformanceService: XRechnungConformanceService = new XRechnungConformanceService(invoicingOptions, invoiceCalcService); @DataSource() -class DbDataSource extends BaseDataSource { - options: DataSourceOptions = { - type: 'postgres', +class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -100,8 +98,8 @@ describe('generateXml', () => { .withPassword('password') .start(); dataSource = new DbDataSource(); - (dataSource.options as PostgresConnectionCredentialsOptions) = { - ...dataSource.options as PostgresConnectionCredentialsOptions, + dataSource.options = { + ...dataSource.options, port: container.getMappedPort(5432) }; await dataSource.init(); diff --git a/src/plugin/invoicing/services/invoice-number.service.test.ts b/src/plugin/invoicing/services/invoice-number.service.test.ts index fac8702..4f5b5b8 100644 --- a/src/plugin/invoicing/services/invoice-number.service.test.ts +++ b/src/plugin/invoicing/services/invoice-number.service.test.ts @@ -3,10 +3,9 @@ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; import { PostgreSqlContainer } from '@testcontainers/postgresql'; import { StartedTestContainer } from 'testcontainers'; -import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; import { InvoiceCalcService } from './invoice-calc.service'; -import { BaseDataSource, DataSource, DataSourceOptions, MigrationEntity, Repository } from '../../../data-source'; +import { DataSource, MigrationEntity, Repository, PostgresDataSource, PostgresOptions } from '../../../data-source'; import { BaseEntity } from '../../../entity/base-entity.model'; import { Newable, OmitStrict } from '../../../types'; import { Invoice, InvoiceAddress, InvoicingOptions, NumberInvoices } from '../models'; @@ -100,9 +99,8 @@ const privateCustomerData: InvoiceAddress = { const invoiceCalcService: InvoiceCalcService = new InvoiceCalcService(); @DataSource() -class DbDataSource extends BaseDataSource { - options: DataSourceOptions = { - type: 'postgres', +class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -126,8 +124,8 @@ describe('generateInvoiceNumber', () => { .withPassword('password') .start(); dataSource = new DbDataSource(); - (dataSource.options as PostgresConnectionCredentialsOptions) = { - ...dataSource.options as PostgresConnectionCredentialsOptions, + dataSource.options = { + ...dataSource.options, port: container.getMappedPort(5432) }; await dataSource.init(); diff --git a/src/plugin/invoicing/services/invoice-pdf.service.test.ts b/src/plugin/invoicing/services/invoice-pdf.service.test.ts index e943f5a..66a2292 100644 --- a/src/plugin/invoicing/services/invoice-pdf.service.test.ts +++ b/src/plugin/invoicing/services/invoice-pdf.service.test.ts @@ -5,12 +5,11 @@ import path from 'node:path'; import { afterAll, beforeAll, describe, it } from '@jest/globals'; import { PostgreSqlContainer } from '@testcontainers/postgresql'; import { StartedTestContainer } from 'testcontainers'; -import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; import { InvoiceCalcService } from './invoice-calc.service'; import { InvoicePdfService } from './invoice-pdf.service'; import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../__testing__'; -import { BaseDataSource, DataSource, DataSourceOptions, MigrationEntity, Repository } from '../../../data-source'; +import { DataSource, MigrationEntity, Repository, PostgresDataSource, PostgresOptions } from '../../../data-source'; import { PdfDocument } from '../../../document'; import { BaseEntity } from '../../../entity/base-entity.model'; import { formatDate } from '../../../localization/formatting/format-date.function'; @@ -87,9 +86,8 @@ const invoicePdfService: InvoicePdfService = new InvoicePdfService( ); @DataSource() -class DbDataSource extends BaseDataSource { - options: DataSourceOptions = { - type: 'postgres', +class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { host: 'localhost', username: 'postgres', password: 'password', @@ -112,8 +110,8 @@ describe('createInvoicePdf', () => { .withPassword('password') .start(); dataSource = new DbDataSource(); - (dataSource.options as PostgresConnectionCredentialsOptions) = { - ...dataSource.options as PostgresConnectionCredentialsOptions, + dataSource.options = { + ...dataSource.options, port: container.getMappedPort(5432) }; await dataSource.init(); diff --git a/src/utilities/validate-entities-registered.function.ts b/src/utilities/validate-entities-registered.function.ts index d124457..8788f91 100644 --- a/src/utilities/validate-entities-registered.function.ts +++ b/src/utilities/validate-entities-registered.function.ts @@ -1,4 +1,4 @@ -import { BaseDataSource } from '../data-source'; +import { DataSourceInterface } from '../data-source'; import { inject } from '../di'; import type { BaseEntity } from '../entity/base-entity.model'; import { MissingEntitiesError } from '../error-handling'; @@ -14,7 +14,7 @@ import type { Newable } from '../types'; export function validateEntitiesRegistered(context: string, ...entities: Newable[]): void { const entitiesInDataSources: Newable[] = []; for (const dataSourceClass of GlobalRegistry.dataSourceClasses) { - const dataSource: BaseDataSource = inject(dataSourceClass); + const dataSource: DataSourceInterface = inject(dataSourceClass); entitiesInDataSources.push(...dataSource.entities); } const missingEntities: Newable[] = entities.filter(e => !entitiesInDataSources.includes(e)); diff --git a/src/websocket/services/websocket-service.interface.ts b/src/websocket/services/websocket-service.interface.ts index 0a70b39..19c3da4 100644 --- a/src/websocket/services/websocket-service.interface.ts +++ b/src/websocket/services/websocket-service.interface.ts @@ -62,7 +62,7 @@ export type WebsocketSendData Date: Tue, 9 Dec 2025 23:12:58 +0100 Subject: [PATCH 2/2] fixed test and linting --- src/data-source/data-sources/postgres-data-source.model.ts | 3 ++- src/data-source/migration/migration.test.ts | 1 - src/http-client/http-client-response.model.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/data-source/data-sources/postgres-data-source.model.ts b/src/data-source/data-sources/postgres-data-source.model.ts index a32d443..e4aa01e 100644 --- a/src/data-source/data-sources/postgres-data-source.model.ts +++ b/src/data-source/data-sources/postgres-data-source.model.ts @@ -32,7 +32,7 @@ type ToColumnMappableTypes = ExcludeStrict; @@ -368,6 +368,7 @@ export abstract class PostgresDataSource implements DataSourceInterface { // eslint-disable-next-line jsdoc/require-jsdoc getRepository(cls: Newable): Repository { if (!this.ds) { + // eslint-disable-next-line sonar/no-duplicate-string throw new Error('The postgres data source needs to be initialized before it can be used.'); } if (!this.entities.find(e => e === cls)) { diff --git a/src/data-source/migration/migration.test.ts b/src/data-source/migration/migration.test.ts index d3dfc12..e080031 100644 --- a/src/data-source/migration/migration.test.ts +++ b/src/data-source/migration/migration.test.ts @@ -68,7 +68,6 @@ class AddTestValueMigration extends Migration { override async up(transaction: Transaction): Promise { await this.dataSource.addPropertyToEntity(Item, 'value', transaction); - await this.dataSource.changePropertyOfEntity(Item, 'oldValue', { type: 'string', name: 'value' }, transaction); const existingItems: Item[] = await this.itemRepository.findAll({ transaction }); await Promise.all(existingItems.map(t => this.itemRepository.updateById(t.id, { value: '42' }, { transaction }))); } diff --git a/src/http-client/http-client-response.model.ts b/src/http-client/http-client-response.model.ts index 2baf415..4b6e840 100644 --- a/src/http-client/http-client-response.model.ts +++ b/src/http-client/http-client-response.model.ts @@ -10,11 +10,11 @@ export type HttpClientResponse< HeaderParamsObject extends Record = Partial> > = { /** - * + * The http status of the response. */ status: HttpStatus, /** - * + * The http status text of the response. */ statusText: string, /**