diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..831a77b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [heusalagroup] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b787d6a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: Run Tests + +on: + push: + workflow_dispatch: + +jobs: + fetch-and-test: + runs-on: self-hosted + timeout-minutes: 30 + steps: + - uses: actions/checkout@v3 + with: + repository: heusalagroup/test + submodules: recursive + - name: set submodule branch + run: | + repo="${{ github.repository }}"; repo="${repo//./\/}"; repo="${repo/heusalagroup/}"; cd src$repo; + git checkout ${{ github.ref_name }} + - name: run tests + run: NODE_ENV="dev" npm run test:ci diff --git a/.gitignore b/.gitignore index c6bba59..3fd294c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* -.pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json @@ -42,8 +41,8 @@ build/Release node_modules/ jspm_packages/ -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ +# TypeScript v1 declaration files +typings/ # TypeScript cache *.tsbuildinfo @@ -54,9 +53,6 @@ web_modules/ # Optional eslint cache .eslintcache -# Optional stylelint cache -.stylelintcache - # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ @@ -72,20 +68,15 @@ web_modules/ # Yarn Integrity file .yarn-integrity -# dotenv environment variable files +# dotenv environment variables file .env -.env.development.local -.env.test.local -.env.production.local -.env.local +.env.test # parcel-bundler cache (https://parceljs.org/) .cache -.parcel-cache # Next.js build output .next -out # Nuxt.js build / generate output .nuxt @@ -93,20 +84,13 @@ dist # Gatsby files .cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js +# Comment in the public line in if your project uses Gatsby and *not* Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - # Serverless directories .serverless/ @@ -119,12 +103,4 @@ dist # TernJS port file .tern-port -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +.idea diff --git a/AsyncLock.test.ts b/AsyncLock.test.ts new file mode 100644 index 0000000..1a05fa5 --- /dev/null +++ b/AsyncLock.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createAsyncLock } from "./AsyncLock"; + +describe('AsyncLock', () => { + + describe('createAsyncLock', () => { + + it('should create an instance of AsyncLock', () => { + const asyncLock = createAsyncLock(); + expect(typeof asyncLock).toBe('object'); + }); + + it('should create a new instance each time it is called', () => { + const asyncLock1 = createAsyncLock(); + const asyncLock2 = createAsyncLock(); + expect(asyncLock1).not.toBe(asyncLock2); + }); + + it('should create an empty object', () => { + const asyncLock = createAsyncLock(); + expect(Object.keys(asyncLock).length).toBe(0); + }); + + }); + +}); diff --git a/AsyncLock.ts b/AsyncLock.ts new file mode 100644 index 0000000..bc3a44b --- /dev/null +++ b/AsyncLock.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Empty object used as a lock object. + * + * E.g. the internal memory reference is the ID of the lock. + */ +export interface AsyncLock { +} + +/** + * Creates a new `AsyncLock` instance. + */ +export function createAsyncLock () : AsyncLock { + return {}; +} diff --git a/AsyncSynchronizer.ts b/AsyncSynchronizer.ts new file mode 100644 index 0000000..c8d6e37 --- /dev/null +++ b/AsyncSynchronizer.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Service which synchronizes asynchronous operations + */ +export interface AsyncSynchronizer { + + /** + * Calls the provided callback and returns the result. + * + * If another call happens before the previous finishes it will wait for + * the previous operation to finish before executing another asynchronous + * callback. + * + * @param callback A async function which returns a promise + */ + run (callback: () => Promise) : Promise; + +} diff --git a/AsyncSynchronizerImpl.test.ts b/AsyncSynchronizerImpl.test.ts new file mode 100644 index 0000000..713e943 --- /dev/null +++ b/AsyncSynchronizerImpl.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { AsyncSynchronizerImpl } from "./AsyncSynchronizerImpl"; +import { LogLevel } from "./types/LogLevel"; + +describe('AsyncSynchronizerImpl', () => { + + let asyncSynchronizer: AsyncSynchronizerImpl; + + beforeAll(() => { + AsyncSynchronizerImpl.setLogLevel(LogLevel.NONE); + }); + + beforeEach(() => { + asyncSynchronizer = AsyncSynchronizerImpl.create(); + }); + + describe('create', () => { + it('should create an instance of AsyncSynchronizerImpl', () => { + expect(asyncSynchronizer).toBeInstanceOf(AsyncSynchronizerImpl); + }); + }); + + describe('run', () => { + + it('should return the result of the callback', async () => { + const expectedResult : string = 'Test'; + const callback = jest.fn<() => Promise>().mockResolvedValue(expectedResult); + + const result = await asyncSynchronizer.run(callback); + + expect(result).toBe(expectedResult); + expect(callback).toHaveBeenCalled(); + }); + + it('should execute the callbacks in order', async () => { + let callbackOrder: number[] = []; + const callback1 = jest.fn(() => new Promise(resolve => setTimeout(() => resolve(callbackOrder.push(1)), 200))); + const callback2 = jest.fn(() => new Promise(resolve => setTimeout(() => resolve(callbackOrder.push(2)), 100))); + asyncSynchronizer.run(callback1); + asyncSynchronizer.run(callback2); + await new Promise(resolve => setTimeout(resolve, 400)); + expect(callbackOrder).toEqual([1, 2]); + }); + + }); + +}); diff --git a/AsyncSynchronizerImpl.ts b/AsyncSynchronizerImpl.ts new file mode 100644 index 0000000..01a0adf --- /dev/null +++ b/AsyncSynchronizerImpl.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { first } from "./functions/first"; +import { LogService } from "./LogService"; +import { AsyncSynchronizer } from "./AsyncSynchronizer"; +import { AsyncLock, createAsyncLock } from "./AsyncLock"; +import { LogLevel } from "./types/LogLevel"; + +const LOG = LogService.createLogger( 'AsyncSynchronizerImpl' ); + +/** + * @inheritDoc + */ +export class AsyncSynchronizerImpl implements AsyncSynchronizer { + + private readonly _queue : AsyncLock[]; + private _waitLockRelease : Promise | undefined; + private _releaseLockQueue : (() => void) | undefined; + + protected constructor () { + this._queue = []; + this._waitLockRelease = undefined; + this._releaseLockQueue = undefined; + } + + public static setLogLevel (level : LogLevel) : void { + LOG.setLogLevel(level); + } + + /** + * Create instance of the AsyncSynchronizer + */ + public static create () : AsyncSynchronizerImpl { + return new AsyncSynchronizerImpl(); + } + + /** + * @inheritDoc + */ + public async run (callback: () => Promise) : Promise { + + // Create a lock for this request + const lock : AsyncLock = createAsyncLock(); + LOG.debug(`Created a lock. This lock queue has ${this._queue.length} locks.`); + + // Put this request to the queue + this._queue.push(lock); + LOG.debug(`Added our lock to the queue`); + + // Wait for a lock + LOG.debug(`Waiting for us to be the first in the queue`); + while ( this._queue.length && first(this._queue) !== lock ) { + + LOG.debug(`The queue did not have us, waiting ${this._queue.length} locks...`); + + if (this._waitLockRelease === undefined) { + let release : boolean = false; + if (this._releaseLockQueue !== undefined) { + this._releaseLockQueue(); + this._releaseLockQueue = () => { + release = true; + }; + } + this._waitLockRelease = new Promise( (resolve) => { + if (release) { + this._releaseLockQueue = undefined; + resolve(); + } else { + this._releaseLockQueue = resolve; + } + }); + } + + await this._waitLockRelease; + + } + + if (first(this._queue) !== lock) { + throw new TypeError(`Could not acquire lock to the queue`); + } + + LOG.debug(`Lock acquired to the queue and calling the callback`); + let result : T; + try { + result = await callback(); + } finally { + // Release the lock + if ( first(this._queue) !== lock ) { + LOG.warn(`Warning! The lock queue did not have us as the first item. Could not release the lock.`); + } else { + this._queue.shift(); + LOG.debug(`Released the lock from queue`); + } + + if (this._releaseLockQueue !== undefined) { + this._releaseLockQueue(); + this._releaseLockQueue = undefined; + LOG.debug(`Released the lock queue for next lock processing`); + } + + } + return result; + + } + +} diff --git a/AuthorizationClientService.ts b/AuthorizationClientService.ts new file mode 100644 index 0000000..66f00eb --- /dev/null +++ b/AuthorizationClientService.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestClientImpl } from "./RequestClientImpl"; +import { LogService } from "./LogService"; +import { RequestError } from "./request/types/RequestError"; +import { RequestStatus } from "./request/types/RequestStatus"; +import { AuthorizationUtils } from "./AuthorizationUtils"; +import { isString } from "./types/String"; + +const LOG = LogService.createLogger('AuthorizationClientService'); + +export interface AuthorizationResultDTO { + email : string; +} + +export function isAuthorizationResultDTO (value : any) : value is AuthorizationResultDTO { + return ( + !!value && isString(value?.email) && !!value?.email + ); +} + +/** + * Experimental service. Not recommended to use. May change later. + */ +export class AuthorizationClientService { + + private readonly _serviceUrl : string; + + public constructor(serviceUrl : string) { + this._serviceUrl = serviceUrl; + } + + public async verifySessionJwt (token: string) : Promise { + + try { + + const result = await RequestClientImpl.postJson(`${this._serviceUrl}/verify`, { + token + }); + + if (!isAuthorizationResultDTO(result)) { + LOG.debug('verifyJwt: result not AuthorizationResultDTO: ', result); + return undefined; + } + + LOG.debug('verifyJwt: result: ', result); + + return { + email : result.email + }; + + } catch (err) { + LOG.error('verifyJwt: error: ', err); + return undefined; + } + + } + + public static async verifySessionAuthorizationHeader ( + authService : AuthorizationClientService, + header : string + ) : Promise { + + const jwt : string | undefined = AuthorizationUtils.parseBearerToken(header); + + if (!jwt) { + LOG.debug('verifySessionAuthorizationHeader: Unsupported header value: ', header); + throw new RequestError(RequestStatus.Forbidden, 'Forbidden'); + } + + LOG.debug('verifyAuthorizationHeader: jwt: ', jwt); + + const result : AuthorizationResultDTO | undefined = await authService.verifySessionJwt(jwt); + + if (!result) { + LOG.debug('verifyAuthorizationHeader: Jwt is not valid: ', jwt); + throw new RequestError(RequestStatus.Forbidden, 'Forbidden'); + } + + LOG.debug('verifyAuthorizationHeader: Jwt verified successfully: ', jwt); + + return { + email: result.email + }; + + } + +} + diff --git a/AuthorizationUtils.test.ts b/AuthorizationUtils.test.ts new file mode 100644 index 0000000..7abbbbd --- /dev/null +++ b/AuthorizationUtils.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { AuthorizationUtils } from "./AuthorizationUtils"; + +describe('AuthorizationUtils', () => { + + describe('createBasicHeader', () => { + it('should create a Basic header from the given token', () => { + const token = 'test_token'; + const expectedHeader = 'Basic test_token'; + const header = AuthorizationUtils.createBasicHeader(token); + + expect(header).toEqual(expectedHeader); + }); + }); + + describe('createBasicHeaderWithUserAndPassword', () => { + + it('should create a Basic header with Base64-encoded username and password', () => { + const username = 'test_user'; + const password = 'test_password'; + const expectedHeader = `Basic dGVzdF91c2VyOnRlc3RfcGFzc3dvcmQ=`; + const header = AuthorizationUtils.createBasicHeaderWithUserAndPassword(username, password); + + expect(header).toEqual(expectedHeader); + }); + + it('should create a Basic header with Base64-encoded username and password with special characters', () => { + const username = 'test user %123$!^ääå,.-/|\&%€#"\'!`¨*;:_,.-<>§°@'; + const password = 'test password %123$!^ääå,.-/|\&%€#"\'!`¨*;:_,.-<>§°@'; + const expectedHeader = `Basic dGVzdCB1c2VyICUxMjMkIV7DpMOkw6UsLi0vfCYl4oKsIyInIWDCqCo7Ol8sLi08PsKnwrBAOnRlc3QgcGFzc3dvcmQgJTEyMyQhXsOkw6TDpSwuLS98JiXigqwjIichYMKoKjs6XywuLTw+wqfCsEA=`; + const header = AuthorizationUtils.createBasicHeaderWithUserAndPassword(username, password); + + expect(header).toEqual(expectedHeader); + }); + + }); + + describe('parseBasicToken', () => { + + it('should return the token from the Basic header', () => { + const token = 'dGVzdCB1c2VyICUxMjMkIV7k5OU6dGVzdCB1c2VyICUxMjMkIV7k5A=='; + const header = `Basic ${token}`; + const parsedToken = AuthorizationUtils.parseBasicToken(header); + + expect(parsedToken).toEqual(token); + }); + + it('should return undefined if the header does not start with "Basic "', () => { + const header = 'Bearer dGVzdCB1c2VyICUxMjMkIV7k5OU6dGVzdCB1c2VyICUxMjMkIV7k5A=='; + const parsedToken = AuthorizationUtils.parseBasicToken(header); + + expect(parsedToken).toBeUndefined(); + }); + + }); + + describe('createBearerHeader', () => { + it('should create a Bearer header from the given token', () => { + const token = 'dGVzdCB1c2VyICUxMjMkIV7k5OU6dGVzdCB1c2VyICUxMjMkIV7k5A=='; + const expectedHeader = 'Bearer dGVzdCB1c2VyICUxMjMkIV7k5OU6dGVzdCB1c2VyICUxMjMkIV7k5A=='; + const header = AuthorizationUtils.createBearerHeader(token); + + expect(header).toEqual(expectedHeader); + }); + }); + + describe('parseBearerToken', () => { + + it('should return the token from the Bearer header', () => { + const token = 'test_token'; + const header = `Bearer ${token}`; + const parsedToken = AuthorizationUtils.parseBearerToken(header); + + expect(parsedToken).toEqual(token); + }); + + it('should return undefined if the header does not start with "Bearer "', () => { + const header = 'Basic test_token'; + const parsedToken = AuthorizationUtils.parseBearerToken(header); + + expect(parsedToken).toBeUndefined(); + }); + + }); + +}); diff --git a/AuthorizationUtils.ts b/AuthorizationUtils.ts new file mode 100644 index 0000000..5c58972 --- /dev/null +++ b/AuthorizationUtils.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2022-2023. Heusala Group. All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +import { startsWith } from "./functions/startsWith"; +import { trim } from "./functions/trim"; + +export class AuthorizationUtils { + + public static createBasicHeader (token: string) : string { + return `Basic ${token}`; + } + + public static createBasicHeaderTokenWithUserAndPassword (username: string, password: string) : string { + return btoa(unescape(`${encodeURIComponent(username)}:${encodeURIComponent(password)}`)); + } + + public static createBasicHeaderWithUserAndPassword (username: string, password: string) : string { + return AuthorizationUtils.createBasicHeader(AuthorizationUtils.createBasicHeaderTokenWithUserAndPassword(username, password)); + } + + public static parseBasicToken (header : string) : string | undefined { + const BasicPrefix = 'Basic '; + if (!startsWith(header, BasicPrefix)) { + return undefined; + } + return trim(header.substring(BasicPrefix.length)); + } + + public static createBearerHeader (token: string) : string { + return `Bearer ${token}`; + } + + public static parseBearerToken (header : string) : string | undefined { + const BearerPrefix = 'Bearer '; + if (!startsWith(header, BearerPrefix)) { + return undefined; + } + return trim(header.substring(BearerPrefix.length)); + } + +} diff --git a/CacheService.ts b/CacheService.ts new file mode 100644 index 0000000..9b927e0 --- /dev/null +++ b/CacheService.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { reduce } from "./functions/reduce"; + +export interface CacheClearCallback { + () : Promise | void; +} + +/** + * This service is used to clear any internal caches. + * + * It is implemented mainly for the ReactJS SSR server so that requests can clear internal state between requests. + */ +export class CacheService { + + private static _clearCallbacks : CacheClearCallback[] = []; + + public static registerClearCallback (callback: CacheClearCallback) { + CacheService._clearCallbacks.push(callback); + } + + /** + * Clear caches + */ + public static async clearCaches () { + await reduce( + CacheService._clearCallbacks, + async (p: Promise, callback: CacheClearCallback) : Promise => { + await p; + await callback(); + }, + Promise.resolve() + ); + } + +} + + diff --git a/CalendarService.ts b/CalendarService.ts new file mode 100644 index 0000000..978aa2c --- /dev/null +++ b/CalendarService.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { CalendarDTO } from "./types/CalendarDTO"; +import { HttpService } from "./HttpService"; +import { LogService } from "./LogService"; +import { CalendarUtils } from "./CalendarUtils"; +import { ContentType } from "./request/types/ContentType"; + +const LOG = LogService.createLogger('CalendarService'); + +export class CalendarService { + + public static async fetchFromUrl (url : string) : Promise { + const responseString : string | undefined = await HttpService.getText( + url, + { + 'Accept': ContentType.CALENDAR + } + ); + if (!responseString) { + LOG.error(`Response was not calendar data: `, responseString); + throw new TypeError(`CalendarService.fetchFromUrl: Response was not calendar data`); + } + return CalendarUtils.parseCalendarDTOFromInternetCalendar(responseString); + } + +} diff --git a/CalendarUtils.ts b/CalendarUtils.ts new file mode 100644 index 0000000..4f7e25b --- /dev/null +++ b/CalendarUtils.ts @@ -0,0 +1,442 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { CalendarDTO, createCalendarDTO } from "./types/CalendarDTO"; +import { endsWith } from "./functions/endsWith"; +import { filter } from "./functions/filter"; +import { find } from "./functions/find"; +import { map } from "./functions/map"; +import { reduce } from "./functions/reduce"; +import { split } from "./functions/split"; +import { LogService } from "./LogService"; +import { createInternetCalendarLine, InternetCalendarLine, isInternetCalendarLine } from "./types/InternetCalendarLine"; +import { createInternetCalendarParam, InternetCalendarParam } from "./types/InternetCalendarParam"; +import { CalendarEvent, createCalendarEvent } from "./types/CalendarEvent"; +import { momentTz, parseUtc } from "./modules/moment"; +import { isArray } from "./types/Array"; + +const LOG = LogService.createLogger('CalendarUtils'); + +export type ReadonlyInternetCalendarLineList = readonly InternetCalendarLine[]; +export type ReadonlyInternetCalendarLineBlockList = readonly (InternetCalendarLine | ReadonlyInternetCalendarLineList)[]; + +export type InternetCalendarLineList = InternetCalendarLine[]; +export type InternetCalendarLineBlockList = (InternetCalendarLine | InternetCalendarLineList)[]; + +/** + * See also `TimeService` + */ +export class CalendarUtils { + + /** + * + * @param line + */ + public static parseInternetCalendarLine (line : string) : InternetCalendarLine { + + const index = line.indexOf(':'); + if (index < 0) { + throw new TypeError(`CalendarUtils.parseInternetCalendarLine: No name found`) + } + + const nameParams = line.substring(0, index); + const value = line.substring(index+1); + + const paramIndex = nameParams.indexOf(';'); + + if (paramIndex < 0) { + return createInternetCalendarLine( + nameParams, + value + ); + } else { + const parts : string[] = split(nameParams, ';'); + const name : string = parts.shift() ?? ''; + return createInternetCalendarLine( + name, + value, + map(parts, (item : string) : InternetCalendarParam => { + const itemParts = split(item, '='); + const paramName : string = itemParts.shift() ?? ''; + const paramValue : string = itemParts.join('='); + return createInternetCalendarParam(paramName, paramValue); + }) + ); + } + + } + + /** + * Parses raw Internet Calendar (RFC 5545) text lines. + * + * Lines split on multiple lines will be unfold as one. + * + * @fixme From the RFC: "It is possible for very simple implementations to generate + * improperly folded lines in the middle of a UTF-8 multi-octet + * sequence. For this reason, implementations need to unfold lines + * in such a way to properly restore the original sequence." + * + * @param rows + */ + public static unfoldInternetCalendarLines (rows : readonly string[]) : readonly string[] { + + return reduce( + rows, + (list: string[], row: string) : string[] => { + + if (row.length) { + + // // Remove possible CR + // if ( row[row.length-1] === '\r' ) { + // row = row.substring(0, row.length - 1); + // } + + // Detect split lines + if (/^\s/.test(row)) { + if (list.length) { + list[list.length - 1] += row.substring(1); + } else { + throw new TypeError(`Unexpected leading white space at: ` + row); + } + } else { + list.push(row); + } + + } else { + LOG.warn(`Warning: Empty line parsed at parseInternetCalendarRows().`); + } + + return list; + }, + [] + ) as readonly string[]; + + } + + public static groupInternetCalendarLines (value : ReadonlyInternetCalendarLineList) : ReadonlyInternetCalendarLineBlockList { + + const stack : InternetCalendarLineBlockList[] = []; + + return reduce( + value, + (list: InternetCalendarLineBlockList, item: InternetCalendarLine) : InternetCalendarLineBlockList => { + + const itemName = item.name; + + if (itemName === 'BEGIN') { + + const block = [ + item + ]; + stack.push(block); + list.push(block); + + LOG.debug(`Started block "${item.value}"`); + return list; + } + + if (itemName === 'END') { + + const blockName = item.value; + + if (stack.length === 0) { + + const block = [ + item + ]; + list.push(block); + + LOG.warn(`Warning! Ended block "${blockName}" without previous START`); + return list; + + } + + const lastBlock : InternetCalendarLineBlockList = stack[stack.length - 1]; + const lastBegin : InternetCalendarLine | InternetCalendarLineList | undefined = lastBlock.length ? lastBlock[0] : undefined; + if (!isInternetCalendarLine(lastBegin)) { + throw new TypeError(`CalendarUtils.groupInternetCalendarLines: Could not detect BEGIN for END (${blockName})`); + } + if (lastBegin.value !== blockName) { + throw new TypeError(`CalendarUtils.groupInternetCalendarLines: Found wrong block for END (${blockName})`); + } + + lastBlock.push(item); + stack.pop(); + + LOG.debug(`Ended block "${blockName}"`); + return list; + + } + + if (stack.length >= 1) { + stack[stack.length - 1].push(item); + return list; + } + + list.push(item); + return list; + + }, + [] + ) as ReadonlyInternetCalendarLineBlockList; + } + + public static parseCalendarDTOFromInternetCalendar (value : string) : CalendarDTO { + + const foldRows = split(value, /\r?\n/); + + const unfoldRows = CalendarUtils.unfoldInternetCalendarLines(foldRows); + + const parsedRows : InternetCalendarLine[] = map(unfoldRows, (item : string) : InternetCalendarLine => CalendarUtils.parseInternetCalendarLine(item)) + + const grouped : ReadonlyInternetCalendarLineBlockList = CalendarUtils.groupInternetCalendarLines(parsedRows); + + // LOG.debug(`grouped = `, grouped); + + const eventBlocks : ReadonlyInternetCalendarLineList[] = filter(grouped, (item : InternetCalendarLine | ReadonlyInternetCalendarLineList) : boolean => { + if (isArray(item) && item.length) { + const itemType = item[0].value; + return itemType === 'VEVENT'; + } else { + return false; + } + }) as ReadonlyInternetCalendarLineList[]; + + const events : CalendarEvent[] = map( + eventBlocks, + (item: ReadonlyInternetCalendarLineList) : CalendarEvent => { + return CalendarUtils.parseCalendarEventFromInternetCalendarLines(item); + } + ); + + return createCalendarDTO(events); + + } + + public static parseCalendarEventFromInternetCalendarLines (list : ReadonlyInternetCalendarLineList) : CalendarEvent { + + // LOG.debug(`parseCalendarEventFromInternetCalendarLines: `, list); + + const start : string = CalendarUtils._findCalendarLineAsTime('DTSTART' , list) ?? ''; + const end : string = CalendarUtils._findCalendarLineAsTime('DTEND' , list) ?? ''; + const repeatRule : string = CalendarUtils._findCalendarLine('RRULE' , list)?.value ?? ''; + const stamp : string = CalendarUtils._findCalendarLineAsTime('DTSTAMP' , list) ?? ''; + const uid : string = CalendarUtils._findCalendarLine('UID' , list)?.value ?? ''; + const created : string = CalendarUtils._findCalendarLineAsTime('CREATED' , list) ?? ''; + const description : string = CalendarUtils._findCalendarLine('DESCRIPTION' , list)?.value ?? ''; + const lastModified : string = CalendarUtils._findCalendarLineAsTime('LAST-MODIFIED' , list) ?? ''; + const location : string = CalendarUtils._findCalendarLine('LOCATION' , list)?.value ?? ''; + const sequence : string = CalendarUtils._findCalendarLine('SEQUENCE' , list)?.value ?? ''; + const status : string = CalendarUtils._findCalendarLine('STATUS' , list)?.value ?? ''; + const summary : string = CalendarUtils._findCalendarLine('SUMMARY' , list)?.value ?? ''; + const transparency : string = CalendarUtils._findCalendarLine('TRANSP' , list)?.value ?? ''; + + // FIXME: Print any keys which were not parsed as a warning line + + const event = createCalendarEvent( + start, + end, + repeatRule, + stamp, + uid, + created, + description, + lastModified, + location, + sequence, + status, + summary, + transparency + ); + + // LOG.debug(`parseCalendarEventFromInternetCalendarLines: event = `, event); + + return event; + + } + + private static _findCalendarLine (name: string, list : ReadonlyInternetCalendarLineList) : InternetCalendarLine | undefined { + return find( + list, + (item : InternetCalendarLine) : boolean => item.name === name + ); + } + + private static _findCalendarLineAsTime (name: string, list : ReadonlyInternetCalendarLineList) : string | undefined { + + const item : InternetCalendarLine | undefined = CalendarUtils._findCalendarLine(name, list); + + if (item) { + + const valueLine : InternetCalendarParam | undefined = find( + item.params, + (param: InternetCalendarParam) : boolean => param.name === 'VALUE' + ); + + const tzId : InternetCalendarParam | undefined = find( + item.params, + (param: InternetCalendarParam) : boolean => param.name === 'TZID' + ); + + if (tzId) { + const tzValue = CalendarUtils.parseWindowsTimeZoneToIANA(tzId.value) ?? tzId.value; + return momentTz(item.value, tzValue).toISOString(); + } + + if (endsWith(item.value, 'Z')) { + return parseUtc(item.value).toISOString(); + } + + if (valueLine && valueLine.value === 'DATE') { + return parseUtc(item.value).toISOString(); + } + + LOG.debug(`Unknown item format: "${item.value}": `, item); + return item.value; + + } else { + return undefined; + } + + } + + public static parseWindowsTimeZoneToIANA (value : string) : string | undefined { + switch(`${value}`.toLowerCase()) { + case "dateline standard time": return "Etc/GMT+12"; + case "utc-11": return "Etc/GMT+11"; + case "aleutian standard time": return "America/Adak"; + case "hawaiian standard time": return "Pacific/Honolulu"; + case "marquesas standard time": return "Pacific/Marquesas"; + case "alaskan standard time": return "America/Anchorage"; + case "utc-09": return "Etc/GMT+9"; + case "pacific standard time (mexico)": return "America/Tijuana"; + case "utc-08": return "Etc/GMT+8"; + case "pacific standard time": return "America/Los_Angeles"; + case "us mountain standard time": return "America/Phoenix"; + case "mountain standard time (mexico)": return "America/Chihuahua"; + case "mountain standard time": return "America/Denver"; + case "central america standard time": return "America/Guatemala"; + case "central standard time": return "America/Chicago"; + case "easter island standard time": return "Pacific/Easter"; + case "central standard time (mexico)": return "America/Mexico_City"; + case "canada central standard time": return "America/Regina"; + case "sa pacific standard time": return "America/Bogota"; + case "eastern standard time (mexico)": return "America/Cancun"; + case "eastern standard time": return "America/New_York"; + case "haiti standard time": return "America/Port-au-Prince"; + case "cuba standard time": return "America/Havana"; + case "us eastern standard time": return "America/Indianapolis"; + case "paraguay standard time": return "America/Asuncion"; + case "atlantic standard time": return "America/Halifax"; + case "venezuela standard time": return "America/Caracas"; + case "central brazilian standard time": return "America/Cuiaba"; + case "sa western standard time": return "America/La_Paz"; + case "pacific sa standard time": return "America/Santiago"; + case "turks and caicos standard time": return "America/Grand_Turk"; + case "newfoundland standard time": return "America/St_Johns"; + case "tocantins standard time": return "America/Araguaina"; + case "e. south america standard time": return "America/Sao_Paulo"; + case "sa eastern standard time": return "America/Cayenne"; + case "argentina standard time": return "America/Buenos_Aires"; + case "greenland standard time": return "America/Godthab"; + case "montevideo standard time": return "America/Montevideo"; + case "magallanes standard time": return "America/Punta_Arenas"; + case "saint pierre standard time": return "America/Miquelon"; + case "bahia standard time": return "America/Bahia"; + case "utc-02": return "Etc/GMT+2"; + case "azores standard time": return "Atlantic/Azores"; + case "cape verde standard time": return "Atlantic/Cape_Verde"; + case "utc": return "Etc/GMT"; + case "morocco standard time": return "Africa/Casablanca"; + case "gmt standard time": return "Europe/London"; + case "greenwich standard time": return "Atlantic/Reykjavik"; + case "w. europe standard time": return "Europe/Berlin"; + case "central europe standard time": return "Europe/Budapest"; + case "romance standard time": return "Europe/Paris"; + case "central european standard time": return "Europe/Warsaw"; + case "w. central africa standard time": return "Africa/Lagos"; + case "jordan standard time": return "Asia/Amman"; + case "gtb standard time": return "Europe/Bucharest"; + case "middle east standard time": return "Asia/Beirut"; + case "egypt standard time": return "Africa/Cairo"; + case "e. europe standard time": return "Europe/Chisinau"; + case "syria standard time": return "Asia/Damascus"; + case "west bank standard time": return "Asia/Hebron"; + case "south africa standard time": return "Africa/Johannesburg"; + case "fle standard time": return "Europe/Kiev"; + case "israel standard time": return "Asia/Jerusalem"; + case "kaliningrad standard time": return "Europe/Kaliningrad"; + case "sudan standard time": return "Africa/Khartoum"; + case "libya standard time": return "Africa/Tripoli"; + case "namibia standard time": return "Africa/Windhoek"; + case "arabic standard time": return "Asia/Baghdad"; + case "turkey standard time": return "Europe/Istanbul"; + case "arab standard time": return "Asia/Riyadh"; + case "belarus standard time": return "Europe/Minsk"; + case "russian standard time": return "Europe/Moscow"; + case "e. africa standard time": return "Africa/Nairobi"; + case "iran standard time": return "Asia/Tehran"; + case "arabian standard time": return "Asia/Dubai"; + case "astrakhan standard time": return "Europe/Astrakhan"; + case "azerbaijan standard time": return "Asia/Baku"; + case "russia time zone 3": return "Europe/Samara"; + case "mauritius standard time": return "Indian/Mauritius"; + case "saratov standard time": return "Europe/Saratov"; + case "georgian standard time": return "Asia/Tbilisi"; + case "caucasus standard time": return "Asia/Yerevan"; + case "afghanistan standard time": return "Asia/Kabul"; + case "west asia standard time": return "Asia/Tashkent"; + case "ekaterinburg standard time": return "Asia/Yekaterinburg"; + case "pakistan standard time": return "Asia/Karachi"; + case "india standard time": return "Asia/Calcutta"; + case "sri lanka standard time": return "Asia/Colombo"; + case "nepal standard time": return "Asia/Katmandu"; + case "central asia standard time": return "Asia/Almaty"; + case "bangladesh standard time": return "Asia/Dhaka"; + case "omsk standard time": return "Asia/Omsk"; + case "myanmar standard time": return "Asia/Rangoon"; + case "se asia standard time": return "Asia/Bangkok"; + case "altai standard time": return "Asia/Barnaul"; + case "w. mongolia standard time": return "Asia/Hovd"; + case "north asia standard time": return "Asia/Krasnoyarsk"; + case "n. central asia standard time": return "Asia/Novosibirsk"; + case "tomsk standard time": return "Asia/Tomsk"; + case "china standard time": return "Asia/Shanghai"; + case "north asia east standard time": return "Asia/Irkutsk"; + case "singapore standard time": return "Asia/Singapore"; + case "w. australia standard time": return "Australia/Perth"; + case "taipei standard time": return "Asia/Taipei"; + case "ulaanbaatar standard time": return "Asia/Ulaanbaatar"; + case "aus central w. standard time": return "Australia/Eucla"; + case "transbaikal standard time": return "Asia/Chita"; + case "tokyo standard time": return "Asia/Tokyo"; + case "north korea standard time": return "Asia/Pyongyang"; + case "korea standard time": return "Asia/Seoul"; + case "yakutsk standard time": return "Asia/Yakutsk"; + case "cen. australia standard time": return "Australia/Adelaide"; + case "aus central standard time": return "Australia/Darwin"; + case "e. australia standard time": return "Australia/Brisbane"; + case "aus eastern standard time": return "Australia/Sydney"; + case "west pacific standard time": return "Pacific/Port_Moresby"; + case "tasmania standard time": return "Australia/Hobart"; + case "vladivostok standard time": return "Asia/Vladivostok"; + case "lord howe standard time": return "Australia/Lord_Howe"; + case "bougainville standard time": return "Pacific/Bougainville"; + case "russia time zone 10": return "Asia/Srednekolymsk"; + case "magadan standard time": return "Asia/Magadan"; + case "norfolk standard time": return "Pacific/Norfolk"; + case "sakhalin standard time": return "Asia/Sakhalin"; + case "central pacific standard time": return "Pacific/Guadalcanal"; + case "russia time zone 11": return "Asia/Kamchatka"; + case "new zealand standard time": return "Pacific/Auckland"; + case "utc+12": return "Etc/GMT-12"; + case "fiji standard time": return "Pacific/Fiji"; + case "chatham islands standard time": return "Pacific/Chatham"; + case "utc+13": return "Etc/GMT-13"; + case "tonga standard time": return "Pacific/Tongatapu"; + case "samoa standard time": return "Pacific/Apia"; + case "line islands standard time": return "Pacific/Kiritimati"; + } + return undefined; + } + +} diff --git a/ChildProcessService.ts b/ChildProcessService.ts new file mode 100644 index 0000000..ad2be8b --- /dev/null +++ b/ChildProcessService.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Disposable } from "./types/Disposable"; +import { DisposeAware } from "./types/DisposeAware"; + +export interface CommandEnvironment { + readonly [key: string]: string; +} + +export interface CommandOptions { + readonly cwd ?: string; + readonly env ?: CommandEnvironment; + readonly argv0 ?: string; + readonly serialization ?: string; + readonly timeout ?: number; + readonly uid ?: number; + readonly gid ?: number; + readonly killSignal ?: string | number; + readonly maxBuffer ?: number; + readonly stdio ?: string | readonly string[]; + readonly detached ?: boolean; +} + +export interface CommandResponse { + readonly name : string; + readonly args : readonly string[]; + readonly output : string; + readonly errors ?: string; +} + +/** + * Interface for running child processes in a system. + * + * The system may be a NodeJS environment or later some external backend + * through an HTTP API. E.g. there could be a frontend client implementation as + * well. + * + * @see {@link NodeChildProcessService} + */ +export interface ChildProcessService extends Disposable, DisposeAware { + + /** + * Destroy the service and free any resources. Do not use the service + * again after you have called this method. + */ + destroy () : void; + + /** + * Returns true if the service has been destroyed + */ + isDestroyed () : boolean; + + /** + * Returns the amount of children running + */ + countChildProcesses () : Promise; + + /** + * Wait until all the started children have stopped + */ + waitAllChildProcessesStopped () : Promise; + + /** + * Close any child processes running + */ + shutdownChildProcesses () : Promise; + + /** + * Starts a new child process to run a command. + * + * @param name + * @param args + * @param opts + */ + executeCommand ( + name : string, + args ?: readonly string[], + opts ?: CommandOptions + ) : Promise; + + /** + * + */ + sendShutdownToChildProcesses () : void; + +} diff --git a/CountryUtils.test.ts b/CountryUtils.test.ts new file mode 100644 index 0000000..092ba61 --- /dev/null +++ b/CountryUtils.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CountryUtils } from "./CountryUtils"; +import { CountryCode } from "./types/CountryCode"; +import { isArray } from "./types/Array"; + +describe('CountryUtils', () => { + + const mockTranslationFunction = (key: string) => `lang.${key}`; // simple mock translation function for testing + + describe('getCountryList', () => { + it('should return a list of Country objects', () => { + const countries = CountryUtils.getCountryList(); + expect(countries).toBeDefined(); + expect(isArray(countries)).toBe(true); + }); + }); + + describe('getCountryCodeList', () => { + it('should return a list of CountryCodes', () => { + const countryCodes = CountryUtils.getCountryCodeList(); + expect(countryCodes).toBeDefined(); + expect(isArray(countryCodes)).toBe(true); + }); + }); + + describe('createCountryByCode', () => { + it('should create Country object for valid CountryCode', () => { + const country = CountryUtils.createCountryByCode(CountryCode.AF); + expect(country).toBeDefined(); + expect(country.iso2).toEqual(CountryCode.AF); + }); + + it('should throw an error for unknown CountryCode', () => { + expect(() => CountryUtils.createCountryByCode('UnknownCode' as CountryCode)).toThrowError(); + }); + }); + + describe('createCountryAutoCompleteValues', () => { + it('should create an array of autocomplete mappings', () => { + const countryCodes = CountryUtils.getCountryCodeList(); + const autoCompleteMappings = CountryUtils.createCountryAutoCompleteValues(countryCodes, mockTranslationFunction); + expect(autoCompleteMappings).toBeDefined(); + expect(isArray(autoCompleteMappings)).toBe(true); + }); + }); + + describe('parseCountry', () => { + + it('should return Country object for a valid country name', () => { + const countryName = 'af'; + const country = CountryUtils.parseCountry(countryName, mockTranslationFunction); + expect(country).toBeDefined(); + expect(country?.iso2).toEqual(CountryCode.AF); + }); + + it('should return Country object for a valid translated country name', () => { + const countryName = 'lang.countryCode.af.name'; + const country = CountryUtils.parseCountry(countryName, mockTranslationFunction); + expect(country).toBeDefined(); + expect(country?.iso2).toEqual(CountryCode.AF); + }); + + it('should return undefined for an unknown country name', () => { + const countryName = 'UnknownCountry'; + const country = CountryUtils.parseCountry(countryName, mockTranslationFunction); + expect(country).toBeUndefined(); + }); + + }); + +}); diff --git a/CountryUtils.ts b/CountryUtils.ts new file mode 100644 index 0000000..127e4d5 --- /dev/null +++ b/CountryUtils.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { map } from "./functions/map"; +import { find } from "./functions/find"; +import { trim } from "./functions/trim"; +import { toLower } from "./functions/toLower"; +import { TranslationFunction } from "./types/TranslationFunction"; +import { Sovereignty } from "./types/Sovereignty"; +import { CountryCode, isCountryCode } from "./types/CountryCode"; +import { Country, createCountry } from "./types/Country"; +import { getCountryNameTranslationKey } from "./translations/country-translation"; +import { EnumUtils } from "./EnumUtils"; + +export type CountryAutoCompleteMapping = [CountryCode, string[]][]; + +export class CountryUtils { + + private static _countryCodeList : readonly CountryCode[] | undefined = undefined; + private static _countryList : readonly Country[] | undefined = undefined; + + public static getCountryList () : readonly Country[] { + if (!this._countryList) { + this._countryList = map( + CountryUtils.getCountryCodeList(), + (item: CountryCode) : Country => CountryUtils.createCountryByCode(item) + ); + } + if (!this._countryList) throw TypeError(`CountryUtils: Country list not initialized`); + return this._countryList; + } + + public static getCountryCodeList () : readonly CountryCode[] { + if (!this._countryCodeList) { + this._countryCodeList = CountryUtils.createCountryCodeList(); + } + if (!this._countryCodeList) throw TypeError(`CountryUtils: Country code list not initialized`); + return this._countryCodeList; + } + + public static createCountryCodeList () : CountryCode[] { + return map(EnumUtils.getKeys(CountryCode), (key: string) : CountryCode => { + // @ts-ignore + return CountryCode[key]; + }); + } + + public static createCountryByCode (code: CountryCode) : Country { + switch(code) { + case CountryCode.AF: return createCountry(CountryCode.AF, Sovereignty.UN_MEMBER_STATE, "AFG", 4, "ISO 3166-2:AF", ".af") + case CountryCode.AX: return createCountry(CountryCode.AX, Sovereignty.FINLAND, "ALA", 248, "ISO 3166-2:AX", ".ax") + case CountryCode.AL: return createCountry(CountryCode.AL, Sovereignty.UN_MEMBER_STATE, "ALB", 8, "ISO 3166-2:AL", ".al") + case CountryCode.DZ: return createCountry(CountryCode.DZ, Sovereignty.UN_MEMBER_STATE, "DZA", 12, "ISO 3166-2:DZ", ".dz") + case CountryCode.AS: return createCountry(CountryCode.AS, Sovereignty.UNITED_STATES, "ASM", 16, "ISO 3166-2:AS", ".as") + case CountryCode.AD: return createCountry(CountryCode.AD, Sovereignty.UN_MEMBER_STATE, "AND", 20, "ISO 3166-2:AD", ".ad") + case CountryCode.AO: return createCountry(CountryCode.AO, Sovereignty.UN_MEMBER_STATE, "AGO", 24, "ISO 3166-2:AO", ".ao") + case CountryCode.AI: return createCountry(CountryCode.AI, Sovereignty.UNITED_KINGDOM, "AIA", 660, "ISO 3166-2:AI", ".ai") + case CountryCode.AQ: return createCountry(CountryCode.AQ, Sovereignty.ANTARCTIC_TREATY, "ATA", 10, "ISO 3166-2:AQ", ".aq") + case CountryCode.AG: return createCountry(CountryCode.AG, Sovereignty.UN_MEMBER_STATE, "ATG", 28, "ISO 3166-2:AG", ".ag") + case CountryCode.AR: return createCountry(CountryCode.AR, Sovereignty.UN_MEMBER_STATE, "ARG", 32, "ISO 3166-2:AR", ".ar") + case CountryCode.AM: return createCountry(CountryCode.AM, Sovereignty.UN_MEMBER_STATE, "ARM", 51, "ISO 3166-2:AM", ".am") + case CountryCode.AW: return createCountry(CountryCode.AW, Sovereignty.NETHERLANDS, "ABW", 533, "ISO 3166-2:AW", ".aw") + case CountryCode.AU: return createCountry(CountryCode.AU, Sovereignty.UN_MEMBER_STATE, "AUS", 36, "ISO 3166-2:AU", ".au") + case CountryCode.AT: return createCountry(CountryCode.AT, Sovereignty.UN_MEMBER_STATE, "AUT", 40, "ISO 3166-2:AT", ".at") + case CountryCode.AZ: return createCountry(CountryCode.AZ, Sovereignty.UN_MEMBER_STATE, "AZE", 31, "ISO 3166-2:AZ", ".az") + case CountryCode.BS: return createCountry(CountryCode.BS, Sovereignty.UN_MEMBER_STATE, "BHS", 44, "ISO 3166-2:BS", ".bs") + case CountryCode.BH: return createCountry(CountryCode.BH, Sovereignty.UN_MEMBER_STATE, "BHR", 48, "ISO 3166-2:BH", ".bh") + case CountryCode.BD: return createCountry(CountryCode.BD, Sovereignty.UN_MEMBER_STATE, "BGD", 50, "ISO 3166-2:BD", ".bd") + case CountryCode.BB: return createCountry(CountryCode.BB, Sovereignty.UN_MEMBER_STATE, "BRB", 52, "ISO 3166-2:BB", ".bb") + case CountryCode.BY: return createCountry(CountryCode.BY, Sovereignty.UN_MEMBER_STATE, "BLR", 112, "ISO 3166-2:BY", ".by") + case CountryCode.BE: return createCountry(CountryCode.BE, Sovereignty.UN_MEMBER_STATE, "BEL", 56, "ISO 3166-2:BE", ".be") + case CountryCode.BZ: return createCountry(CountryCode.BZ, Sovereignty.UN_MEMBER_STATE, "BLZ", 84, "ISO 3166-2:BZ", ".bz") + case CountryCode.BJ: return createCountry(CountryCode.BJ, Sovereignty.UN_MEMBER_STATE, "BEN", 204, "ISO 3166-2:BJ", ".bj") + case CountryCode.BM: return createCountry(CountryCode.BM, Sovereignty.UNITED_KINGDOM, "BMU", 60, "ISO 3166-2:BM", ".bm") + case CountryCode.BT: return createCountry(CountryCode.BT, Sovereignty.UN_MEMBER_STATE, "BTN", 64, "ISO 3166-2:BT", ".bt") + case CountryCode.BO: return createCountry(CountryCode.BO, Sovereignty.UN_MEMBER_STATE, "BOL", 68, "ISO 3166-2:BO", ".bo") + case CountryCode.BQ: return createCountry(CountryCode.BQ, Sovereignty.NETHERLANDS, "BES", 535, "ISO 3166-2:BQ", ".bq .nl") + case CountryCode.BA: return createCountry(CountryCode.BA, Sovereignty.UN_MEMBER_STATE, "BIH", 70, "ISO 3166-2:BA", ".ba") + case CountryCode.BW: return createCountry(CountryCode.BW, Sovereignty.UN_MEMBER_STATE, "BWA", 72, "ISO 3166-2:BW", ".bw") + case CountryCode.BV: return createCountry(CountryCode.BV, Sovereignty.NORWAY, "BVT", 74, "ISO 3166-2:BV", "") + case CountryCode.BR: return createCountry(CountryCode.BR, Sovereignty.UN_MEMBER_STATE, "BRA", 76, "ISO 3166-2:BR", ".br") + case CountryCode.IO: return createCountry(CountryCode.IO, Sovereignty.UNITED_KINGDOM, "IOT", 86, "ISO 3166-2:IO", ".io") + case CountryCode.BN: return createCountry(CountryCode.BN, Sovereignty.UN_MEMBER_STATE, "BRN", 96, "ISO 3166-2:BN", ".bn") + case CountryCode.BG: return createCountry(CountryCode.BG, Sovereignty.UN_MEMBER_STATE, "BGR", 100, "ISO 3166-2:BG", ".bg") + case CountryCode.BF: return createCountry(CountryCode.BF, Sovereignty.UN_MEMBER_STATE, "BFA", 854, "ISO 3166-2:BF", ".bf") + case CountryCode.BI: return createCountry(CountryCode.BI, Sovereignty.UN_MEMBER_STATE, "BDI", 108, "ISO 3166-2:BI", ".bi") + case CountryCode.CV: return createCountry(CountryCode.CV, Sovereignty.UN_MEMBER_STATE, "CPV", 132, "ISO 3166-2:CV", ".cv") + case CountryCode.KH: return createCountry(CountryCode.KH, Sovereignty.UN_MEMBER_STATE, "KHM", 116, "ISO 3166-2:KH", ".kh") + case CountryCode.CM: return createCountry(CountryCode.CM, Sovereignty.UN_MEMBER_STATE, "CMR", 120, "ISO 3166-2:CM", ".cm") + case CountryCode.CA: return createCountry(CountryCode.CA, Sovereignty.UN_MEMBER_STATE, "CAN", 124, "ISO 3166-2:CA", ".ca") + case CountryCode.KY: return createCountry(CountryCode.KY, Sovereignty.UNITED_KINGDOM, "CYM", 136, "ISO 3166-2:KY", ".ky") + case CountryCode.CF: return createCountry(CountryCode.CF, Sovereignty.UN_MEMBER_STATE, "CAF", 140, "ISO 3166-2:CF", ".cf") + case CountryCode.TD: return createCountry(CountryCode.TD, Sovereignty.UN_MEMBER_STATE, "TCD", 148, "ISO 3166-2:TD", ".td") + case CountryCode.CL: return createCountry(CountryCode.CL, Sovereignty.UN_MEMBER_STATE, "CHL", 152, "ISO 3166-2:CL", ".cl") + case CountryCode.CN: return createCountry(CountryCode.CN, Sovereignty.UN_MEMBER_STATE, "CHN", 156, "ISO 3166-2:CN", ".cn") + case CountryCode.CX: return createCountry(CountryCode.CX, Sovereignty.AUSTRALIA, "CXR", 162, "ISO 3166-2:CX", ".cx") + case CountryCode.CC: return createCountry(CountryCode.CC, Sovereignty.AUSTRALIA, "CCK", 166, "ISO 3166-2:CC", ".cc") + case CountryCode.CO: return createCountry(CountryCode.CO, Sovereignty.UN_MEMBER_STATE, "COL", 170, "ISO 3166-2:CO", ".co") + case CountryCode.KM: return createCountry(CountryCode.KM, Sovereignty.UN_MEMBER_STATE, "COM", 174, "ISO 3166-2:KM", ".km") + case CountryCode.CD: return createCountry(CountryCode.CD, Sovereignty.UN_MEMBER_STATE, "COD", 180, "ISO 3166-2:CD", ".cd") + case CountryCode.CG: return createCountry(CountryCode.CG, Sovereignty.UN_MEMBER_STATE, "COG", 178, "ISO 3166-2:CG", ".cg") + case CountryCode.CK: return createCountry(CountryCode.CK, Sovereignty.NEW_ZEALAND, "COK", 184, "ISO 3166-2:CK", ".ck") + case CountryCode.CR: return createCountry(CountryCode.CR, Sovereignty.UN_MEMBER_STATE, "CRI", 188, "ISO 3166-2:CR", ".cr") + case CountryCode.CI: return createCountry(CountryCode.CI, Sovereignty.UN_MEMBER_STATE, "CIV", 384, "ISO 3166-2:CI", ".ci") + case CountryCode.HR: return createCountry(CountryCode.HR, Sovereignty.UN_MEMBER_STATE, "HRV", 191, "ISO 3166-2:HR", ".hr") + case CountryCode.CU: return createCountry(CountryCode.CU, Sovereignty.UN_MEMBER_STATE, "CUB", 192, "ISO 3166-2:CU", ".cu") + case CountryCode.CW: return createCountry(CountryCode.CW, Sovereignty.NETHERLANDS, "CUW", 531, "ISO 3166-2:CW", ".cw") + case CountryCode.CY: return createCountry(CountryCode.CY, Sovereignty.UN_MEMBER_STATE, "CYP", 196, "ISO 3166-2:CY", ".cy") + case CountryCode.CZ: return createCountry(CountryCode.CZ, Sovereignty.UN_MEMBER_STATE, "CZE", 203, "ISO 3166-2:CZ", ".cz") + case CountryCode.DK: return createCountry(CountryCode.DK, Sovereignty.UN_MEMBER_STATE, "DNK", 208, "ISO 3166-2:DK", ".dk") + case CountryCode.DJ: return createCountry(CountryCode.DJ, Sovereignty.UN_MEMBER_STATE, "DJI", 262, "ISO 3166-2:DJ", ".dj") + case CountryCode.DM: return createCountry(CountryCode.DM, Sovereignty.UN_MEMBER_STATE, "DMA", 212, "ISO 3166-2:DM", ".dm") + case CountryCode.DO: return createCountry(CountryCode.DO, Sovereignty.UN_MEMBER_STATE, "DOM", 214, "ISO 3166-2:DO", ".do") + case CountryCode.EC: return createCountry(CountryCode.EC, Sovereignty.UN_MEMBER_STATE, "ECU", 218, "ISO 3166-2:EC", ".ec") + case CountryCode.EG: return createCountry(CountryCode.EG, Sovereignty.UN_MEMBER_STATE, "EGY", 818, "ISO 3166-2:EG", ".eg") + case CountryCode.SV: return createCountry(CountryCode.SV, Sovereignty.UN_MEMBER_STATE, "SLV", 222, "ISO 3166-2:SV", ".sv") + case CountryCode.GQ: return createCountry(CountryCode.GQ, Sovereignty.UN_MEMBER_STATE, "GNQ", 226, "ISO 3166-2:GQ", ".gq") + case CountryCode.ER: return createCountry(CountryCode.ER, Sovereignty.UN_MEMBER_STATE, "ERI", 232, "ISO 3166-2:ER", ".er") + case CountryCode.EE: return createCountry(CountryCode.EE, Sovereignty.UN_MEMBER_STATE, "EST", 233, "ISO 3166-2:EE", ".ee") + case CountryCode.SZ: return createCountry(CountryCode.SZ, Sovereignty.UN_MEMBER_STATE, "SWZ", 748, "ISO 3166-2:SZ", ".sz") + case CountryCode.ET: return createCountry(CountryCode.ET, Sovereignty.UN_MEMBER_STATE, "ETH", 231, "ISO 3166-2:ET", ".et") + case CountryCode.FK: return createCountry(CountryCode.FK, Sovereignty.UNITED_KINGDOM, "FLK", 238, "ISO 3166-2:FK", ".fk") + case CountryCode.FO: return createCountry(CountryCode.FO, Sovereignty.DENMARK, "FRO", 234, "ISO 3166-2:FO", ".fo") + case CountryCode.FJ: return createCountry(CountryCode.FJ, Sovereignty.UN_MEMBER_STATE, "FJI", 242, "ISO 3166-2:FJ", ".fj") + case CountryCode.FI: return createCountry(CountryCode.FI, Sovereignty.UN_MEMBER_STATE, "FIN", 246, "ISO 3166-2:FI", ".fi") + case CountryCode.FR: return createCountry(CountryCode.FR, Sovereignty.UN_MEMBER_STATE, "FRA", 250, "ISO 3166-2:FR", ".fr") + case CountryCode.GF: return createCountry(CountryCode.GF, Sovereignty.FRANCE, "GUF", 254, "ISO 3166-2:GF", ".gf") + case CountryCode.PF: return createCountry(CountryCode.PF, Sovereignty.FRANCE, "PYF", 258, "ISO 3166-2:PF", ".pf") + case CountryCode.TF: return createCountry(CountryCode.TF, Sovereignty.FRANCE, "ATF", 260, "ISO 3166-2:TF", ".tf") + case CountryCode.GA: return createCountry(CountryCode.GA, Sovereignty.UN_MEMBER_STATE, "GAB", 266, "ISO 3166-2:GA", ".ga") + case CountryCode.GM: return createCountry(CountryCode.GM, Sovereignty.UN_MEMBER_STATE, "GMB", 270, "ISO 3166-2:GM", ".gm") + case CountryCode.GE: return createCountry(CountryCode.GE, Sovereignty.UN_MEMBER_STATE, "GEO", 268, "ISO 3166-2:GE", ".ge") + case CountryCode.DE: return createCountry(CountryCode.DE, Sovereignty.UN_MEMBER_STATE, "DEU", 276, "ISO 3166-2:DE", ".de") + case CountryCode.GH: return createCountry(CountryCode.GH, Sovereignty.UN_MEMBER_STATE, "GHA", 288, "ISO 3166-2:GH", ".gh") + case CountryCode.GI: return createCountry(CountryCode.GI, Sovereignty.UNITED_KINGDOM, "GIB", 292, "ISO 3166-2:GI", ".gi") + case CountryCode.GR: return createCountry(CountryCode.GR, Sovereignty.UN_MEMBER_STATE, "GRC", 300, "ISO 3166-2:GR", ".gr") + case CountryCode.GL: return createCountry(CountryCode.GL, Sovereignty.DENMARK, "GRL", 304, "ISO 3166-2:GL", ".gl") + case CountryCode.GD: return createCountry(CountryCode.GD, Sovereignty.UN_MEMBER_STATE, "GRD", 308, "ISO 3166-2:GD", ".gd") + case CountryCode.GP: return createCountry(CountryCode.GP, Sovereignty.FRANCE, "GLP", 312, "ISO 3166-2:GP", ".gp") + case CountryCode.GU: return createCountry(CountryCode.GU, Sovereignty.UNITED_STATES, "GUM", 316, "ISO 3166-2:GU", ".gu") + case CountryCode.GT: return createCountry(CountryCode.GT, Sovereignty.UN_MEMBER_STATE, "GTM", 320, "ISO 3166-2:GT", ".gt") + case CountryCode.GG: return createCountry(CountryCode.GG, Sovereignty.BRITISH_CROWN, "GGY", 831, "ISO 3166-2:GG", ".gg") + case CountryCode.GN: return createCountry(CountryCode.GN, Sovereignty.UN_MEMBER_STATE, "GIN", 324, "ISO 3166-2:GN", ".gn") + case CountryCode.GW: return createCountry(CountryCode.GW, Sovereignty.UN_MEMBER_STATE, "GNB", 624, "ISO 3166-2:GW", ".gw") + case CountryCode.GY: return createCountry(CountryCode.GY, Sovereignty.UN_MEMBER_STATE, "GUY", 328, "ISO 3166-2:GY", ".gy") + case CountryCode.HT: return createCountry(CountryCode.HT, Sovereignty.UN_MEMBER_STATE, "HTI", 332, "ISO 3166-2:HT", ".ht") + case CountryCode.HM: return createCountry(CountryCode.HM, Sovereignty.AUSTRALIA, "HMD", 334, "ISO 3166-2:HM", ".hm") + case CountryCode.VA: return createCountry(CountryCode.VA, Sovereignty.UN_OBSERVER, "VAT", 336, "ISO 3166-2:VA", ".va") + case CountryCode.HN: return createCountry(CountryCode.HN, Sovereignty.UN_MEMBER_STATE, "HND", 340, "ISO 3166-2:HN", ".hn") + case CountryCode.HK: return createCountry(CountryCode.HK, Sovereignty.CHINA, "HKG", 344, "ISO 3166-2:HK", ".hk") + case CountryCode.HU: return createCountry(CountryCode.HU, Sovereignty.UN_MEMBER_STATE, "HUN", 348, "ISO 3166-2:HU", ".hu") + case CountryCode.IS: return createCountry(CountryCode.IS, Sovereignty.UN_MEMBER_STATE, "ISL", 352, "ISO 3166-2:IS", ".is") + case CountryCode.IN: return createCountry(CountryCode.IN, Sovereignty.UN_MEMBER_STATE, "IND", 356, "ISO 3166-2:IN", ".in") + case CountryCode.ID: return createCountry(CountryCode.ID, Sovereignty.UN_MEMBER_STATE, "IDN", 360, "ISO 3166-2:ID", ".id") + case CountryCode.IR: return createCountry(CountryCode.IR, Sovereignty.UN_MEMBER_STATE, "IRN", 364, "ISO 3166-2:IR", ".ir") + case CountryCode.IQ: return createCountry(CountryCode.IQ, Sovereignty.UN_MEMBER_STATE, "IRQ", 368, "ISO 3166-2:IQ", ".iq") + case CountryCode.IE: return createCountry(CountryCode.IE, Sovereignty.UN_MEMBER_STATE, "IRL", 372, "ISO 3166-2:IE", ".ie") + case CountryCode.IM: return createCountry(CountryCode.IM, Sovereignty.BRITISH_CROWN, "IMN", 833, "ISO 3166-2:IM", ".im") + case CountryCode.IL: return createCountry(CountryCode.IL, Sovereignty.UN_MEMBER_STATE, "ISR", 376, "ISO 3166-2:IL", ".il") + case CountryCode.IT: return createCountry(CountryCode.IT, Sovereignty.UN_MEMBER_STATE, "ITA", 380, "ISO 3166-2:IT", ".it") + case CountryCode.JM: return createCountry(CountryCode.JM, Sovereignty.UN_MEMBER_STATE, "JAM", 388, "ISO 3166-2:JM", ".jm") + case CountryCode.JP: return createCountry(CountryCode.JP, Sovereignty.UN_MEMBER_STATE, "JPN", 392, "ISO 3166-2:JP", ".jp") + case CountryCode.JE: return createCountry(CountryCode.JE, Sovereignty.BRITISH_CROWN, "JEY", 832, "ISO 3166-2:JE", ".je") + case CountryCode.JO: return createCountry(CountryCode.JO, Sovereignty.UN_MEMBER_STATE, "JOR", 400, "ISO 3166-2:JO", ".jo") + case CountryCode.KZ: return createCountry(CountryCode.KZ, Sovereignty.UN_MEMBER_STATE, "KAZ", 398, "ISO 3166-2:KZ", ".kz") + case CountryCode.KE: return createCountry(CountryCode.KE, Sovereignty.UN_MEMBER_STATE, "KEN", 404, "ISO 3166-2:KE", ".ke") + case CountryCode.KI: return createCountry(CountryCode.KI, Sovereignty.UN_MEMBER_STATE, "KIR", 296, "ISO 3166-2:KI", ".ki") + case CountryCode.KP: return createCountry(CountryCode.KP, Sovereignty.UN_MEMBER_STATE, "PRK", 408, "ISO 3166-2:KP", ".kp") + case CountryCode.KR: return createCountry(CountryCode.KR, Sovereignty.UN_MEMBER_STATE, "KOR", 410, "ISO 3166-2:KR", ".kr") + case CountryCode.KW: return createCountry(CountryCode.KW, Sovereignty.UN_MEMBER_STATE, "KWT", 414, "ISO 3166-2:KW", ".kw") + case CountryCode.KG: return createCountry(CountryCode.KG, Sovereignty.UN_MEMBER_STATE, "KGZ", 417, "ISO 3166-2:KG", ".kg") + case CountryCode.LA: return createCountry(CountryCode.LA, Sovereignty.UN_MEMBER_STATE, "LAO", 418, "ISO 3166-2:LA", ".la") + case CountryCode.LV: return createCountry(CountryCode.LV, Sovereignty.UN_MEMBER_STATE, "LVA", 428, "ISO 3166-2:LV", ".lv") + case CountryCode.LB: return createCountry(CountryCode.LB, Sovereignty.UN_MEMBER_STATE, "LBN", 422, "ISO 3166-2:LB", ".lb") + case CountryCode.LS: return createCountry(CountryCode.LS, Sovereignty.UN_MEMBER_STATE, "LSO", 426, "ISO 3166-2:LS", ".ls") + case CountryCode.LR: return createCountry(CountryCode.LR, Sovereignty.UN_MEMBER_STATE, "LBR", 430, "ISO 3166-2:LR", ".lr") + case CountryCode.LY: return createCountry(CountryCode.LY, Sovereignty.UN_MEMBER_STATE, "LBY", 434, "ISO 3166-2:LY", ".ly") + case CountryCode.LI: return createCountry(CountryCode.LI, Sovereignty.UN_MEMBER_STATE, "LIE", 438, "ISO 3166-2:LI", ".li") + case CountryCode.LT: return createCountry(CountryCode.LT, Sovereignty.UN_MEMBER_STATE, "LTU", 440, "ISO 3166-2:LT", ".lt") + case CountryCode.LU: return createCountry(CountryCode.LU, Sovereignty.UN_MEMBER_STATE, "LUX", 442, "ISO 3166-2:LU", ".lu") + case CountryCode.MO: return createCountry(CountryCode.MO, Sovereignty.CHINA, "MAC", 446, "ISO 3166-2:MO", ".mo") + case CountryCode.MK: return createCountry(CountryCode.MK, Sovereignty.UN_MEMBER_STATE, "MKD", 807, "ISO 3166-2:MK", ".mk") + case CountryCode.MG: return createCountry(CountryCode.MG, Sovereignty.UN_MEMBER_STATE, "MDG", 450, "ISO 3166-2:MG", ".mg") + case CountryCode.MW: return createCountry(CountryCode.MW, Sovereignty.UN_MEMBER_STATE, "MWI", 454, "ISO 3166-2:MW", ".mw") + case CountryCode.MY: return createCountry(CountryCode.MY, Sovereignty.UN_MEMBER_STATE, "MYS", 458, "ISO 3166-2:MY", ".my") + case CountryCode.MV: return createCountry(CountryCode.MV, Sovereignty.UN_MEMBER_STATE, "MDV", 462, "ISO 3166-2:MV", ".mv") + case CountryCode.ML: return createCountry(CountryCode.ML, Sovereignty.UN_MEMBER_STATE, "MLI", 466, "ISO 3166-2:ML", ".ml") + case CountryCode.MT: return createCountry(CountryCode.MT, Sovereignty.UN_MEMBER_STATE, "MLT", 470, "ISO 3166-2:MT", ".mt") + case CountryCode.MH: return createCountry(CountryCode.MH, Sovereignty.UN_MEMBER_STATE, "MHL", 584, "ISO 3166-2:MH", ".mh") + case CountryCode.MQ: return createCountry(CountryCode.MQ, Sovereignty.FRANCE, "MTQ", 474, "ISO 3166-2:MQ", ".mq") + case CountryCode.MR: return createCountry(CountryCode.MR, Sovereignty.UN_MEMBER_STATE, "MRT", 478, "ISO 3166-2:MR", ".mr") + case CountryCode.MU: return createCountry(CountryCode.MU, Sovereignty.UN_MEMBER_STATE, "MUS", 480, "ISO 3166-2:MU", ".mu") + case CountryCode.YT: return createCountry(CountryCode.YT, Sovereignty.FRANCE, "MYT", 175, "ISO 3166-2:YT", ".yt") + case CountryCode.MX: return createCountry(CountryCode.MX, Sovereignty.UN_MEMBER_STATE, "MEX", 484, "ISO 3166-2:MX", ".mx") + case CountryCode.FM: return createCountry(CountryCode.FM, Sovereignty.UN_MEMBER_STATE, "FSM", 583, "ISO 3166-2:FM", ".fm") + case CountryCode.MD: return createCountry(CountryCode.MD, Sovereignty.UN_MEMBER_STATE, "MDA", 498, "ISO 3166-2:MD", ".md") + case CountryCode.MC: return createCountry(CountryCode.MC, Sovereignty.UN_MEMBER_STATE, "MCO", 492, "ISO 3166-2:MC", ".mc") + case CountryCode.MN: return createCountry(CountryCode.MN, Sovereignty.UN_MEMBER_STATE, "MNG", 496, "ISO 3166-2:MN", ".mn") + case CountryCode.ME: return createCountry(CountryCode.ME, Sovereignty.UN_MEMBER_STATE, "MNE", 499, "ISO 3166-2:ME", ".me") + case CountryCode.MS: return createCountry(CountryCode.MS, Sovereignty.UNITED_KINGDOM, "MSR", 500, "ISO 3166-2:MS", ".ms") + case CountryCode.MA: return createCountry(CountryCode.MA, Sovereignty.UN_MEMBER_STATE, "MAR", 504, "ISO 3166-2:MA", ".ma") + case CountryCode.MZ: return createCountry(CountryCode.MZ, Sovereignty.UN_MEMBER_STATE, "MOZ", 508, "ISO 3166-2:MZ", ".mz") + case CountryCode.MM: return createCountry(CountryCode.MM, Sovereignty.UN_MEMBER_STATE, "MMR", 104, "ISO 3166-2:MM", ".mm") + case CountryCode.NA: return createCountry(CountryCode.NA, Sovereignty.UN_MEMBER_STATE, "NAM", 516, "ISO 3166-2:NA", ".na") + case CountryCode.NR: return createCountry(CountryCode.NR, Sovereignty.UN_MEMBER_STATE, "NRU", 520, "ISO 3166-2:NR", ".nr") + case CountryCode.NP: return createCountry(CountryCode.NP, Sovereignty.UN_MEMBER_STATE, "NPL", 524, "ISO 3166-2:NP", ".np") + case CountryCode.NL: return createCountry(CountryCode.NL, Sovereignty.UN_MEMBER_STATE, "NLD", 528, "ISO 3166-2:NL", ".nl") + case CountryCode.NC: return createCountry(CountryCode.NC, Sovereignty.FRANCE, "NCL", 540, "ISO 3166-2:NC", ".nc") + case CountryCode.NZ: return createCountry(CountryCode.NZ, Sovereignty.UN_MEMBER_STATE, "NZL", 554, "ISO 3166-2:NZ", ".nz") + case CountryCode.NI: return createCountry(CountryCode.NI, Sovereignty.UN_MEMBER_STATE, "NIC", 558, "ISO 3166-2:NI", ".ni") + case CountryCode.NE: return createCountry(CountryCode.NE, Sovereignty.UN_MEMBER_STATE, "NER", 562, "ISO 3166-2:NE", ".ne") + case CountryCode.NG: return createCountry(CountryCode.NG, Sovereignty.UN_MEMBER_STATE, "NGA", 566, "ISO 3166-2:NG", ".ng") + case CountryCode.NU: return createCountry(CountryCode.NU, Sovereignty.NEW_ZEALAND, "NIU", 570, "ISO 3166-2:NU", ".nu") + case CountryCode.NF: return createCountry(CountryCode.NF, Sovereignty.AUSTRALIA, "NFK", 574, "ISO 3166-2:NF", ".nf") + case CountryCode.MP: return createCountry(CountryCode.MP, Sovereignty.UNITED_STATES, "MNP", 580, "ISO 3166-2:MP", ".mp") + case CountryCode.NO: return createCountry(CountryCode.NO, Sovereignty.UN_MEMBER_STATE, "NOR", 578, "ISO 3166-2:NO", ".no") + case CountryCode.OM: return createCountry(CountryCode.OM, Sovereignty.UN_MEMBER_STATE, "OMN", 512, "ISO 3166-2:OM", ".om") + case CountryCode.PK: return createCountry(CountryCode.PK, Sovereignty.UN_MEMBER_STATE, "PAK", 586, "ISO 3166-2:PK", ".pk") + case CountryCode.PW: return createCountry(CountryCode.PW, Sovereignty.UN_MEMBER_STATE, "PLW", 585, "ISO 3166-2:PW", ".pw") + case CountryCode.PS: return createCountry(CountryCode.PS, Sovereignty.UN_OBSERVER, "PSE", 275, "ISO 3166-2:PS", ".ps") + case CountryCode.PA: return createCountry(CountryCode.PA, Sovereignty.UN_MEMBER_STATE, "PAN", 591, "ISO 3166-2:PA", ".pa") + case CountryCode.PG: return createCountry(CountryCode.PG, Sovereignty.UN_MEMBER_STATE, "PNG", 598, "ISO 3166-2:PG", ".pg") + case CountryCode.PY: return createCountry(CountryCode.PY, Sovereignty.UN_MEMBER_STATE, "PRY", 600, "ISO 3166-2:PY", ".py") + case CountryCode.PE: return createCountry(CountryCode.PE, Sovereignty.UN_MEMBER_STATE, "PER", 604, "ISO 3166-2:PE", ".pe") + case CountryCode.PH: return createCountry(CountryCode.PH, Sovereignty.UN_MEMBER_STATE, "PHL", 608, "ISO 3166-2:PH", ".ph") + case CountryCode.PN: return createCountry(CountryCode.PN, Sovereignty.UNITED_KINGDOM, "PCN", 612, "ISO 3166-2:PN", ".pn") + case CountryCode.PL: return createCountry(CountryCode.PL, Sovereignty.UN_MEMBER_STATE, "POL", 616, "ISO 3166-2:PL", ".pl") + case CountryCode.PT: return createCountry(CountryCode.PT, Sovereignty.UN_MEMBER_STATE, "PRT", 620, "ISO 3166-2:PT", ".pt") + case CountryCode.PR: return createCountry(CountryCode.PR, Sovereignty.UNITED_STATES, "PRI", 630, "ISO 3166-2:PR", ".pr") + case CountryCode.QA: return createCountry(CountryCode.QA, Sovereignty.UN_MEMBER_STATE, "QAT", 634, "ISO 3166-2:QA", ".qa") + case CountryCode.RE: return createCountry(CountryCode.RE, Sovereignty.FRANCE, "REU", 638, "ISO 3166-2:RE", ".re") + case CountryCode.RO: return createCountry(CountryCode.RO, Sovereignty.UN_MEMBER_STATE, "ROU", 642, "ISO 3166-2:RO", ".ro") + case CountryCode.RU: return createCountry(CountryCode.RU, Sovereignty.UN_MEMBER_STATE, "RUS", 643, "ISO 3166-2:RU", ".ru") + case CountryCode.RW: return createCountry(CountryCode.RW, Sovereignty.UN_MEMBER_STATE, "RWA", 646, "ISO 3166-2:RW", ".rw") + case CountryCode.BL: return createCountry(CountryCode.BL, Sovereignty.FRANCE, "BLM", 652, "ISO 3166-2:BL", ".bl") + case CountryCode.SH: return createCountry(CountryCode.SH, Sovereignty.UNITED_KINGDOM, "SHN", 654, "ISO 3166-2:SH", ".sh") + case CountryCode.KN: return createCountry(CountryCode.KN, Sovereignty.UN_MEMBER_STATE, "KNA", 659, "ISO 3166-2:KN", ".kn") + case CountryCode.LC: return createCountry(CountryCode.LC, Sovereignty.UN_MEMBER_STATE, "LCA", 662, "ISO 3166-2:LC", ".lc") + case CountryCode.MF: return createCountry(CountryCode.MF, Sovereignty.FRANCE, "MAF", 663, "ISO 3166-2:MF", ".mf") + case CountryCode.PM: return createCountry(CountryCode.PM, Sovereignty.FRANCE, "SPM", 666, "ISO 3166-2:PM", ".pm") + case CountryCode.VC: return createCountry(CountryCode.VC, Sovereignty.UN_MEMBER_STATE, "VCT", 670, "ISO 3166-2:VC", ".vc") + case CountryCode.WS: return createCountry(CountryCode.WS, Sovereignty.UN_MEMBER_STATE, "WSM", 882, "ISO 3166-2:WS", ".ws") + case CountryCode.SM: return createCountry(CountryCode.SM, Sovereignty.UN_MEMBER_STATE, "SMR", 674, "ISO 3166-2:SM", ".sm") + case CountryCode.ST: return createCountry(CountryCode.ST, Sovereignty.UN_MEMBER_STATE, "STP", 678, "ISO 3166-2:ST", ".st") + case CountryCode.SA: return createCountry(CountryCode.SA, Sovereignty.UN_MEMBER_STATE, "SAU", 682, "ISO 3166-2:SA", ".sa") + case CountryCode.SN: return createCountry(CountryCode.SN, Sovereignty.UN_MEMBER_STATE, "SEN", 686, "ISO 3166-2:SN", ".sn") + case CountryCode.RS: return createCountry(CountryCode.RS, Sovereignty.UN_MEMBER_STATE, "SRB", 688, "ISO 3166-2:RS", ".rs") + case CountryCode.SC: return createCountry(CountryCode.SC, Sovereignty.UN_MEMBER_STATE, "SYC", 690, "ISO 3166-2:SC", ".sc") + case CountryCode.SL: return createCountry(CountryCode.SL, Sovereignty.UN_MEMBER_STATE, "SLE", 694, "ISO 3166-2:SL", ".sl") + case CountryCode.SG: return createCountry(CountryCode.SG, Sovereignty.UN_MEMBER_STATE, "SGP", 702, "ISO 3166-2:SG", ".sg") + case CountryCode.SX: return createCountry(CountryCode.SX, Sovereignty.NETHERLANDS, "SXM", 534, "ISO 3166-2:SX", ".sx") + case CountryCode.SK: return createCountry(CountryCode.SK, Sovereignty.UN_MEMBER_STATE, "SVK", 703, "ISO 3166-2:SK", ".sk") + case CountryCode.SI: return createCountry(CountryCode.SI, Sovereignty.UN_MEMBER_STATE, "SVN", 705, "ISO 3166-2:SI", ".si") + case CountryCode.SB: return createCountry(CountryCode.SB, Sovereignty.UN_MEMBER_STATE, "SLB", 90, "ISO 3166-2:SB", ".sb") + case CountryCode.SO: return createCountry(CountryCode.SO, Sovereignty.UN_MEMBER_STATE, "SOM", 706, "ISO 3166-2:SO", ".so") + case CountryCode.ZA: return createCountry(CountryCode.ZA, Sovereignty.UN_MEMBER_STATE, "ZAF", 710, "ISO 3166-2:ZA", ".za") + case CountryCode.GS: return createCountry(CountryCode.GS, Sovereignty.UNITED_KINGDOM, "SGS", 239, "ISO 3166-2:GS", ".gs") + case CountryCode.SS: return createCountry(CountryCode.SS, Sovereignty.UN_MEMBER_STATE, "SSD", 728, "ISO 3166-2:SS", ".ss") + case CountryCode.ES: return createCountry(CountryCode.ES, Sovereignty.UN_MEMBER_STATE, "ESP", 724, "ISO 3166-2:ES", ".es") + case CountryCode.LK: return createCountry(CountryCode.LK, Sovereignty.UN_MEMBER_STATE, "LKA", 144, "ISO 3166-2:LK", ".lk") + case CountryCode.SD: return createCountry(CountryCode.SD, Sovereignty.UN_MEMBER_STATE, "SDN", 729, "ISO 3166-2:SD", ".sd") + case CountryCode.SR: return createCountry(CountryCode.SR, Sovereignty.UN_MEMBER_STATE, "SUR", 740, "ISO 3166-2:SR", ".sr") + case CountryCode.SJ: return createCountry(CountryCode.SJ, Sovereignty.NORWAY, "SJM", 744, "ISO 3166-2:SJ", "") + case CountryCode.SE: return createCountry(CountryCode.SE, Sovereignty.UN_MEMBER_STATE, "SWE", 752, "ISO 3166-2:SE", ".se") + case CountryCode.CH: return createCountry(CountryCode.CH, Sovereignty.UN_MEMBER_STATE, "CHE", 756, "ISO 3166-2:CH", ".ch") + case CountryCode.SY: return createCountry(CountryCode.SY, Sovereignty.UN_MEMBER_STATE, "SYR", 760, "ISO 3166-2:SY", ".sy") + case CountryCode.TW: return createCountry(CountryCode.TW, Sovereignty.DISPUTED_Z, "TWN", 158, "ISO 3166-2:TW", ".tw") + case CountryCode.TJ: return createCountry(CountryCode.TJ, Sovereignty.UN_MEMBER_STATE, "TJK", 762, "ISO 3166-2:TJ", ".tj") + case CountryCode.TZ: return createCountry(CountryCode.TZ, Sovereignty.UN_MEMBER_STATE, "TZA", 834, "ISO 3166-2:TZ", ".tz") + case CountryCode.TH: return createCountry(CountryCode.TH, Sovereignty.UN_MEMBER_STATE, "THA", 764, "ISO 3166-2:TH", ".th") + case CountryCode.TL: return createCountry(CountryCode.TL, Sovereignty.UN_MEMBER_STATE, "TLS", 626, "ISO 3166-2:TL", ".tl") + case CountryCode.TG: return createCountry(CountryCode.TG, Sovereignty.UN_MEMBER_STATE, "TGO", 768, "ISO 3166-2:TG", ".tg") + case CountryCode.TK: return createCountry(CountryCode.TK, Sovereignty.NEW_ZEALAND, "TKL", 772, "ISO 3166-2:TK", ".tk") + case CountryCode.TO: return createCountry(CountryCode.TO, Sovereignty.UN_MEMBER_STATE, "TON", 776, "ISO 3166-2:TO", ".to") + case CountryCode.TT: return createCountry(CountryCode.TT, Sovereignty.UN_MEMBER_STATE, "TTO", 780, "ISO 3166-2:TT", ".tt") + case CountryCode.TN: return createCountry(CountryCode.TN, Sovereignty.UN_MEMBER_STATE, "TUN", 788, "ISO 3166-2:TN", ".tn") + case CountryCode.TR: return createCountry(CountryCode.TR, Sovereignty.UN_MEMBER_STATE, "TUR", 792, "ISO 3166-2:TR", ".tr") + case CountryCode.TM: return createCountry(CountryCode.TM, Sovereignty.UN_MEMBER_STATE, "TKM", 795, "ISO 3166-2:TM", ".tm") + case CountryCode.TC: return createCountry(CountryCode.TC, Sovereignty.UNITED_KINGDOM, "TCA", 796, "ISO 3166-2:TC", ".tc") + case CountryCode.TV: return createCountry(CountryCode.TV, Sovereignty.UN_MEMBER_STATE, "TUV", 798, "ISO 3166-2:TV", ".tv") + case CountryCode.UG: return createCountry(CountryCode.UG, Sovereignty.UN_MEMBER_STATE, "UGA", 800, "ISO 3166-2:UG", ".ug") + case CountryCode.UA: return createCountry(CountryCode.UA, Sovereignty.UN_MEMBER_STATE, "UKR", 804, "ISO 3166-2:UA", ".ua") + case CountryCode.AE: return createCountry(CountryCode.AE, Sovereignty.UN_MEMBER_STATE, "ARE", 784, "ISO 3166-2:AE", ".ae") + case CountryCode.GB: return createCountry(CountryCode.GB, Sovereignty.UN_MEMBER_STATE, "GBR", 826, "ISO 3166-2:GB", ".gb .uk") + case CountryCode.UM: return createCountry(CountryCode.UM, Sovereignty.UNITED_STATES, "UMI", 581, "ISO 3166-2:UM", "") + case CountryCode.US: return createCountry(CountryCode.US, Sovereignty.UN_MEMBER_STATE, "USA", 840, "ISO 3166-2:US", ".us") + case CountryCode.UY: return createCountry(CountryCode.UY, Sovereignty.UN_MEMBER_STATE, "URY", 858, "ISO 3166-2:UY", ".uy") + case CountryCode.UZ: return createCountry(CountryCode.UZ, Sovereignty.UN_MEMBER_STATE, "UZB", 860, "ISO 3166-2:UZ", ".uz") + case CountryCode.VU: return createCountry(CountryCode.VU, Sovereignty.UN_MEMBER_STATE, "VUT", 548, "ISO 3166-2:VU", ".vu") + case CountryCode.VE: return createCountry(CountryCode.VE, Sovereignty.UN_MEMBER_STATE, "VEN", 862, "ISO 3166-2:VE", ".ve") + case CountryCode.VN: return createCountry(CountryCode.VN, Sovereignty.UN_MEMBER_STATE, "VNM", 704, "ISO 3166-2:VN", ".vn") + case CountryCode.VG: return createCountry(CountryCode.VG, Sovereignty.UNITED_KINGDOM, "VGB", 92, "ISO 3166-2:VG", ".vg") + case CountryCode.VI: return createCountry(CountryCode.VI, Sovereignty.UNITED_STATES, "VIR", 850, "ISO 3166-2:VI", ".vi") + case CountryCode.WF: return createCountry(CountryCode.WF, Sovereignty.FRANCE, "WLF", 876, "ISO 3166-2:WF", ".wf") + case CountryCode.EH: return createCountry(CountryCode.EH, Sovereignty.DISPUTED_AI, "ESH", 732, "ISO 3166-2:EH", "") + case CountryCode.YE: return createCountry(CountryCode.YE, Sovereignty.UN_MEMBER_STATE, "YEM", 887, "ISO 3166-2:YE", ".ye") + case CountryCode.ZM: return createCountry(CountryCode.ZM, Sovereignty.UN_MEMBER_STATE, "ZMB", 894, "ISO 3166-2:ZM", ".zm") + case CountryCode.ZW: return createCountry(CountryCode.ZW, Sovereignty.UN_MEMBER_STATE, "ZWE", 716, "ISO 3166-2:ZW", ".zw") + } + throw new TypeError(`Unknown country code: ${code}`) + } + + public static createCountryAutoCompleteValues ( + list: readonly CountryCode[], + t: TranslationFunction + ) : CountryAutoCompleteMapping { + return map( + list, + (item : CountryCode) => + [ + item, + [ t(getCountryNameTranslationKey(item)) ] + ] + ); + } + + public static parseCountry ( + name : string, + t : TranslationFunction + ) : Country | undefined { + name = toLower(trim(name)).replace(/ +/g, " "); + const allCountries = this.getCountryList(); + return find( + allCountries, + (item: Country) : boolean => { + + const iso2 = item.iso2; + if (toLower(iso2) === name) return true; + + const iso3 = item.iso3; + if (toLower(iso3) === name) return true; + + if (isCountryCode(iso2) && toLower(trim(t(getCountryNameTranslationKey(iso2)))).replace(/ +/g, " ") === name) { + // console.log(`key =`, getCountryNameTranslationKey(iso2)); + return true; + } + + return false; + } + ); + } + +} diff --git a/Csv.test.ts b/Csv.test.ts new file mode 100644 index 0000000..d4a9e3a --- /dev/null +++ b/Csv.test.ts @@ -0,0 +1,388 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { stringifyCsv } from "./Csv"; + +const properties = [ + 'ticketNumber', + 'title', + 'categoryType', + 'state', + 'priority', + 'requester', + 'cost', + 'detail', + 'currency', + 'status', + 'dueDate', + 'workspaceId', + 'workspace', + 'supplierId', + 'supplier', + 'approver', + 'labels' +]; + +const firstLine = ['1', 'test-title', 'SALES', 'WAITING', 'MEDIUM', 'Mike', '1200', 'Oh yes! I have some text here for you to test.', 'EUR', 'WAITING']; +const secondLine = ['2', 'second-test-title', 'ICT', 'WAITING', 'HIGH', 'Jacob', '2200,12', 'Oh yeah I ended up ordering, some new stuff; and so on.', 'EUR', 'WAITING']; +const thirdLine = ['3', 'third-test-title', 'ICT', 'WAITING', 'HIGH', 'Jacob', '2200,12', 'Hermot ei kestä kaamosta, pitää päästä lepuuttamaan', 'EUR', 'WAITING']; + +describe('Csv', () => { + + describe('#stringifyCsv', () => { + + test('can stringify csv data', () => { + const result = stringifyCsv([properties, firstLine, secondLine], ',', '"', '\n'); + expect(typeof result).toBe("string"); + }); + + test('can stringify csv headers correctly', () => { + const result = stringifyCsv([properties, firstLine, secondLine], ';', "'", '\t'); + const headerLine = result.split('\t').shift(); + expect(headerLine).toStrictEqual( + 'ticketNumber' + +';title' + +';categoryType' + +';state' + +';priority' + +';requester' + +';cost' + +';detail' + +';currency' + +';status' + +';dueDate' + +';workspaceId' + +';workspace' + +';supplierId' + +';supplier' + +';approver' + +';labels' + ); + }); + + test('can stringify csv data without cvs control characters', () => { + const result = stringifyCsv([properties, firstLine, secondLine], ';', "'", '\t'); + const rows = result.split('\t'); + expect(rows[1]).toStrictEqual( + '1' + +';test-title' + +';SALES' + +';WAITING' + +';MEDIUM' + +';Mike' + +';1200' + +';Oh yes! I have some text here for you to test.' + +';EUR' + +';WAITING' + ); + }) + + test('can stringify csv data with leading cvs quote characters', () => { + const result = stringifyCsv( + [ + [ 'id', 'name' ], + [ '1', '"name' ] + ], + ',', + '"', + '\n' + ); + const rows = result.split('\n'); + expect(rows[1]).toStrictEqual('1,"""name"'); + }) + + test('can stringify csv data with middle cvs quote characters', () => { + const result = stringifyCsv( + [ + [ 'id', 'name' ], + [ '1', 'hello"world' ] + ], + ',', + '"', + '\n' + ); + const rows = result.split('\n'); + expect(rows[1]).toStrictEqual('1,hello"world'); + }) + + test('can stringify csv data with cvs quote characters both ways', () => { + const result = stringifyCsv( + [ + [ 'id', 'name' ], + [ '1', '"hello"world"' ] + ], + ',', + '"', + '\n' + ); + const rows = result.split('\n'); + expect(rows[1]).toStrictEqual('1,"""hello""world"""'); + }) + + test('can stringify csv data with suffix cvs quote characters', () => { + const result = stringifyCsv( + [ + [ 'id', 'name' ], + [ '1', 'helloworld"' ] + ], + ',', + '"', + '\n' + ); + const rows = result.split('\n'); + expect(rows[1]).toStrictEqual('1,helloworld"'); + }) + + test('can stringify csv data with csv control characters', () => { + const result = stringifyCsv([properties, firstLine, secondLine], ';', "'", '\t'); + const rows = result.split('\t'); + expect(rows[2]).toStrictEqual( + '2' + +';second-test-title' + +';ICT' + +';WAITING' + +';HIGH' + +';Jacob' + +';2200,12' + +";'Oh yeah I ended up ordering, some new stuff; and so on.'" + +';EUR' + +';WAITING' + ); + }); + + test('can stringify csv data with unicode characters', () => { + const result = stringifyCsv([properties, firstLine, secondLine, thirdLine], ',', '"', '\n'); + const rows = result.split('\n'); + expect(rows[3]).toStrictEqual( + '3' + +',third-test-title' + +',ICT' + +',WAITING' + +',HIGH' + +',Jacob' + +',"2200,12"' + +',"Hermot ei kestä kaamosta, pitää päästä lepuuttamaan"' + +',EUR' + +',WAITING' + ); + }); + + test('can stringify csv data with empty separator characters (using default)', () => { + const result = stringifyCsv([properties, firstLine, secondLine, thirdLine], '', '"', '\n'); + const rows = result.split('\n'); + expect(rows[3]).toStrictEqual( + '3' + +',third-test-title' + +',ICT' + +',WAITING' + +',HIGH' + +',Jacob' + +',"2200,12"' + +',"Hermot ei kestä kaamosta, pitää päästä lepuuttamaan"' + +',EUR' + +',WAITING' + ); + }); + + test('can stringify csv data with empty quote characters (using default)', () => { + const result = stringifyCsv([properties, firstLine, secondLine, thirdLine], ',', '', '\n'); + const rows = result.split('\n'); + expect(rows[3]).toStrictEqual( + '3' + +',third-test-title' + +',ICT' + +',WAITING' + +',HIGH' + +',Jacob' + +',"2200,12"' + +',"Hermot ei kestä kaamosta, pitää päästä lepuuttamaan"' + +',EUR' + +',WAITING' + ); + }); + + test('can stringify csv data with empty line break characters (using default)', () => { + const result = stringifyCsv([properties, firstLine, secondLine, thirdLine], ',', '"', ''); + const rows = result.split('\n'); + expect(rows[3]).toStrictEqual( + '3' + +',third-test-title' + +',ICT' + +',WAITING' + +',HIGH' + +',Jacob' + +',"2200,12"' + +',"Hermot ei kestä kaamosta, pitää päästä lepuuttamaan"' + +',EUR' + +',WAITING' + ); + }); + + test('can stringify csv data with linebreak replaced (using default)', () => { + const result = stringifyCsv( + [ + properties, firstLine, secondLine, + + [ '3', 'forth-test-title', 'ICT', 'WAITING', 'HIGH', 'Jacob', '2200,12', 'Hermot ei kestä kaamosta, ' + '\n' + + 'pitää päästä lepuuttamaan' + '\n' + + 'testi', 'EUR', 'WAITING' + ] + ], ',', '', '\n', ' '); + const rows = result.split('\n'); + expect(rows[3]).toStrictEqual( + '3' + +',forth-test-title' + +',ICT' + +',WAITING' + +',HIGH' + +',Jacob' + +',"2200,12"' + +',"Hermot ei kestä kaamosta, pitää päästä lepuuttamaan testi"' + +',EUR' + +',WAITING' + ); + }); + + test('can stringify csv data with data linebreak replaced', () => { + const result = stringifyCsv( + [ + [ 'ticketNumber', + 'title', + 'categoryType', + 'state', + 'priority', + 'requester', + 'cost', + 'detail', + 'currency', + 'status', + 'dueDate', + 'workspaceId', 'workspace', 'supplierId', 'supplier', 'approver', 'labels' + ], + [ + '1', 'muumipapan hattu', 'OTHER', 'REQUEST', 'STANDARD', 'haisuli@heusalagroup.fi', '45', + 'Kissa ei \nkestä kaamosta - pitää\npäästä koiruuksia tekemään', 'EUR', 'WAITING_APPROVAL', '2022-11-04T00:00:00+02:00', '!FSlIOckQywLzmGoePz:matrix.my.host', 'pappa', '', '', '' ] + ] + , + ',', '"', '\n', ' ' + ); + const rows = result.split('\n'); + expect(rows[1]).toStrictEqual( + '1' + +',muumipapan hattu' + +',OTHER' + +',REQUEST' + +',STANDARD' + +',haisuli@heusalagroup.fi' + +',45' + +',Kissa ei kestä kaamosta - pitää päästä koiruuksia tekemään' + +',EUR' + +',WAITING_APPROVAL' + +',2022-11-04T00:00:00+02:00' + +',!FSlIOckQywLzmGoePz:matrix.my.host' + +',pappa' + +',' + +',' + +',' + ); + }); + + test('can stringify csv data with data linebreak removed', () => { + const result = stringifyCsv( + [ + [ 'ticketNumber', + 'title', + 'categoryType', + 'state', + 'priority', + 'requester', + 'cost', + 'detail', + 'currency', + 'status', + 'dueDate', + 'workspaceId', 'workspace', 'supplierId', 'supplier', 'approver', 'labels' + ], + [ + '1', 'muumipapan hattu', 'OTHER', 'REQUEST', 'STANDARD', 'haisuli@heusalagroup.fi', '45', + 'Kissa ei \nkestä kaamosta - pitää\npäästä koiruuksia tekemään', 'EUR', 'WAITING_APPROVAL', '2022-11-04T00:00:00+02:00', '!FSlIOckQywLzmGoePz:matrix.my.host', 'pappa', '', '', '' ] + ] + , + ',', '"', '\n', '' + ); + const rows = result.split('\n'); + expect(rows[1]).toStrictEqual( + '1' + +',muumipapan hattu' + +',OTHER' + +',REQUEST' + +',STANDARD' + +',haisuli@heusalagroup.fi' + +',45' + +',Kissa ei kestä kaamosta - pitääpäästä koiruuksia tekemään' + +',EUR' + +',WAITING_APPROVAL' + +',2022-11-04T00:00:00+02:00' + +',!FSlIOckQywLzmGoePz:matrix.my.host' + +',pappa' + +',' + +',' + +',' + ); + }); + + test('can stringify csv data with data linebreak kept', () => { + const result = stringifyCsv( + [ + [ 'ticketNumber', + 'title', + 'categoryType', + 'state', + 'priority', + 'requester', + 'cost', + 'detail', + 'currency', + 'status', + 'dueDate', + 'workspaceId', 'workspace', 'supplierId', 'supplier', 'approver', 'labels' + ], + [ + '1', 'muumipapan hattu', 'OTHER', 'REQUEST', 'STANDARD', 'haisuli@heusalagroup.fi', '45', + 'Kissa ei \nkestä kaamosta - pitää\npäästä koiruuksia tekemään', 'EUR', 'WAITING_APPROVAL', '2022-11-04T00:00:00+02:00', '!FSlIOckQywLzmGoePz:matrix.my.host', 'pappa', '', '', '' ] + ] + , + ',', + '"', + '\n', + false + ); + const rows = result.split('\n'); + expect(rows[1]).toStrictEqual( + '1' + +',muumipapan hattu' + +',OTHER' + +',REQUEST' + +',STANDARD' + +',haisuli@heusalagroup.fi' + +',45' + +',Kissa ei ' + ); + expect(rows[2]).toStrictEqual( + 'kestä kaamosta - pitää' + ); + expect(rows[3]).toStrictEqual( + 'päästä koiruuksia tekemään' + +',EUR' + +',WAITING_APPROVAL' + +',2022-11-04T00:00:00+02:00' + +',!FSlIOckQywLzmGoePz:matrix.my.host' + +',pappa' + +',' + +',' + +',' + ); + }); + + }); + +}); diff --git a/Csv.ts b/Csv.ts new file mode 100644 index 0000000..abdf311 --- /dev/null +++ b/Csv.ts @@ -0,0 +1,331 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +/** + * This module provides functions for working with CSV (Comma Separated Values) + * data in TypeScript. It includes functions for parsing CSV data into an array + * of rows (each of which is an array of cell values), and for converting an + * array of rows into CSV data as a string. + * @see https://docs.hg.fi/common/csv/ + * @file + */ + +import { endsWith} from "./functions/endsWith"; +import { get } from "./functions/get"; +import { has } from "./functions/has"; +import { map } from "./functions/map"; +import { split } from "./functions/split"; +import { startsWith } from "./functions/startsWith"; + +import { ReadonlyJsonObject } from "./Json"; +import { isArray, isArrayOf } from "./types/Array"; +import { isString } from "./types/String"; +import { replaceAll } from "./functions/replaceAll"; +import { keys } from "./functions/keys"; + +export const DEFAULT_CSV_SEPARATOR = ','; +export const DEFAULT_CSV_QUOTE = '"'; +export const DEFAULT_CSV_LINE_BREAK = '\n'; +export const DEFAULT_CSV_LINE_BREAK_REPLACE_CHARACTER : string = ' '; + +// FIXME: Add unit tests +export type CsvRow = string[]; +export type Csv = CsvRow[]; + +export type ReadonlyCsvRow = readonly string[]; +export type ReadonlyCsv = ReadonlyCsvRow[]; + +export function isCsvRow (value: any): value is CsvRow { + return isArrayOf(value, isString); +} + +export function isCsv (value: any): value is Csv { + return isArrayOf(value, isCsvRow); +} + +export interface CsvPropertyTransformerCallback { + (item: T, key: string) : string; +} + +export interface CsvPropertyTransformerMap { + [key: string]: CsvPropertyTransformerCallback; +} + +/** + * The `stringifyCsvCellValue` function converts a single cell value into a string + * suitable for inclusion in a CSV file. It handles arrays by joining the + * elements with commas. If the input value is undefined, an empty string is + * returned. + * + * @param value + */ +export function stringifyCsvCellValue (value: any) : string { + if (value === undefined) return ''; + if (isArray(value)) { + return `${value.join(',') ?? ''}`; + } + return `${value ?? ''}`; +} + +/** + * The `getCsvCellFromProperty` function retrieves the value of a property from an + * object and converts it to a string suitable for inclusion in a CSV file using + * the stringifyCsvCellValue function. It takes an object and the name of the + * property to retrieve as parameters. + * + * @param item + * @param key + */ +export function getCsvCellFromProperty (item: T, key: string) : string { + return stringifyCsvCellValue( get(item, key) ); +} + +/** + * The `getCsvRowFromJsonObject` function generates a row of CSV data from an + * object. It takes an object, an array of property names to include in the row, + * and an optional map of property transformers as parameters. The property + * transformers are functions that take an object and a property name, and + * return the value to be included in the row for that property. If no property + * transformer is provided for a given property, the value of the property is + * retrieved using the `getCsvCellFromProperty` function. + * + * @param item + * @param properties + * @param propertyTransformer + */ +export function getCsvRowFromJsonObject ( + item: T, + properties: readonly string[], + propertyTransformer : CsvPropertyTransformerMap = {} +): CsvRow { + return map( + properties, + (key: string): string => { + if (has(propertyTransformer, key)) { + return stringifyCsvCellValue( propertyTransformer[key](item, key) ); + } + return getCsvCellFromProperty(item, key); + } + ); +} + +/** + * The `getCsvFromJsonObjectList` function generates a CSV file from a list of + * objects. It takes a list of objects, an optional array of property names to + * include in the file, an optional flag indicating whether to include a header + * row with the property names, and an optional map of property transformers as + * parameters. It uses the `getCsvRowFromJsonObject` function to generate rows for + * each object in the list. + * + * If no property names are provided, the properties of the first object in the + * list are used. + * + * @param list + * @param properties + * @param includeHeader If the `includeHeader` flag is `true`, the + * first row of the CSV file will be a header row with the property names. + * If the `includeHeader` flag is `false` or not provided, no header row will be + * included. + * @param propertyTransformer + */ +export function getCsvFromJsonObjectList ( + list: readonly T[], + properties: readonly string[] | undefined = undefined, + includeHeader : boolean = true, + propertyTransformer : CsvPropertyTransformerMap = {} +): Csv { + + const keyList : CsvRow = ( + properties === undefined ? ( + list.length === 0 ? [] : keys(list[0]) + ) : ( + map(properties, (item: string) : string => item) + ) + ); + + const rows : Csv = map( + list, + (item: T): CsvRow => getCsvRowFromJsonObject( + item, + keyList, + propertyTransformer + ) + ); + + if (includeHeader) { + return [ + keyList, + ...rows + ]; + } + + return rows; +} + +/** + * The `parseCsvRow` function parses a single row of CSV data into an array of + * cell values. It takes the row to be parsed as a string, and optional + * separator and quote characters as parameters. If no separator character is + * provided, the default value of ',' (a comma) is used. If no quote character + * is provided, the default value of '"' (a double quote) is used. The function + * handles quoted cell values and escaped quote characters within cell values. + * + * + * @fixme Add support to parse quoted line breaks + * @param value + * @param separator + * @param quote + */ +export function parseCsvRow ( + value: any, + separator: string = DEFAULT_CSV_SEPARATOR, + quote: string = DEFAULT_CSV_QUOTE +): CsvRow { + + separator = separator ? separator : DEFAULT_CSV_SEPARATOR; + quote = quote ? quote : DEFAULT_CSV_QUOTE; + + if ( separator?.length !== 1 ) { + throw new TypeError(`The separator must be exactly 1 character long: ${separator}`); + } + + if ( quote?.length !== 1 ) { + throw new TypeError(`The quote must be exactly 1 character long: ${quote}`); + } + + if ( isCsvRow(value) ) { + return value; + } + + if ( !isString(value) ) { + value = `${value}`; + } + + let pieces: string[] = []; + let lastIndex = 0; + while ( lastIndex < value.length ) { + + const nextIndex = value.indexOf(separator, lastIndex); + + if ( nextIndex < 0 ) { + pieces.push(value.substr(lastIndex)); + lastIndex = pieces.length; + break; + } + + let piece = value.substr(lastIndex, nextIndex - lastIndex); + if ( piece.length >= 2 && startsWith(piece, quote) && endsWith(piece, quote) ) { + piece = piece.substr(1, piece.length - 2).split(piece + piece).join(piece); + } + pieces.push(piece); + lastIndex = nextIndex + 1; + + } + + return pieces; + +} + +/** + * + * @fixme Add support to detect if the input was just a single CsvRow + * @fixme Add support to convert arrays with (JSON able) objects as Csv + * + * @param value + * @param separator + * @param quote + * @param lineBreak + */ +export function parseCsv ( + value: any, + separator: string = DEFAULT_CSV_SEPARATOR, + quote: string = DEFAULT_CSV_QUOTE, + lineBreak: string = DEFAULT_CSV_LINE_BREAK +): Csv | undefined { + separator = separator ? separator : DEFAULT_CSV_SEPARATOR; + quote = quote ? quote : DEFAULT_CSV_QUOTE; + lineBreak = lineBreak ? lineBreak : DEFAULT_CSV_LINE_BREAK; + if ( isCsv(value) ) return value; + if ( !isString(value) ) { + value = `${value}`; + } + return map( + split(value, lineBreak), + (item: any): CsvRow => parseCsvRow(item, separator, quote) + ); +} + +export function stringifyCsvRow ( + value: CsvRow, + separator: string = DEFAULT_CSV_SEPARATOR, + quote: string = DEFAULT_CSV_QUOTE, +): string { + separator = separator ? separator : DEFAULT_CSV_SEPARATOR; + quote = quote ? quote : DEFAULT_CSV_QUOTE; + return map(value, (column: string) => { + if ( column.length === 0 ) return column; + if ( column.indexOf(separator) >= 0 || (column[0] === quote) ) { + if ( column.indexOf(quote) >= 0 ) { + return `${quote}${column.split(quote).join(quote + quote)}${quote}`; + } else { + return `${quote}${column}${quote}`; + } + } else { + return column; + } + }).join(separator); +} + +/** + * @param value + * @param separator + * @param quote + * @param lineBreak + * @param replaceLineBreak + */ +export function stringifyCsv ( + value : Csv, + separator : string = DEFAULT_CSV_SEPARATOR, + quote : string = DEFAULT_CSV_QUOTE, + lineBreak : string = DEFAULT_CSV_LINE_BREAK, + replaceLineBreak : string | false = DEFAULT_CSV_LINE_BREAK_REPLACE_CHARACTER +): string { + separator = separator ? separator : DEFAULT_CSV_SEPARATOR; + quote = quote ? quote : DEFAULT_CSV_QUOTE; + lineBreak = lineBreak ? lineBreak : DEFAULT_CSV_LINE_BREAK; + + if (replaceLineBreak !== false ) { + value = replaceCsvContentLineBreaks( + value, + lineBreak, + replaceLineBreak + ); + } + + return map( + value, + (row: CsvRow) => stringifyCsvRow(row, separator, quote) + ).join(lineBreak); +} + +/** + * Can be used to modify Csv data structure so that line breaks in the Csv content + * are replaced to different character + * @param value + * @param lineBreak + * @param replaceTo + */ +export function replaceCsvContentLineBreaks ( + value : Csv, + lineBreak : string = DEFAULT_CSV_LINE_BREAK, + replaceTo : string = DEFAULT_CSV_LINE_BREAK_REPLACE_CHARACTER +) : Csv { + return map( + value, + (row: CsvRow) : CsvRow => + map( + row, + (column: string): string => replaceAll(column, lineBreak, replaceTo) + ) + ); +} diff --git a/CurrencyService.ts b/CurrencyService.ts new file mode 100644 index 0000000..9e915d4 --- /dev/null +++ b/CurrencyService.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { Currency } from "./types/Currency"; +import { CurrencyFetchRatesCallback } from "./types/CurrencyFetchRatesCallback"; +import { LogService } from "./LogService"; +import { CurrencyRates } from "./types/CurrencyRates"; +import { CurrencyUtils } from "./CurrencyUtils"; +import { Observer, ObserverCallback, ObserverDestructor } from "./Observer"; +import { Disposable } from "./types/Disposable"; + +const LOG = LogService.createLogger('CurrencyService'); + +const DEFAULT_FETCH_INTERVAL_SECONDS = 24*60*60; + +export enum CurrencyServiceEvent { + INITIALIZED = "CurrencyService:initialized", + RATES_UPDATED = "CurrencyService:ratesUpdated", + STARTED = "CurrencyService:started", + STOPPED = "CurrencyService:stopped", +} + +export type CurrencyServiceDestructor = ObserverDestructor; + +export class CurrencyService implements Disposable { + + public static Event = CurrencyServiceEvent; + + private readonly _observer : Observer; + private readonly _fetchRatesCallback : CurrencyFetchRatesCallback; + private readonly _fetchIntervalMinutes : number; + + private _rates : CurrencyRates | undefined; + private _fetchIntervalId : any | undefined; + private _initializing : boolean = false; + + /** + * + * @param callback + * @param fetchInterval + */ + public constructor ( + callback: CurrencyFetchRatesCallback, + fetchInterval : number = DEFAULT_FETCH_INTERVAL_SECONDS + ) { + this._observer = new Observer("CurrencyService"); + this._fetchRatesCallback = callback; + this._rates = undefined; + this._fetchIntervalMinutes = fetchInterval; + } + + public on ( + name: CurrencyServiceEvent, + callback: ObserverCallback + ): CurrencyServiceDestructor { + return this._observer.listenEvent(name, callback); + } + + public async initialize () : Promise { + this._initializing = true; + try { + await this._updateRates(); + this.start(); + } finally { + this._initializing = false; + } + if (this._observer.hasCallbacks(CurrencyServiceEvent.INITIALIZED)) { + this._observer.triggerEvent(CurrencyServiceEvent.INITIALIZED) + } + } + + public destroy (): void { + this.stop(); + this._observer.destroy(); + } + + public start () { + if (this._fetchIntervalId === undefined) { + this._startInterval(); + } + } + + public stop () { + if (this._fetchIntervalId !== undefined) { + this._stopInterval(); + } + } + + public isInitializing () : boolean { + return this._initializing; + } + + public isStarted () : boolean { + return this._fetchIntervalId !== undefined; + } + + public hasRates () : boolean { + return this._rates !== undefined; + } + + public getRates () : CurrencyRates | undefined { + return this._rates; + } + + /** + * Set rates directly + * @param rates + */ + public setRates (rates : CurrencyRates) { + if (rates !== this._rates) { + this._rates = rates; + if (this._observer.hasCallbacks(CurrencyServiceEvent.RATES_UPDATED)) { + this._observer.triggerEvent(CurrencyServiceEvent.RATES_UPDATED) + } + } + } + + /** + * Update rates using fetch callback + */ + public updateRates () { + this._updateRates().catch((err) => { + LOG.error(`Could not update rates: `, err); + }); + } + + /** + * + * @param amount + * @param from + * @param to + * @param accuracy + */ + public convertCurrencyAmount ( + amount : number, + from : Currency, + to : Currency, + accuracy : number + ) : number { + if (this._rates === undefined) { + throw new TypeError(`CurrencyService does not have rates defined yet`); + } + return CurrencyUtils.convertCurrencyAmount(this._rates, amount, from, to, accuracy); + } + + /** + * + * @private + */ + private async _updateRates () { + this._rates = await this._fetchRatesCallback(); + if (this._observer.hasCallbacks(CurrencyServiceEvent.RATES_UPDATED)) { + this._observer.triggerEvent(CurrencyServiceEvent.RATES_UPDATED) + } + } + + private _stopInterval () { + clearInterval(this._fetchIntervalId); + this._fetchIntervalId = undefined; + if (this._observer.hasCallbacks(CurrencyServiceEvent.STOPPED)) { + this._observer.triggerEvent(CurrencyServiceEvent.STOPPED) + } + } + + private _startInterval () { + if (this._fetchIntervalId !== undefined) { + this._stopInterval(); + } + this._fetchIntervalId = setInterval( + () => { + this.updateRates(); + }, + this._fetchIntervalMinutes * 1000 + ); + if (this._observer.hasCallbacks(CurrencyServiceEvent.STARTED)) { + this._observer.triggerEvent(CurrencyServiceEvent.STARTED) + } + } + +} diff --git a/CurrencyUtils.test.ts b/CurrencyUtils.test.ts new file mode 100644 index 0000000..db7c0ef --- /dev/null +++ b/CurrencyUtils.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CurrencyUtils } from "./CurrencyUtils"; +import { createCurrencyRates, CurrencyRates } from "./types/CurrencyRates"; +import { Currency } from "./types/Currency"; + +describe('CurrencyUtils', () => { + + const validRates: CurrencyRates = createCurrencyRates(1.2, 0.85); + + describe('stringifySum', () => { + it('converts a number to a string with two decimal places', () => { + expect(CurrencyUtils.stringifySum(1234.5678)).toEqual('1234.57'); + }); + }); + + describe('getSum', () => { + + it('calculates the sum of price and amount considering a discount', () => { + expect(CurrencyUtils.getSum(20, 5, 0.10)).toEqual(90); + }); + + }); + + describe('getSumWithVat', () => { + it('calculates the total sum including VAT and considering a discount', () => { + expect(CurrencyUtils.getSumWithVat(20, 5, 0.1, 0.10)).toEqual(99); + }); + }); + + describe('getSumWithDiscount', () => { + it('calculates the sum considering a discount', () => { + expect(CurrencyUtils.getSumWithDiscount(200, 0.10)).toEqual(180); + }); + }); + + describe('getVatlessSum', () => { + it('calculates the sum without VAT and considering a discount', () => { + expect(CurrencyUtils.getVatlessSum(110, 0.1, 0.10)).toEqual(90); + }); + }); + + describe('roundByAccuracy', () => { + it('rounds a number by a specified accuracy', () => { + expect(CurrencyUtils.roundByAccuracy(1234.5678, 2)).toEqual(1234.57); + }); + }); + + describe('convertCurrencyAmount', () => { + + it('converts a currency amount from one currency to another', () => { + expect(CurrencyUtils.convertCurrencyAmount(validRates, 100, Currency.EUR, Currency.USD, 2)).toEqual(120); + }); + + it('throws an error when to-currency is invalid', () => { + expect(() => CurrencyUtils.convertCurrencyAmount(validRates, 100, Currency.EUR, + // @ts-ignore + 'INVALID', + 2)).toThrow('CurrencyService: To: No exchange rate found: INVALID'); + }); + + it('throws an error when from-currency is invalid', () => { + expect(() => CurrencyUtils.convertCurrencyAmount(validRates, 100, + // @ts-ignore + 'INVALID', + Currency.USD, 2)).toThrow('CurrencyService: From: No exchange rate found: INVALID'); + }); + }); + + describe('getCents', () => { + it('converts a decimal number to cents', () => { + expect(CurrencyUtils.getCents(1234.5678)).toEqual(123457); + }); + }); + +}); diff --git a/CurrencyUtils.ts b/CurrencyUtils.ts new file mode 100644 index 0000000..f6d75b6 --- /dev/null +++ b/CurrencyUtils.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { get } from "./functions/get"; +import { Currency } from "./types/Currency"; +import { CurrencyRates } from "./types/CurrencyRates"; + +export class CurrencyUtils { + + public static stringifySum ( + sum : number + ) : string { + return (Math.round(sum*100)/100).toFixed(2); + } + + public static getSum ( + price : number, + amount : number, + discountPercent ?: number | undefined + ): number { + return this.getSumWithDiscount(amount * price, discountPercent); + } + + /** + * Returns the total sum including VAT + * @param price The price without VAT + * @param amount The amount of items + * @param vatPercent The VAT percentage + * @param discountPercent The discount percentage + */ + public static getSumWithVat ( + price : number, + amount : number, + vatPercent : number, + discountPercent ?: number | undefined + ): number { + const sum = this.getSumWithDiscount(amount * price, discountPercent); + return Math.round( (sum*100) + (sum*100) * vatPercent ) / 100; + } + + public static getSumWithDiscount ( + price : number, + discountPercent : number | undefined + ): number { + return discountPercent !== undefined && discountPercent > 0 && discountPercent <= 1 ? price - price * discountPercent : price; + } + + public static getVatlessSum ( + sum: number, + vatPercent: number, + discountPercent ?: number | undefined + ) : number { + const realSum = this.getSumWithDiscount(sum, discountPercent); + return (realSum * 100 / (1+vatPercent)) / 100; + } + + public static roundByAccuracy ( + value: number, + accuracy: number + ) { + const m = Math.pow(10, accuracy); + return Math.round(value * m) / m; + } + + public static convertCurrencyAmount ( + rates : CurrencyRates, + amount : number, + from : Currency, + to : Currency, + accuracy : number = 10 + ) : number { + const toRate = get(rates, to); + if (toRate === undefined) throw new TypeError(`CurrencyService: To: No exchange rate found: ${to}`); + const fromRate = get(rates, from); + if (fromRate === undefined) throw new TypeError(`CurrencyService: From: No exchange rate found: ${from}`); + return CurrencyUtils.roundByAccuracy( (amount / fromRate) * toRate, accuracy); + } + + public static getCents (value: number) : number { + return Math.round(value*100); + } + + public static fromCents (value: number) : number { + return Math.round(value) / 100; + } + +} diff --git a/DateUtils.test.ts b/DateUtils.test.ts new file mode 100644 index 0000000..93212a7 --- /dev/null +++ b/DateUtils.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DateUtils } from "./DateUtils"; + +describe('DateUtils', () => { + + describe('#cloneDate', () => { + it('should create a new Date object that is a copy of the provided date', () => { + const originalDate = new Date('2023-07-25T12:34:56.789Z'); + const clonedDate = DateUtils.cloneDate(originalDate); + expect(clonedDate).toEqual(originalDate); + expect(clonedDate).not.toBe(originalDate); // Make sure the dates are not the same object + }); + }); + + describe('#subtractDays', () => { + it('should subtract a specified number of days from the given date', () => { + const startDate = new Date(2023,6,25,0,0,0,0); + const offset = 5; + const resultDate = DateUtils.subtractDays(startDate, offset); + expect(resultDate.getFullYear()).toBe(2023); + expect(resultDate.getMonth()).toBe(6); + expect(resultDate.getDate()).toBe(20); + expect(resultDate.getHours()).toBe(0); + expect(resultDate.getMinutes()).toBe(0); + expect(resultDate.getSeconds()).toBe(0); + expect(resultDate.getMilliseconds()).toBe(0); + }); + }); + + describe('#getFirstDayOfMonth', () => { + + it('should get the first day of the current month', () => { + const startDate = new Date('2023-07-25T12:34:56.789Z'); + const firstDayOfMonth = DateUtils.getFirstDayOfMonth(startDate); + expect(firstDayOfMonth.getFullYear()).toBe(2023); + expect(firstDayOfMonth.getMonth()).toBe(6); + expect(firstDayOfMonth.getDate()).toBe(1); + expect(firstDayOfMonth.getHours()).toBe(0); + expect(firstDayOfMonth.getMinutes()).toBe(0); + expect(firstDayOfMonth.getSeconds()).toBe(0); + expect(firstDayOfMonth.getMilliseconds()).toBe(0); + }); + + it('should get the first day of the next month', () => { + const startDate = new Date('2023-07-25T12:34:56.789Z'); + const firstDayOfNextMonth = DateUtils.getFirstDayOfMonth(startDate, 1); + expect(firstDayOfNextMonth.getFullYear()).toBe(2023); + expect(firstDayOfNextMonth.getMonth()).toBe(7); + expect(firstDayOfNextMonth.getDate()).toBe(1); + expect(firstDayOfNextMonth.getHours()).toBe(0); + expect(firstDayOfNextMonth.getMinutes()).toBe(0); + expect(firstDayOfNextMonth.getSeconds()).toBe(0); + expect(firstDayOfNextMonth.getMilliseconds()).toBe(0); + }); + + }); + + describe('#getLastDayOfMonth', () => { + + it('should get the last day of the current month with time set to 23:59:59.999', () => { + const startDate = new Date('2023-07-25T12:34:56.789Z'); + const lastDayOfMonth = DateUtils.getLastDayOfMonth(startDate); + expect(lastDayOfMonth.getFullYear()).toBe(2023); + expect(lastDayOfMonth.getMonth()).toBe(6); + expect(lastDayOfMonth.getDate()).toBe(31); + expect(lastDayOfMonth.getHours()).toBe(23); + expect(lastDayOfMonth.getMinutes()).toBe(59); + expect(lastDayOfMonth.getSeconds()).toBe(59); + expect(lastDayOfMonth.getMilliseconds()).toBe(999); + }); + + it('should get the last day of the next month with time set to 23:59:59.999', () => { + const startDate = new Date('2023-07-25T12:34:56.789Z'); + const lastDayOfNextMonth = DateUtils.getLastDayOfMonth(startDate, 1); + expect(lastDayOfNextMonth.getFullYear()).toBe(2023); + expect(lastDayOfNextMonth.getMonth()).toBe(7); + expect(lastDayOfNextMonth.getDate()).toBe(31); + expect(lastDayOfNextMonth.getHours()).toBe(23); + expect(lastDayOfNextMonth.getMinutes()).toBe(59); + expect(lastDayOfNextMonth.getSeconds()).toBe(59); + expect(lastDayOfNextMonth.getMilliseconds()).toBe(999); + }); + + }); + + describe('#getLastTimeOfDate', () => { + it('should get the last time of the day (23:59:59.999) for the given date', () => { + const date = new Date('2023-07-25T12:34:56.789Z'); + const lastTime = DateUtils.getLastTimeOfDate(date); + expect(lastTime.getFullYear()).toBe(2023); + expect(lastTime.getMonth()).toBe(6); + expect(lastTime.getDate()).toBe(25); + expect(lastTime.getHours()).toBe(23); + expect(lastTime.getMinutes()).toBe(59); + expect(lastTime.getSeconds()).toBe(59); + expect(lastTime.getMilliseconds()).toBe(999); + }); + }); + + describe('#getFirstTimeOfDate', () => { + it('should get the first time of the day (00:00:00.000) for the given date', () => { + const date = new Date('2023-07-25T12:34:56.789Z'); + const firstTime = DateUtils.getFirstTimeOfDate(date); + expect(firstTime.getFullYear()).toBe(2023); + expect(firstTime.getMonth()).toBe(6); + expect(firstTime.getDate()).toBe(25); + expect(firstTime.getHours()).toBe(0); + expect(firstTime.getMinutes()).toBe(0); + expect(firstTime.getSeconds()).toBe(0); + expect(firstTime.getMilliseconds()).toBe(0); + }); + }); + + describe('#getMicroSeconds', () => { + it('should get the number of microseconds since January 1, 1970, for the given date', () => { + const date = new Date('2023-07-25T12:34:56.789Z'); + const microseconds = DateUtils.getMicroSeconds(date); + expect(microseconds).toBe(1690288496789000); + }); + }); + +}); diff --git a/DateUtils.ts b/DateUtils.ts new file mode 100644 index 0000000..e916834 --- /dev/null +++ b/DateUtils.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Pure zero-dep JavaScript date operations. + */ +export class DateUtils { + + /** + * Creates a new Date object that is a copy of the provided date. + * + * @param {Date} date - The original Date object to be cloned. + * @returns {Date} A new Date object that is a copy of the original date. + */ + public static cloneDate (date: Date) : Date { + return new Date(date); + } + + /** + * Subtracts a specified number of days from a given date. + * + * Note! This handles times with local timezone. + * + * @param {Date} date - The original Date object. + * @param {number} offset - The number of days to subtract. + * @returns {Date} A new Date object resulting from subtracting the specified days from the original date. + */ + public static subtractDays ( + date: Date, + offset: number, + ) : Date { + const newDate = DateUtils.cloneDate(date); + newDate.setDate(newDate.getDate() - offset); + return newDate; + } + + /** + * Gets the first day of the month based on the provided start date and offset. + * + * Note! This handles times with local timezone. + * + * @param {Date} startDate - The starting date from which to calculate the first day of the month. + * @param {number} offset - An optional offset to calculate the first day of a different month (default is 0). + * @returns {Date} The first day of the month as a new Date object. + */ + public static getFirstDayOfMonth ( + startDate: Date, + offset: number = 0, + ) : Date { + const nextMonthFirstDay = DateUtils.cloneDate(startDate); + nextMonthFirstDay.setMonth(nextMonthFirstDay.getMonth() + offset, 1); + return DateUtils.getFirstTimeOfDate(nextMonthFirstDay); + } + + /** + * Gets the last day of the month based on the provided start date and offset. + * + * Note! This handles times with local timezone. + * + * @param {Date} startDate - The starting date from which to calculate the last day of the month. + * @param {number} offset - An optional offset to calculate the last day of a different month (default is 0). + * @returns {Date} The last day of the month as a new Date object with the time set to 23:59:59.999. + */ + public static getLastDayOfMonth ( + startDate: Date, + offset: number = 0, + ) : Date { + const firstDayOfMonth = DateUtils.getFirstDayOfMonth(startDate, offset + 1); + // Subtract one day to get the last day of the month + const lastDayOfMonth = DateUtils.subtractDays(firstDayOfMonth, 1); + return DateUtils.getLastTimeOfDate(lastDayOfMonth); + } + + /** + * Gets the last time of the day (23:59:59.999) for a given date. + * + * Note! This handles times with local timezone. + * + * @param {Date} date - The input date. + * @returns {Date} A new Date object representing the last time (23:59:59.999) of the given date. + */ + public static getLastTimeOfDate (date: Date): Date { + const lastTime = DateUtils.cloneDate(date); + lastTime.setHours(23, 59, 59, 999); + return lastTime; + } + + /** + * Gets the first time of the day (00:00:00.000) for a given date. + * + * Note! This handles times with local timezone. + * + * @param {Date} date - The input date. + * @returns {Date} A new Date object representing the last time (00:00:00.000) of the given date. + */ + public static getFirstTimeOfDate (date: Date): Date { + const lastTime = DateUtils.cloneDate(date); + lastTime.setHours(0, 0, 0, 0); + return lastTime; + } + + /** + * Gets the number of microseconds since January 1, 1970 (Unix Epoch) for the given date. + * + * @param {Date} value - The date for which to calculate the number of microseconds. + * @returns {number} The number of microseconds since January 1, 1970, for the given date. + */ + public static getMicroSeconds (value: Date) : number { + return value.getTime()*1000; + } + +} diff --git a/EmailAuthHttpService.ts b/EmailAuthHttpService.ts new file mode 100644 index 0000000..9792e13 --- /dev/null +++ b/EmailAuthHttpService.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021-2023. Sendanor . All rights reserved. + +import { EmailTokenDTO, isEmailTokenDTO } from "./auth/email/types/EmailTokenDTO"; +import { Language } from "./types/Language"; +import { LanguageService } from "./LanguageService"; +import { HttpService } from "./HttpService"; +import { LogService } from "./LogService"; +import { createVerifyEmailCodeDTO, VerifyEmailCodeDTO } from "./auth/email/types/VerifyEmailCodeDTO"; +import { ReadonlyJsonAny } from "./Json"; +import { CallbackWithLanguage, AUTHENTICATE_EMAIL_URL, VERIFY_EMAIL_CODE_URL, VERIFY_EMAIL_TOKEN_URL } from "./auth/email/email-auth-constants"; +import { LogLevel } from "./types/LogLevel"; +import { createVerifyEmailTokenDTO, VerifyEmailTokenDTO } from "./auth/email/types/VerifyEmailTokenDTO"; +import { createAuthenticateEmailDTO } from "./auth/email/types/AuthenticateEmailDTO"; + +const LOG = LogService.createLogger('EmailAuthHttpService'); + +/** + * This is a client service for email address based user authentication over + * HTTP protocol. + */ +export class EmailAuthHttpService { + + private static _authenticateEmailUrl : CallbackWithLanguage = AUTHENTICATE_EMAIL_URL; + private static _verifyEmailCodeUrl : CallbackWithLanguage = VERIFY_EMAIL_CODE_URL; + private static _verifyEmailTokenUrl : CallbackWithLanguage = VERIFY_EMAIL_TOKEN_URL; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static initialize ( + authenticateEmailUrl : CallbackWithLanguage = AUTHENTICATE_EMAIL_URL, + verifyEmailCodeUrl : CallbackWithLanguage = VERIFY_EMAIL_CODE_URL, + verifyEmailTokenUrl : CallbackWithLanguage = VERIFY_EMAIL_TOKEN_URL + ) { + this._authenticateEmailUrl = authenticateEmailUrl; + this._verifyEmailCodeUrl = verifyEmailCodeUrl; + this._verifyEmailTokenUrl = verifyEmailTokenUrl; + } + + public static async authenticateEmailAddress ( + email : string, + language ?: Language + ) : Promise { + const lang : Language = language ?? LanguageService.getCurrentLanguage(); + const body = createAuthenticateEmailDTO(email); + return await this._postJson( + this._authenticateEmailUrl(lang), + body as unknown as ReadonlyJsonAny + ); + } + + public static async verifyEmailToken ( + emailToken : EmailTokenDTO, + language ?: Language + ) : Promise { + const lang : Language = language ?? LanguageService.getCurrentLanguage(); + const body : VerifyEmailTokenDTO = createVerifyEmailTokenDTO(emailToken); + return await this._postJson( + this._verifyEmailTokenUrl(lang), + body as unknown as ReadonlyJsonAny + ); + } + + public static async verifyEmailCode ( + token : EmailTokenDTO, + code: string, + language ?: Language + ) : Promise { + const lang : Language = language ?? LanguageService.getCurrentLanguage(); + const body : VerifyEmailCodeDTO = createVerifyEmailCodeDTO(token, code); + return await this._postJson( + this._verifyEmailCodeUrl(lang), + body as unknown as ReadonlyJsonAny + ); + } + + private static async _postJson ( + url : string, + body : ReadonlyJsonAny + ) : Promise { + const response : unknown = await HttpService.postJson(url, body); + if (!isEmailTokenDTO(response)) { + LOG.debug(`Response: `, response); + throw new TypeError(`Response was not EmailTokenDTO`); + } + return response; + } + +} diff --git a/EmailUtils.ts b/EmailUtils.ts new file mode 100644 index 0000000..dda94f3 --- /dev/null +++ b/EmailUtils.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { trim } from "./functions/trim"; +import { isString } from "./types/String"; + +export class EmailUtils { + + public static isEmailValid (value: string) : boolean { + if (!isString(value)) return false; + const l = value?.length ?? 0; + return l >= 3 && value.includes('@') && !'@.'.includes(value[0]) && !'@.'.includes(value[l-1]); + } + + public static getEmailDomain (value: string) : string | undefined { + if (!EmailUtils.isEmailValid(value)) return undefined; + const atIndex = value.lastIndexOf('@'); + return atIndex >= 0 ? trim(value.substring(atIndex+1)) : undefined; + } + +} diff --git a/EnumUtils.test.ts b/EnumUtils.test.ts new file mode 100644 index 0000000..218d602 --- /dev/null +++ b/EnumUtils.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EnumUtils } from './EnumUtils'; + +enum TestType { + KEY0 = "0value", + KEY1 = "1value", + KEY = "value", + ANOTHER_KEY = "anotherValue", +} + +enum NumericTestType { + KEY0 = 0, + KEY1 = 1, + KEY = 2, + ANOTHER_KEY = 3, +} + +describe('EnumUtils', () => { + + describe('getKeyValuePairs', () => { + + it('should return key-value pairs for string based enum', () => { + const expected = [ + ["KEY0", "0value"], + ["KEY1", "1value"], + ["KEY", "value"], + ["ANOTHER_KEY", "anotherValue"], + ]; + expect(EnumUtils.getKeyValuePairs(TestType)).toStrictEqual(expected); + }); + + it('should return key-value pairs for numeric enum', () => { + const expected = [ + ["KEY0", 0], + ["KEY1", 1], + ["KEY", 2], + ["ANOTHER_KEY", 3], + ]; + expect(EnumUtils.getKeyValuePairs(NumericTestType)).toStrictEqual(expected); + }); + + }); + + describe('getValues', () => { + + it('should return values for string based enum', () => { + const expected = ["0value", "1value", "value", "anotherValue"]; + expect(EnumUtils.getValues(TestType)).toStrictEqual(expected); + }); + + it('should return values for numeric enum', () => { + const expected = [0, 1, 2, 3]; + expect(EnumUtils.getValues(NumericTestType)).toStrictEqual(expected); + }); + + }); + + describe('getKeys', () => { + + it('should return keys for string based enum', () => { + const expected = ["KEY0", "KEY1", "KEY", "ANOTHER_KEY"]; + expect(EnumUtils.getKeys(TestType)).toStrictEqual(expected); + }); + + it('should return keys for numeric enum', () => { + const expected = ["KEY0", "KEY1", "KEY", "ANOTHER_KEY"]; + expect(EnumUtils.getKeys(NumericTestType)).toStrictEqual(expected); + }); + + }); + + describe('createFilteredKeysFromValues', () => { + + it('should return keys corresponding to provided string values', () => { + const values = ["0value", "1value"]; + const expected = ["KEY0", "KEY1"]; + expect(EnumUtils.createFilteredKeysFromValues(TestType, values)).toStrictEqual(expected); + }); + + it('should return keys corresponding to provided numeric values', () => { + const values : readonly number[] = [0, 1]; + const expected = ["KEY0", "KEY1"]; + expect(EnumUtils.createFilteredKeysFromValues(NumericTestType, values)).toStrictEqual(expected); + }); + + }); + +}); diff --git a/EnumUtils.ts b/EnumUtils.ts new file mode 100644 index 0000000..1ca5e0b --- /dev/null +++ b/EnumUtils.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { filter } from "./functions/filter"; +import { map } from "./functions/map"; + +export interface EnumObject { + readonly [key: string]: T; +} + +export type EnumKeyValuePair = readonly [string, T]; + +export class EnumUtils { + + public static getKeyValuePairs ( + obj: EnumObject + ) : readonly EnumKeyValuePair[] { + return map(EnumUtils.getKeys(obj), (key : string) : EnumKeyValuePair => [key, obj[key]]); + } + + public static getValues ( + obj: EnumObject + ) : readonly T[] { + return map(EnumUtils.getKeys(obj), (key : string) : T => obj[key]); + } + + public static getKeys ( + obj: EnumObject + ) : readonly string[] { + return filter( + Object.keys(obj), + (k: string): boolean => k ? '0123456789'.indexOf( k[0] ) < 0 : true + ); + } + + public static createFilteredKeysFromValues ( + obj : EnumObject, + values : readonly T[] + ) : readonly string[] { + return map( + filter( + EnumUtils.getKeyValuePairs(obj), + (item: EnumKeyValuePair) : boolean => values.includes(item[1]) + ), + (item: EnumKeyValuePair) : string => item[0] + ); + } + +} diff --git a/HgTest.ts b/HgTest.ts new file mode 100644 index 0000000..a7362d2 --- /dev/null +++ b/HgTest.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { LogService } from "./LogService"; +import { LogLevel } from "./types/LogLevel"; +import { RequestClientAdapter } from "./requestClient/RequestClientAdapter"; +import { MockRequestClientAdapter } from "./requestClient/mock/MockRequestClientAdapter"; +import { RequestClientImpl } from "./RequestClientImpl"; + +const LOG = LogService.createLogger('HgTest'); + +export class HgTest { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + /** + * This method will initialize our libraries using frontend implementations. + * + * Right now it will call `RequestClientImpl.setClient()` with a standard NodeJS + * implementation. It has a dependency to NodeJS's http and https modules. + * + * @param requestClient The request client adapter to be used by default + */ + public static initialize ( + requestClient ?: RequestClientAdapter | undefined + ) { + if (!requestClient) { + requestClient = new MockRequestClientAdapter(); + } + RequestClientImpl.setClient(requestClient); + } + +} diff --git a/HttpService.ts b/HttpService.ts new file mode 100644 index 0000000..864fba3 --- /dev/null +++ b/HttpService.ts @@ -0,0 +1,415 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { JsonAny, ReadonlyJsonAny } from "./Json"; +import { RequestClientImpl } from "./RequestClientImpl"; +import { Observer, ObserverCallback, ObserverDestructor } from "./Observer"; +import { LogService } from "./LogService"; +import { LogLevel } from "./types/LogLevel"; +import { ResponseEntity } from "./request/types/ResponseEntity"; +import { isRequestError } from "./request/types/RequestError"; +import { getNextRetryDelay, HttpRetryPolicy, shouldRetry } from "./request/types/HttpRetryPolicy"; +import { Method } from "./types/Method"; + +export { Method }; + +const LOG = LogService.createLogger('HttpService'); + +export enum HttpServiceEvent { + REQUEST_STARTED = "HttpService:requestStarted", + REQUEST_STOPPED = "HttpService:requestStopped" +} + +export type HttpServiceDestructor = ObserverDestructor; + +export class HttpService { + + private static _defaultRetryDelay : number = 1000; + private static _requestLimit : number = 100; + private static _baseApiUrl : string | undefined; + private static _requestCount : number = 0; + + private static _observer: Observer = new Observer("HttpService"); + + public static Event = HttpServiceEvent; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + RequestClientImpl.setLogLevel(level); + } + + public static setRequestLimit (value : number ) { + this._requestLimit = value; + } + + /** + * How long we should wait after a recoverable error happens until trying + * the request again. This is the base delay. + * + * This is active only if the retry policy has been defined but it does not + * include a base delay. + * + * @param value The time to wait in milliseconds + */ + public static setDefaultRetryLimit (value : number ) { + this._defaultRetryDelay = value; + } + + /** + * Defines an optional base API URL which will be used if URL does not have a full URL (e.g. starts with "/api"). + * + * This is required for browser compatible NodeJS SSR use case. + * + * @param url + */ + public static setBaseUrl (url : string | undefined) { + this._baseApiUrl = url; + } + + public static on ( + name: HttpServiceEvent, + callback: ObserverCallback + ): HttpServiceDestructor { + return this._observer.listenEvent(name, callback); + } + + public static destroy (): void { + + this._observer.destroy(); + + // FIXME: Cancel requests + + } + + public static hasOpenRequests () : boolean { + return this._requestCount >= 1; + } + + public static getRequestCount () : number { + return this._requestCount; + } + + public static async waitUntilNoOpenRequests () : Promise { + + if (!this.hasOpenRequests()) { + LOG.debug(`No open requests to wait`); + return; + } + + LOG.debug(`waitUntilNoOpenRequests: Let's wait until no requests`); + return await new Promise((resolve, reject) => { + try { + let destructor : any | undefined = this.on(HttpServiceEvent.REQUEST_STOPPED, () => { + try { + if (!this.hasOpenRequests()) { + LOG.debug(`waitUntilNoOpenRequests: No requests anymore. We're ready!`); + destructor(); + destructor = undefined; + resolve(); + } else { + LOG.debug(`waitUntilNoOpenRequests: We still have ${this.getRequestCount()} requests`); + } + } catch (err) { + LOG.debug(`waitUntilNoOpenRequests: Canceling waiting: error: `, err); + reject(err); + } + }); + } catch (err) { + LOG.debug(`waitUntilNoOpenRequests: Canceling waiting: error: `, err); + reject(err); + } + }); + + } + + private static _prepareUrl (url : string) : string { + if (this._baseApiUrl && url.startsWith('/api')) { + return `${this._baseApiUrl}${url.substring('/api'.length)}`; + } + return url; + } + + private static async _request ( + context : string, + method : Method, + url : string, + callback : () => T, + retryPolicy ?: HttpRetryPolicy, + attempt ?: number, + retryDelay ?: number + ) : Promise { + attempt = attempt ?? 0; + retryDelay = retryDelay ?? retryPolicy?.baseDelay ?? this._defaultRetryDelay; + if (attempt === 0 && this._requestCount >= this._requestLimit) { + throw new TypeError(`${context}: Too many requests: ${this._requestCount}`); + } + try { + if (attempt === 0) { + this._requestCount += 1; + if ( this._observer.hasCallbacks(HttpServiceEvent.REQUEST_STARTED) ) { + this._observer.triggerEvent(HttpServiceEvent.REQUEST_STARTED, url, method); + } + LOG.debug(`Started ${method} request to "${url} "(${this._requestCount} requests)`); + } else { + LOG.debug(`Started attempt ${attempt} for ${method} request to "${url} "(${this._requestCount} requests)`); + } + return await callback(); + } catch (e) { + const code : any = (e as any)?.code; + const status = isRequestError(e) ? e.status : 0; + if (retryPolicy) { + if (shouldRetry(retryPolicy, attempt, method, status, code)) { + LOG.warn(`Error in ${method} "${url}": ${e} ${code} ${status}`); + LOG.debug(`Waiting next attempt for ${method} request to "${url} "(${this._requestCount} requests)`); + await this._waitForRetry(retryDelay); + retryDelay = getNextRetryDelay(retryDelay, retryPolicy); + return await this._request(context, method, url, callback, retryPolicy, attempt + 1, retryDelay); + } else { + throw e; + } + } else { + throw e; + } + } finally { + if (attempt === 0) { + this._requestCount -= 1; + if (this._observer.hasCallbacks(HttpServiceEvent.REQUEST_STOPPED)) { + this._observer.triggerEvent(HttpServiceEvent.REQUEST_STOPPED, url, method); + } + LOG.debug(`Stopped ${method} request to "${url}" (${this._requestCount} requests)`); + } + } + } + + private static async _waitForRetry (time: number) : Promise { + LOG.debug(`Waiting for retry time: `, time); + return new Promise( (resolve, reject) => { + try { + setTimeout( + () => { + resolve(); + }, + time + ); + } catch (err) { + reject(err); + } + }); + } + + public static async getJson ( + url : string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise { + url = this._prepareUrl(url); + return this._request( + 'getJson', + Method.GET, + url, + async () => { + const response : JsonAny | undefined = await RequestClientImpl.getJson(url, headers); + return response as ReadonlyJsonAny | undefined; + }, + retryPolicy + ); + } + + public static async postJson ( + url : string, + data ?: ReadonlyJsonAny, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise { + url = this._prepareUrl(url); + return this._request( + 'postJson', + Method.POST, + url, + async () => { + const response : JsonAny | undefined = await RequestClientImpl.postJson(url, data as JsonAny, headers); + return response as ReadonlyJsonAny | undefined; + }, + retryPolicy + ); + } + + public static async deleteJson ( + url : string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise { + url = this._prepareUrl(url); + return this._request( + 'deleteJson', + Method.DELETE, + url, + async () => { + const response : JsonAny | undefined = await RequestClientImpl.deleteJson(url, headers); + return response as ReadonlyJsonAny | undefined; + }, + retryPolicy + ); + } + + + public static async getText ( + url : string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise { + url = this._prepareUrl(url); + return this._request( + 'getText', + Method.GET, + url, + async () => { + const response : string | undefined = await RequestClientImpl.getText(url, headers); + return response as string | undefined; + }, + retryPolicy + ); + } + + public static async postText ( + url : string, + data ?: string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise { + url = this._prepareUrl(url); + return this._request( + 'postText', + Method.POST, + url, + async () => { + const response : string | undefined = await RequestClientImpl.postText(url, data, headers); + return response as string | undefined; + }, + retryPolicy + ); + } + + public static async deleteText ( + url : string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise { + url = this._prepareUrl(url); + return this._request( + 'deleteText', + Method.DELETE, + url, + async () => { + const response : string | undefined = await RequestClientImpl.deleteText(url, headers); + return response as string | undefined; + }, + retryPolicy + ); + } + + public static async getJsonEntity ( + url : string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise | undefined> { + url = this._prepareUrl(url); + return this._request( + 'getJsonEntity', + Method.GET, + url, + async () => { + return await RequestClientImpl.getJsonEntity(url, headers); + }, + retryPolicy + ); + } + + public static async postJsonEntity ( + url : string, + data ?: ReadonlyJsonAny, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise | undefined> { + url = this._prepareUrl(url); + return this._request( + 'postJsonEntity', + Method.POST, + url, + async () => { + return await RequestClientImpl.postJsonEntity(url, data as JsonAny, headers); + }, + retryPolicy + ); + } + + public static async deleteJsonEntity ( + url : string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise | undefined> { + url = this._prepareUrl(url); + return this._request( + 'deleteJsonEntity', + Method.DELETE, + url, + async () => { + return await RequestClientImpl.deleteJsonEntity(url, headers); + }, + retryPolicy + ); + } + + + public static async getTextEntity ( + url : string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise | undefined> { + url = this._prepareUrl(url); + return this._request( + 'getTextEntity', + Method.GET, + url, + async () => { + return await RequestClientImpl.getTextEntity(url, headers); + }, + retryPolicy + ); + } + + public static async postTextEntity ( + url : string, + data ?: string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise | undefined> { + url = this._prepareUrl(url); + return this._request( + 'postTextEntity', + Method.POST, + url, + async () => { + return await RequestClientImpl.postTextEntity(url, data, headers); + }, + retryPolicy + ); + } + + public static async deleteTextEntity ( + url : string, + headers ?: {[key: string]: string}, + retryPolicy ?: HttpRetryPolicy + ) : Promise | undefined> { + url = this._prepareUrl(url); + return this._request( + 'deleteTextEntity', + Method.DELETE, + url, + async () => { + return await RequestClientImpl.deleteTextEntity(url, headers); + }, + retryPolicy + ); + } + +} diff --git a/InitService.ts b/InitService.ts new file mode 100644 index 0000000..61bc3ef --- /dev/null +++ b/InitService.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { reduce } from "./functions/reduce"; + +export interface InitCallback { + () : Promise | void; +} + +export class InitService { + + private static _initialized : boolean = false; + private static _initializing : boolean = false; + private static _initializers : InitCallback[] = []; + + public static registerInitializer (callback: InitCallback) { + if (InitService._initialized) throw new TypeError('Service already initialized'); + if (InitService._initializing) throw new TypeError('Service already initializing'); + InitService._initializers.push(callback); + } + + public static isInitializing () : boolean { + return InitService._initializing; + } + + public static isInitialized () : boolean { + return InitService._initialized; + } + + /** + * Initializes dynamic data for SSR / SEO + */ + public static async initialize () { + + if (InitService._initialized) throw new TypeError('Service already initialized'); + if (InitService._initializing) throw new TypeError('Service already initializing'); + + InitService._initializing = true; + + await reduce( + InitService._initializers, + async (p: Promise, callback: InitCallback) : Promise => { + await p; + await callback(); + }, + Promise.resolve() + ); + + InitService._initializing = false; + InitService._initialized = true; + + } + +} + + diff --git a/Json.test.ts b/Json.test.ts new file mode 100644 index 0000000..d4db4c5 --- /dev/null +++ b/Json.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isReadonlyJsonObject, parseJson } from "./Json"; + +describe('Json', () => { + + describe('isReadonlyJsonObject', () => { + + it('can check empty object', () => { + expect( isReadonlyJsonObject({}) ).toBe(true); + }); + + it('can check empty object from parsed JSON', () => { + expect( isReadonlyJsonObject(parseJson('{}')) ).toBe(true); + }); + + }); + + describe('parseJson', () => { + + it('can parse empty JSON object', () => { + expect( parseJson('{}') ).toStrictEqual({}); + }); + + it('cannot parse regular object', () => { + expect( parseJson({}) ).toStrictEqual(undefined); + }); + + }); + +}); diff --git a/Json.ts b/Json.ts new file mode 100644 index 0000000..9b8f3c7 --- /dev/null +++ b/Json.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020. Sendanor. All rights reserved. +// 2020. Jaakko Heusala +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { isBoolean } from "./types/Boolean"; +import { isNull } from "./types/Null"; +import { map } from "./functions/map"; +import { reduce } from "./functions/reduce"; +import { createOr, TestCallbackNonStandard } from "./types/TestCallback"; +import { isUndefined } from "./types/undefined"; +import { explainNot, explainOk, explainOr } from "./types/explain"; +import { isString } from "./types/String"; +import { isFunction } from "./types/Function"; +import { isNumber } from "./types/Number"; +import { isRegularObject } from "./types/RegularObject"; +import { keys } from "./functions/keys"; +import { everyProperty } from "./functions/everyProperty"; +import { isArrayOf } from "./types/Array"; + + +export interface WritableJsonSerializable { + toJSON () : JsonAny; +} + +export interface ReadonlyJsonSerializable { + toJSON () : ReadonlyJsonAny; +} + + +export type JsonSerializable = WritableJsonSerializable | ReadonlyJsonSerializable; +export type FlatJsonValue = string | number | boolean | null; +export type FlatJsonObject = JsonObjectOf; +export type FlatJsonArray = JsonArrayOf; +export type JsonAny = FlatJsonValue | JsonArray | JsonObject | JsonSerializable; +export type JsonObjectOf = { [name: string]: T | undefined }; +export type JsonObject = { [name: string]: JsonAny | undefined }; +export type JsonArrayOf = T[]; +export type JsonArray = (JsonAny | undefined)[]; + +export type ReadonlyJsonAny = FlatJsonValue | ReadonlyJsonArray | ReadonlyJsonObject; +export type ReadonlyJsonObjectOf = { readonly [name: string]: T | undefined }; +export type ReadonlyJsonObject = { readonly [name: string]: ReadonlyJsonAny | undefined }; +export type ReadonlyJsonArrayOf = readonly T[]; +export type ReadonlyJsonArray = readonly ReadonlyJsonAny[]; +export type ReadonlyFlatJsonObject = ReadonlyJsonObjectOf; +export type ReadonlyFlatJsonArray = ReadonlyJsonArrayOf; + + +export function isReadonlyJsonSerializable (value: any) : value is ReadonlyJsonSerializable { + return !!value && isFunction(value?.toJSON); +} + +export function isJsonSerializable (value: any) : value is JsonSerializable { + return !!value && isFunction(value?.toJSON); +} + +export function isFlatJsonValue (value: any) : value is FlatJsonValue { + return isString(value) || isNumber(value) || isBoolean(value) || isNull(value); +} + + +export function isJson (value: any) : value is JsonAny | ReadonlyJsonAny { + return isJsonAny(value); +} + +export function isJsonAny (value: any) : value is JsonAny | ReadonlyJsonAny { + return isFlatJsonValue(value) || isJsonArray(value) || isJsonObject(value); +} + +export function isJsonAnyOrUndefined (value: any) : value is JsonAny | ReadonlyJsonAny { + return isJsonAny(value) || isUndefined(value); +} + +/** + * Will accept objects with undefined values, which usually disappear from the JSON when stringified. + * + * @param value + */ +export function isJsonObject (value : any) : value is JsonObject | ReadonlyJsonObject { + return isRegularObject(value) && everyProperty(value, isString, createOr(isJsonAny, isUndefined)); +} + +export function explainJsonObject (value: any) : string { + return isJsonObject(value) ? explainOk() : explainNot('JsonObject'); +} + +/** + * Will accept objects with undefined values, which usually disappear from the JSON when stringified. + * + * @param value + */ +export function isJsonObjectOrUndefined (value : any) : value is JsonObject | ReadonlyJsonObject | undefined { + return isUndefined(value) || isJsonObject(value); +} + +export function explainJsonObjectOrUndefined (value: any) : string { + return isJsonObjectOrUndefined(value) ? explainOk() : explainNot(explainOr(['JsonObject', 'undefined'])); +} + +/** + * Will accept objects with undefined values, which usually disappear from the JSON when stringified. + * + * @param value + * @param isPropertyOf + */ +export function isJsonObjectOf ( + value : any, + isPropertyOf : TestCallbackNonStandard = isJsonAny +) : value is JsonObject | ReadonlyJsonObject { + return isRegularObject(value) && everyProperty(value, isString, createOr(isPropertyOf, isUndefined)); +} + +/** + * Will also accept arrays with undefined values, too, although these will usually convert to null. + * + * @param value + */ +export function isJsonArray ( + value : any +) : value is JsonArrayOf { + return isArrayOf(value, createOr(isJsonAny, isUndefined)); +} + +/** + * Will also accept arrays with undefined values, too, although these will usually convert to null. + * + * @param value + * @param isItemOf + */ +export function isJsonArrayOf ( + value : any, + isItemOf : TestCallbackNonStandard = isJsonAny +) : value is JsonArrayOf { + return isArrayOf(value, createOr(isItemOf, isUndefined)); +} + + +export function isReadonlyJsonAny (value: any) : value is ReadonlyJsonAny { + return isFlatJsonValue(value) || isReadonlyJsonArray(value) || isReadonlyJsonObject(value); +} + +export function explainReadonlyJsonAny (value: any) : string { + return isReadonlyJsonAny(value) ? explainOk() : explainNot('ReadonlyJsonAny'); +} + +export function isReadonlyJsonObject (value : any) : value is ReadonlyJsonObjectOf { + return isRegularObject(value) && everyProperty(value, isString, createOr(isReadonlyJsonAny, isUndefined)); +} + +export function isReadonlyJsonObjectOrUndefined (value : any) : value is ReadonlyJsonObjectOf | undefined { + return isUndefined(value) || isReadonlyJsonObject(value); +} + +export function explainReadonlyJsonObjectOrUndefined (value: any) : string { + return isReadonlyJsonObjectOrUndefined(value) ? explainOk() : explainNot(explainOr(['undefined', 'ReadonlyJsonObject'])); +} + +export function isReadonlyJsonObjectOrNullOrUndefined (value : any) : value is ReadonlyJsonObjectOf | undefined | null { + return isUndefined(value) || isNull(value) || isReadonlyJsonObject(value); +} + +export function explainReadonlyJsonObjectOrNullOrUndefined (value: any) : string { + return isReadonlyJsonObjectOrNullOrUndefined(value) ? explainOk() : explainNot(explainOr(['undefined', 'null', 'ReadonlyJsonObject'])); +} + +export function explainReadonlyJsonObject (value: any) : string { + return isReadonlyJsonObject(value) ? explainOk() : explainNot('ReadonlyJsonObject'); +} + +export function explainReadonlyJsonArray (value: any) : string { + return isReadonlyJsonArray(value) ? explainOk() : explainNot('ReadonlyJsonArray'); +} + +export function parseReadonlyJsonObject (value : any) : ReadonlyJsonObject | undefined { + if (isString(value)) value = parseJson(value); + return isReadonlyJsonObject(value) ? value : undefined; +} + +export function isReadonlyJsonObjectOf ( + value : any, + isPropertyOf : TestCallbackNonStandard = isReadonlyJsonAny +) : value is ReadonlyJsonObjectOf { + return isRegularObject(value) && everyProperty(value, isString, createOr(isPropertyOf, isUndefined)); +} + +export function isReadonlyJsonArray ( + value : any +) : value is ReadonlyJsonArrayOf { + return isArrayOf(value, createOr(isReadonlyJsonAny, isUndefined)); +} + +export function isReadonlyJsonArrayOf ( + value : any, + isItemOf : TestCallbackNonStandard = isReadonlyJsonAny +) : value is ReadonlyJsonArrayOf { + return isArrayOf(value, createOr(isItemOf, isUndefined)); +} + +export function parseJson (value: any) : JsonAny | undefined { + try { + return JSON.parse(value); + } catch (err) { + return undefined; + } +} + +export function isJsonString (value : any) : value is string { + return parseJson(value) !== undefined; +} + +export function explainJsonString (value : any) : string { + return isJsonString(value) ? explainOk() : explainNot('JSON as string'); +} + +export function closeJsonArray (value: JsonArray | ReadonlyJsonArray) : JsonArray | ReadonlyJsonArray { + if (isJsonArray(value)) { + return map(value, cloneJson) as JsonArray | ReadonlyJsonArray; + } + throw new TypeError(`closeJsonArray: Not an JSON array: ${value}`); +} + +export function cloneJsonObject (value: JsonObject | ReadonlyJsonObject) : JsonObject | ReadonlyJsonObject { + if (isJsonObject(value)) { + return reduce( + keys(value), + (obj: {[key: string]: any}, key: string) : JsonObject => { + obj[key] = cloneJson(value[key]); + return obj; + }, + {} as JsonObject | ReadonlyJsonObject + ); + } + throw new TypeError(`cloneJsonObject: Not an JSON object: ${value}`); +} + +export function cloneJson (value: any) : JsonAny | ReadonlyJsonAny | undefined { + + if (value === undefined) return undefined; + if (value === null) return null; + if (isString(value)) return value; + if (isNumber(value)) return value; + if (isBoolean(value)) return value; + if (isJsonArray(value)) return closeJsonArray(value); + if (isJsonObject(value)) return cloneJsonObject(value); + + throw new TypeError(`cloneJson: Not JSON compatible value: ${value}`); + +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b3bbd17 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2022-2023 Heusala Group Oy +Copyright (c) 2020-2021 Sendanor +Copyright (c) 2020-2021 Jaakko-Heikki Heusala + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LanguageService.ts b/LanguageService.ts new file mode 100644 index 0000000..7a550de --- /dev/null +++ b/LanguageService.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2021-2022. Sendanor . All rights reserved. + +import { Language } from "./types/Language"; +import { Observer, ObserverCallback, ObserverDestructor } from "./Observer"; + +export enum LanguageServiceEvent { + CURRENT_LANGUAGE_CHANGED = "LanguageService:currentLanguageChanged", + DEFAULT_LANGUAGE_CHANGED = "LanguageService:defaultLanguageChanged" +} + +export type LanguageServiceDestructor = ObserverDestructor; + +export class LanguageService { + + private static _defaultLanguage : Language | undefined; + private static _language : Language | undefined; + + private static _observer: Observer = new Observer( + "LanguageService"); + + public static Event = LanguageServiceEvent; + + public static on ( + name: LanguageServiceEvent, + callback: ObserverCallback + ): LanguageServiceDestructor { + return this._observer.listenEvent(name, callback); + } + + public static destroy (): void { + this._observer.destroy(); + } + + public static setDefaultLanguage (lang : Language) { + if (this._defaultLanguage !== lang) { + + this._defaultLanguage = lang; + if (this._observer.hasCallbacks(LanguageServiceEvent.DEFAULT_LANGUAGE_CHANGED)) { + this._observer.triggerEvent(LanguageServiceEvent.DEFAULT_LANGUAGE_CHANGED); + } + + if ( !this._language && this._observer.hasCallbacks(LanguageServiceEvent.CURRENT_LANGUAGE_CHANGED) ) { + this._observer.triggerEvent(LanguageServiceEvent.CURRENT_LANGUAGE_CHANGED); + } + + } + } + + public static getDefaultLanguage () : Language { + return this._defaultLanguage ?? Language.ENGLISH; + } + + public static getCurrentLanguage () : Language { + return this._language ?? this._defaultLanguage ?? Language.ENGLISH; + } + + public static setCurrentLanguage (lang : Language) { + if (this._language !== lang) { + this._language = lang; + if (this._observer.hasCallbacks(LanguageServiceEvent.CURRENT_LANGUAGE_CHANGED)) { + this._observer.triggerEvent(LanguageServiceEvent.CURRENT_LANGUAGE_CHANGED); + } + } + } + +} + diff --git a/LogService.test.ts b/LogService.test.ts new file mode 100644 index 0000000..4fd720c --- /dev/null +++ b/LogService.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { MockLogger } from "./logger/mock/MockLogger"; +import { LogLevel } from "./types/LogLevel"; +import { LogService } from "./LogService"; +import { Logger } from "./types/Logger"; +import { ContextLogger } from "./logger/context/ContextLogger"; + +describe('LogService', () => { + + let mockLogger: Logger; + let prevLogger : Logger; + let prevLogLevel: LogLevel; + let spyDebug : any; + let spyInfo : any; + let spyWarn : any; + let spyError : any; + + beforeEach(() => { + prevLogger = LogService.getLogger(); + prevLogLevel = LogService.getLogLevel(); + mockLogger = new MockLogger(); + spyDebug = jest.spyOn(mockLogger, 'debug').mockImplementation(() => {}); + spyInfo = jest.spyOn(mockLogger, 'info').mockImplementation(() => {}); + spyWarn = jest.spyOn(mockLogger, 'warn').mockImplementation(() => {}); + spyError = jest.spyOn(mockLogger, 'error').mockImplementation(() => {}); + LogService.setLogger(mockLogger); + LogService.setLogLevel(LogLevel.DEBUG); + }); + + afterEach( () => { + spyDebug.mockRestore(); + spyInfo.mockRestore(); + spyWarn.mockRestore(); + spyError.mockRestore(); + LogService.setLogLevel(prevLogLevel); + LogService.setLogger(prevLogger) + }); + + describe('#setLogLevel', () => { + + it('sets the log level to the specified value', () => { + LogService.setLogLevel(LogLevel.INFO); + expect(LogService.getLogLevel()).toEqual(LogLevel.INFO); + }); + + }); + + describe('#getLogLevel', () => { + + it('returns the current log level', () => { + expect(LogService.getLogLevel()).toEqual(LogLevel.DEBUG); + }); + + }); + + describe('#getLogLevelString', () => { + + it('returns the string representation of the current log level', () => { + expect(LogService.getLogLevelString()).toEqual('DEBUG'); + }); + + }); + + describe('#setLogger', () => { + + it('sets the logger to the specified value', () => { + const newLogger = new MockLogger(); + LogService.setLogger(newLogger); + expect(LogService['_logger']).toEqual(newLogger); + }); + + it('throws an error when the logger is not defined', () => { + expect(() => LogService.setLogger(undefined as any)).toThrow(TypeError); + }); + + }); + + describe('#debug', () => { + + it('logs messages with log level DEBUG', () => { + LogService.debug('test message'); + expect(mockLogger.debug).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level INFO', () => { + LogService.setLogLevel(LogLevel.INFO); + LogService.debug('test message'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + }); + + describe('#info', () => { + + it('logs messages with log level INFO', () => { + LogService.setLogLevel(LogLevel.INFO); + LogService.info('test message'); + expect(mockLogger.info).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level WARN', () => { + LogService.setLogLevel(LogLevel.WARN); + LogService.info('test message'); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + }); + + describe('#warn', () => { + + it('logs messages with log level WARN', () => { + LogService.setLogLevel(LogLevel.WARN); + LogService.warn('test message'); + expect(mockLogger.warn).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level ERROR', () => { + LogService.setLogLevel(LogLevel.ERROR); + LogService.warn('test message'); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + }); + + describe('#error', () => { + + it('logs messages with log level ERROR', () => { + LogService.setLogLevel(LogLevel.ERROR); + LogService.error('test message'); + expect(mockLogger.error).toHaveBeenCalledWith('test message'); + }); + + }); + + describe('#createLogger', () => { + + it('returns a new ContextLogger instance', () => { + const contextLogger = LogService.createLogger('test'); + expect(contextLogger).toBeInstanceOf(ContextLogger); + }); + + }); + +}); diff --git a/LogService.ts b/LogService.ts new file mode 100644 index 0000000..3cb7fd5 --- /dev/null +++ b/LogService.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2022-2023. Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2022. Sendanor. All rights reserved. + +import { LogLevel, stringifyLogLevel } from "./types/LogLevel"; +import { Logger } from "./types/Logger"; +import { ContextLogger } from "./logger/context/ContextLogger"; +import { ConsoleLogger } from "./logger/console/ConsoleLogger"; + +export class LogService { + + public static Level = LogLevel; + + private static _level : LogLevel = LogLevel.DEBUG; + private static _logger : Logger = new ConsoleLogger(); + + public static setLogLevel (value : LogLevel | undefined) : Logger { + this._level = value ?? LogLevel.DEBUG; + return this; + } + + public static getLogLevel () : LogLevel { + return this._level; + } + + public static getLogLevelString () : string { + return stringifyLogLevel(this._level); + } + + public static setLogger (value : Logger) { + if (!value) throw new TypeError(`The logger was not defined`); + this._logger = value; + } + + public static getLogger () : Logger { + return this._logger; + } + + /** + * Logs a debug message. + * + * @param args - The arguments to log. + * @see {@link LogLevel.DEBUG} + */ + public static debug (...args : readonly any[]) { + if (this._level <= LogLevel.DEBUG) { + this._logger.debug(...args); + } + } + + /** + * Logs an info message. + * + * @param args - The arguments to log. + * @see {@link LogLevel.INFO} + */ + public static info (...args : readonly any[]) { + if (this._level <= LogLevel.INFO) { + this._logger.info(...args); + } + } + + /** + * Logs a warning message. + * + * @param args - The arguments to log. + * @see @{@link LogLevel.WARN} + */ + public static warn (...args : readonly any[]) { + if (this._level <= LogLevel.WARN) { + this._logger.warn(...args); + } + } + + /** + * Logs an error message. + * + * @param args - The arguments to log. + * @see {@link LogLevel.ERROR} + */ + public static error (...args : readonly any[]) { + if (this._level <= LogLevel.ERROR) { + this._logger.error(...args); + } + } + + public static createLogger (name : string) : ContextLogger { + return new ContextLogger(name, LogService); + } + +} diff --git a/LogUtils.test.ts b/LogUtils.test.ts new file mode 100644 index 0000000..a59d2d4 --- /dev/null +++ b/LogUtils.test.ts @@ -0,0 +1,349 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogUtils } from "./LogUtils"; +import "../testing/jest/matchers"; + +describe('LogUtils', () => { + + describe('#stringifyArray', () => { + + it('returns empty string when given an empty array', () => { + expect(LogUtils.stringifyArray([])).toEqual(''); + }); + + it('converts an array of primitive types to a string', () => { + expect(LogUtils.stringifyArray([1, 'two', true])).toEqual('1 two true'); + }); + + it('converts an array of objects to a string', () => { + expect(LogUtils.stringifyArray([{ name: 'John' }, { name: 'Jane' }])).toEqual('{"name":"John"} {"name":"Jane"}'); + }); + + it('converts an array of mixed types to a string', () => { + expect(LogUtils.stringifyArray([1, 'two', { name: 'John' }, true])).toEqual('1 two {"name":"John"} true'); + }); + + }); + + describe('#stringifyValue', () => { + + it('converts a string to a string', () => { + expect(LogUtils.stringifyValue('hello')).toEqual('hello'); + }); + + it('converts a number to a string', () => { + expect(LogUtils.stringifyValue(42)).toEqual('42'); + }); + + it('converts a boolean to a string', () => { + expect(LogUtils.stringifyValue(true)).toEqual('true'); + expect(LogUtils.stringifyValue(false)).toEqual('false'); + }); + + it('converts an object to a string', () => { + expect(LogUtils.stringifyValue({ name: 'John' })).toEqual('{"name":"John"}'); + }); + + it('converts an Error object to a string', () => { + expect(LogUtils.stringifyValue(new Error('Fail'))).toEqual('Error: Fail'); + }); + + it('converts an TypeError object to a string', () => { + expect(LogUtils.stringifyValue(new TypeError('Fail'))).toEqual('TypeError: Fail'); + }); + + it('converts undefined to the string "undefined"', () => { + expect(LogUtils.stringifyValue(undefined)).toEqual('undefined'); + }); + + it('converts null to the string "null"', () => { + expect(LogUtils.stringifyValue(null)).toEqual('null'); + }); + + it('returns the value as a string when it cannot be JSON.stringify\'d', () => { + const value = { toString: () => 'custom object' }; + expect(LogUtils.stringifyValue(value)).toEqual('custom object'); + }); + + it('prefers toString() over toJSON() for custom objects', () => { + const value = { toString: () => 'custom text', toJSON: () => 'custom json' }; + expect(LogUtils.stringifyValue(value)).toEqual('custom text'); + }); + + it('returns a string for a Date object', () => { + const date = new Date(Date.UTC(2023, 4, 9, 9, 30, 0, 0)); + const result = LogUtils.stringifyValue(date); + expect(result).toBe('2023-05-09T09:30:00.000Z'); + }); + + }); + + describe('#splitStringValue', () => { + + it('can split long string to five rows', () => { + const rows = LogUtils.splitStringValue( + '1234567890abcdefghj\n', + 10, + '>>>', + '...\n' + ); + expect(rows).toBeArray(); + expect(rows[0]).toBe('123456...\n'); + expect(rows[1]).toBe('>>>789...\n'); + expect(rows[2]).toBe('>>>0ab...\n'); + expect(rows[3]).toBe('>>>cde...\n'); + expect(rows[4]).toBe('>>>fghj\n'); + expect(rows.length).toBe(5); + }); + + + it('can split long string to four rows 1', () => { + const rows = LogUtils.splitStringValue( + '1234567890abcdefgh\n', + 10, + '>>>', + '...\n' + ); + expect(rows).toBeArray(); + expect(rows[0]).toBe('123456...\n'); + expect(rows[1]).toBe('>>>789...\n'); + expect(rows[2]).toBe('>>>0ab...\n'); + expect(rows[3]).toBe('>>>cdefgh\n'); + expect(rows.length).toBe(4); + }); + + it('can split long string four rows 2', () => { + const rows = LogUtils.splitStringValue( + '1234567890abcdefg\n', + 10, + '>>>', + '...\n' + ); + expect(rows).toBeArray(); + expect(rows[0]).toBe('123456...\n'); + expect(rows[1]).toBe('>>>789...\n'); + expect(rows[2]).toBe('>>>0ab...\n'); + expect(rows[3]).toBe('>>>cdefg\n'); + expect(rows.length).toBe(4); + }); + + it('can split long string to four rows 3', () => { + const rows = LogUtils.splitStringValue( + '1234567890abcdef\n', + 10, + '>>>', + '...\n' + ); + expect(rows).toBeArray(); + expect(rows[0]).toBe('123456...\n'); + expect(rows[1]).toBe('>>>789...\n'); + expect(rows[2]).toBe('>>>0ab...\n'); + expect(rows[3]).toBe('>>>cdef\n'); + expect(rows.length).toBe(4); + }); + + it('can split long string to two rows', () => { + const rows = LogUtils.splitStringValue( + '1234567890\n', + 10, + '>>>', + '...\n' + ); + expect(rows).toBeArray(); + expect(rows[0]).toBe('123456...\n'); + expect(rows[1]).toBe('>>>7890\n'); + expect(rows.length).toBe(2); + }); + + it('can split long string to one row 1', () => { + const rows = LogUtils.splitStringValue( + '123456789\n', + 10, + '>>>', + '...\n' + ); + expect(rows).toBeArray(); + expect(rows[0]).toBe('123456789\n'); + expect(rows.length).toBe(1); + }); + + it('can split long string to one row 1', () => { + const rows = LogUtils.splitStringValue( + '12345\n', + 10, + '>>>', + '...\n' + ); + expect(rows).toBeArray(); + expect(rows[0]).toBe('12345\n'); + expect(rows.length).toBe(1); + }); + + it('can split short strings 1', () => { + const rows = LogUtils.splitStringValue( + '12345\n', + 5, + '> ', + '<\n' + ); + expect(rows).toBeArray(); + expect(rows[0]).toBe('123<\n'); + expect(rows[1]).toBe('> 45\n'); + expect(rows.length).toBe(2); + }); + + it('cannot split too short rows', () => { + expect( () => LogUtils.splitStringValue( + '12345\n', + 4, + '> ', + '<\n' + ) ).toThrow(new TypeError(`Max size to splitStringValue() must be greater than the length of prefix and suffix (4 < 4)`)); + }); + + }); + + describe('#splitStringArray', () => { + + let maxSize: number; + let prefix: string; + let suffix: string; + let linebreak: string; + + beforeEach(() => { + maxSize = 20; + prefix = '>>>'; + suffix = '...\n'; + linebreak = '\n'; + }); + + it('splits an array of strings into chunks of maximum length', () => { + const input = [ + 'This is a long sentence that will be split.', + 'Another long sentence that will be split as well.' + ]; + const result = LogUtils.splitStringArray(input, maxSize, prefix, suffix, linebreak); + expect(result).toStrictEqual( + [ + 'This is a long s...', + '>>>entence that ...', + '>>>will be split.', + 'Another long sen...', + '>>>tence that wi...', + '>>>ll be split a...', + '>>>s well.' + ] + ); + }); + + it('handles an empty input array', () => { + const input: string[] = []; + const result = LogUtils.splitStringArray(input, maxSize, prefix, suffix, linebreak); + expect(result).toStrictEqual([]); + }); + + it('handles an array of short strings', () => { + const input = [ + 'Short string.', + 'Another short word.' // Notice: this is exactly 19 characters. It should not be split. + ]; + const result = LogUtils.splitStringArray(input, maxSize, prefix, suffix, linebreak); + expect(result).toStrictEqual( + [ + 'Short string.', + 'Another short word.' + ] + ); + }); + + it('handles an array of mixed length strings', () => { + const input = [ + 'Short string.', + 'This is a long sentence that will be split.', + 'Short string.', + 'Another long sentence that will be split as well.' + ]; + const result = LogUtils.splitStringArray(input, maxSize, prefix, suffix, linebreak); + expect(result).toStrictEqual( + [ + 'Short string.', + 'This is a long s...', + '>>>entence that ...', + '>>>will be split.', + 'Short string.', + 'Another long sen...', + '>>>tence that wi...', + '>>>ll be split a...', + '>>>s well.' + ] + ); + }); + + it('returns an unchanged array if maxSize is large enough for all strings', () => { + const input = [ + 'Short string.', + 'Another short string.' + ]; + maxSize = 50; + const result = LogUtils.splitStringArray(input, maxSize, prefix, suffix, linebreak); + expect(result).toStrictEqual(input); + }); + + }); + + describe('#mergeStringArray', () => { + + it('merges an empty array into an empty array', () => { + const chunks = LogUtils.mergeStringArray([], 10, '\n'); + expect(chunks).toStrictEqual([]); + }); + + it('merges a single short row into a single chunk', () => { + const chunks = LogUtils.mergeStringArray(['hello'], 10, '\n'); + expect(chunks).toStrictEqual(['hello']); + }); + + it('merges multiple short rows into a single chunk', () => { + const chunks = LogUtils.mergeStringArray(['hello', 'world'], 20, '\n'); + expect(chunks).toStrictEqual(['hello\nworld']); + }); + + it('merges multiple rows into multiple chunks', () => { + const chunks = LogUtils.mergeStringArray(['hello', 'world'], 10, '\n'); + expect(chunks).toStrictEqual(['hello', 'world']); + }); + + it('merges rows with varying lengths into multiple chunks', () => { + const chunks = LogUtils.mergeStringArray( + [ + 'one', + 'two', + 'three', + 'four4', + 'five' + ], + 12, + '\n' + ); + expect(chunks).toStrictEqual( + [ + 'one\ntwo', + 'three\nfour4', // This line requires exactly 12 characters with new line + 'five' + ] + ); + }); + + it('merges rows with custom line break character', () => { + const chunks = LogUtils.mergeStringArray(['hello', 'world'], 20, '\r\n'); + expect(chunks).toStrictEqual(['hello\r\nworld']); + }); + + it('handles empty rows', () => { + const chunks = LogUtils.mergeStringArray(['hello', '', 'world'], 15, '\n'); + expect(chunks).toStrictEqual(['hello\n\nworld']); + }); + + }); + +}); diff --git a/LogUtils.ts b/LogUtils.ts new file mode 100644 index 0000000..c98b3c4 --- /dev/null +++ b/LogUtils.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isString } from "./types/String"; +import { map } from "./functions/map"; +import { isFunction } from "./types/Function"; +import { isBoolean } from "./types/Boolean"; +import { has } from "./functions/has"; +import { isObject } from "./types/Object"; +import { reduce } from "./functions/reduce"; +import { trimEnd } from "lodash"; + +export class LogUtils { + + public static stringifyArray (args: readonly any[]) : string { + return map(args, (arg) => LogUtils.stringifyValue(arg)).join(' '); + } + + public static stringifyValue (value: any) : string { + try { + if ( isString(value) ) return value; + if ( value === undefined ) return 'undefined'; + if ( value === null ) return 'null'; + if ( isBoolean(value) ) return value ? 'true' : 'false'; + if ( value && isObject(value) ) { + if ( value instanceof Error ) return `${value}`; + if ( value instanceof Date ) return value.toISOString(); + if ( has(value, 'toString') && isFunction(value?.toString) ) { + return value.toString(); + } + } + return JSON.stringify(value); + } catch (err) { + return `${value}`; + } + } + + /** + * Turns a long string into multiple rows with prefix and suffix + * added to the split rows. + * + * Notice, that this function will take into account that `maxSize` must + * include the complete prefix and suffix (including any newlines). E.g. + * it will split strings to exactly 19 character long when a maximum is 20 + * characters and suffix has a linebreak. + * + * @param value The target string to split + * @param maxSize Maximum line length, including newline characters. + * @param prefix The prefix to add to start of a split line, e.g. `...` + * @param suffix The suffix to add to end of a split line, e.g. `...\n`, + * including new line characters. + * @returns Array of chunks of strings without new line characters + * @see {@link LogUtils.splitStringArray} for full arrays + */ + public static splitStringValue ( + value: string, + maxSize: number, + prefix: string, + suffix: string + ) : readonly string[] { + if (maxSize < prefix.length + suffix.length + 1) { + throw new TypeError(`Max size to splitStringValue() must be greater than the length of prefix and suffix (${maxSize} < ${prefix.length + suffix.length})`); + } + let str : string = value; + let chunks : string[] = []; + while (str.length) { + const chunkPrefix : string = chunks.length === 0 ? '' : prefix; + const chunkSuffix : string = str.length <= maxSize - chunkPrefix.length ? '' : suffix; + const chunkSize : number = maxSize - chunkPrefix.length - chunkSuffix.length; + const chunkStr : string = str.substring(0, chunkSize); + // console.debug(`chunkStr = "${chunkStr}", size=${chunkSize}, prefix="${chunkPrefix}", suffix="${chunkSuffix}"`); + str = str.substring(chunkSize); + chunks.push( chunkPrefix + chunkStr + chunkSuffix ); + } + return chunks; + } + + /** + * Splits array of rows in a way that each chunk is maximum `maxSize` + * in length. + * + * Uses {@link LogUtils.splitStringValue} for the splitting. + * + * Notice, that this function will take into account that `maxSize` must + * include the complete prefix and suffix (including any newlines). E.g. + * it will split strings to exactly 19 character long when a maximum is 20 + * characters and suffix has a linebreak. + * + * @param input The input rows as a array of strings + * @param maxSize Maximum line length, including newline characters. + * @param prefix The prefix to add to start of a split line, e.g. `...` + * @param suffix The suffix to add to end of a split line, e.g. `...\n` + * @param linebreak The line break character, e.g. `\n` + * @returns Array of chunks of strings without new line characters + */ + public static splitStringArray ( + input: readonly string[], + maxSize: number, + prefix: string, + suffix: string, + linebreak: string + ) : string[] { + return reduce( + input, + (prevRows: string[], row : string) : string[] => { + let chunks = LogUtils.splitStringValue(row + linebreak, maxSize, prefix, suffix); + return [ + ...prevRows, + ...map(chunks, (chunk: string) => trimEnd(chunk, linebreak)) + ]; + }, + [] + ); + } + + /** + * This function takes an array of rows and merges them with line break + * character to chunks of maximum `chunkSize` characters, line breaks + * included. + * + * @param value Array of strings to merge + * @param chunkSize The maximum size for each chunk, line break characters + * included in the size. + * @param lineBreak The line break character + * @returns All chunks which are maximum of `chunkSize` in length + */ + public static mergeStringArray ( + value : readonly string[], + chunkSize : number, + lineBreak : string + ) : string[] { + let rows = map(value, (item: string) : string => item); + let chunks : string[] = []; + let nextChunk : string = ''; + while ( rows.length ) { + const row = rows.shift(); + if ( row !== undefined ) { + let nextRow = nextChunk ? lineBreak + row : row; + if ( nextChunk && nextChunk.length + nextRow.length > chunkSize ) { + chunks.push(nextChunk); + nextChunk = ''; + nextRow = row; + } + nextChunk += nextRow; + } + } + if ( nextChunk ) { + chunks.push(nextChunk); + } + return chunks; + } + +} diff --git a/NumberUtils.test.ts b/NumberUtils.test.ts new file mode 100644 index 0000000..148d1c6 --- /dev/null +++ b/NumberUtils.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { NumberUtils } from "./NumberUtils"; + +describe('NumberUtils', () => { + + describe('#parseNumber', () => { + + it('returns a number when a valid string number is given', () => { + const value = '123.45'; + const result = NumberUtils.parseNumber(value); + expect(result).toEqual(123.45); + }); + + it('returns a number when a valid numeric value is given', () => { + const value = 123.45; + const result = NumberUtils.parseNumber(value); + expect(result).toEqual(123.45); + }); + + it('returns undefined when undefined is given', () => { + const result = NumberUtils.parseNumber(undefined); + expect(result).toBeUndefined(); + }); + + it('returns undefined when a non-numeric string is given', () => { + const value = 'abc'; + const result = NumberUtils.parseNumber(value); + expect(result).toBeUndefined(); + }); + + it('returns undefined when NaN is given', () => { + const value = NaN; + const result = NumberUtils.parseNumber(value); + expect(result).toBeUndefined(); + }); + + it('returns undefined when a string with whitespaces only is given', () => { + const value = ' '; + const result = NumberUtils.parseNumber(value); + expect(result).toBeUndefined(); + }); + + }); + +}); diff --git a/NumberUtils.ts b/NumberUtils.ts new file mode 100644 index 0000000..52e4c0e --- /dev/null +++ b/NumberUtils.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { trim } from "lodash"; +import { LogService } from "./LogService"; +import { isString } from "./types/String"; + +const LOG = LogService.createLogger('NumberUtils'); + +export class NumberUtils { + + /** + * Validates string and returns float + * + * @param value + */ + public static parseNumber (value: any): number | undefined { + try { + if ( value === undefined ) return undefined; + if ( !isString(value) ) { + value = `${value}`; + } + value = trim(value); + if ( value === '' ) return undefined; + const parsedValue = parseFloat(value as string); + return isNaN(parsedValue) ? undefined : parsedValue; + } catch (err) { + LOG.warn(`toNumber: Error while parsing value as number "${value}": `, err); + return undefined; + } + } + +} diff --git a/ObjectUtils.test.ts b/ObjectUtils.test.ts new file mode 100644 index 0000000..ad13f39 --- /dev/null +++ b/ObjectUtils.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ObjectUtils } from "./ObjectUtils"; + +describe('ObjectUtils', () => { + describe('#isReservedPropertyName', () => { + + it('can check direct property name', () => { + + class Foo { + getName () : string { + return ''; + } + } + + expect( ObjectUtils.isReservedPropertyName(Foo.prototype, 'getName')).toBe(true); + expect( ObjectUtils.isReservedPropertyName(Foo.prototype, 'getBar')).toBe(false); + + }); + + it('can check extended property name', () => { + + class Foo { + getName () : string { + return ''; + } + } + + class Bar extends Foo { + getBar () : string { + return ''; + } + } + + expect( ObjectUtils.isReservedPropertyName(Bar.prototype, 'getName')).toBe(true); + expect( ObjectUtils.isReservedPropertyName(Bar.prototype, 'getBar')).toBe(true); + expect( ObjectUtils.isReservedPropertyName(Bar.prototype, 'getHello')).toBe(false); + + }); + + it('can check extended property name on three levels', () => { + + class Foo { + getName () : string { + return ''; + } + } + + class Bar extends Foo { + getBar () : string { + return ''; + } + } + + class Hello extends Bar { + getHello () : string { + return ''; + } + } + + expect( ObjectUtils.isReservedPropertyName(Hello.prototype, 'getName')).toBe(true); + expect( ObjectUtils.isReservedPropertyName(Hello.prototype, 'getBar')).toBe(true); + expect( ObjectUtils.isReservedPropertyName(Hello.prototype, 'getHello')).toBe(true); + expect( ObjectUtils.isReservedPropertyName(Hello.prototype, 'somethingNotThere')).toBe(false); + + }); + + }); +}); diff --git a/ObjectUtils.ts b/ObjectUtils.ts new file mode 100644 index 0000000..683cd78 --- /dev/null +++ b/ObjectUtils.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { has } from "./functions/has"; + +export class ObjectUtils { + + /** + * Returns true if the method name is already defined in this object or + * one of the prototypes. + * + * @param obj + * @param name + */ + public static isReservedPropertyName ( + obj : any, + name : string + ) : boolean { + if (!obj) return false; + if (has(obj, name)) return true; + const proto = Object.getPrototypeOf(obj); + if (!proto) return false; + if (proto === obj) return false; + return ObjectUtils.isReservedPropertyName(proto, name); + } + +} diff --git a/Observer.test.ts b/Observer.test.ts new file mode 100644 index 0000000..ed482ca --- /dev/null +++ b/Observer.test.ts @@ -0,0 +1,54 @@ +import { jest } from '@jest/globals'; +import { Observer } from './Observer'; + +describe('Observer', () => { + + describe('#getName', () => { + it('should return the name of the Observer', () => { + const observer = new Observer('my-observer'); + expect(observer.getName()).toBe('my-observer'); + }); + }); + + describe('destroy', () => { + it('should remove all data associated with the Observer', () => { + const observer = new Observer('my-observer'); + observer.destroy(); + // @ts-ignore + expect(observer._name).toBeUndefined(); + // @ts-ignore + expect(observer._callbacks).toBeUndefined(); + }); + }); + + describe('waitForEvent', () => { + + it('should resolve the returned Promise when the specified event is triggered', async () => { + const observer = new Observer('my-observer'); + const eventPromise = observer.waitForEvent('my-event', 100); + observer.triggerEvent('my-event'); + await eventPromise; + }); + + it('should reject the returned Promise if the specified event is not triggered within the specified timeout', async () => { + const observer = new Observer('my-observer'); + const eventPromise = observer.waitForEvent('my-event', 100); + eventPromise.catch(error => { + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/timed out/i); + }); + }); + + it('should resolve the returned Promise when the specified event is triggered multiple times', async () => { + const observer = new Observer('my-observer'); + console.warn = jest.fn(); + observer.triggerEvent('my-event'); + const eventPromise = observer.waitForEvent('my-event', 100); + observer.triggerEvent('my-event'); + await eventPromise; + expect(console.warn).toHaveBeenCalledWith(`Warning! The observer for "${observer.getName()}" did not have anything listening "${'my-event'}"`); + }); + + }); + +}); diff --git a/Observer.ts b/Observer.ts new file mode 100644 index 0000000..cd20b6a --- /dev/null +++ b/Observer.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2022-2023 Heusala Group . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { filter } from "./functions/filter"; +import { forEach } from "./functions/forEach"; +import { has } from "./functions/has"; +import { Disposable } from "./types/Disposable"; + +// NOTICE! THIS LIBRARY INTENTIONALLY DOES NOT USE HG LOGGER BECAUSE IT IS USED +// IN LOWER LEVEL CALLS + +/** + * A callback function that is registered to be executed when a particular event + * is triggered by an `Observer` instance. + * + * @param event The name of the event that was triggered. + * @param args An array of arguments that were passed to the `triggerEvent` + * method when the event was triggered. + */ +export interface ObserverCallback { + (event: EventName, ...args : T) : void; +} + +/** + * A function that can be used to stop listening to events on an `Observer` + * instance. + * + * When this function is called, it will remove the callback function that was + * registered with the `listenEvent` method + * from the list of callback functions that are triggered when the corresponding + * event is triggered. + */ +export interface ObserverDestructor { + () : void; +} + +/** + * An array of `ObserverCallback` functions that are registered to be executed + * when a particular event is triggered by an `Observer` instance. + */ +export type ObserverCallbackArray = Array>; + +/** + * A record that maps event names to `ObserverCallbackArray` arrays. + * + * This is used by the `Observer` class to store the callback functions that are + * registered to be executed when particular events are triggered. + */ +export type ObserverRecord = Record >; + +/** + * This is a simple observer implementation for implementing synchronous in-process events for a local service. + * + * You can use it like this: + * + * ``` + * enum FooEvent { + * CHANGED = "FooService:changed" + * } + * + * class FooService { + * + * private static _data : any; + * private static _observer : Observer = new Observer("FooService"); + * + * public static getData () : any { + * return this._data; + * } + * + * public static on (name : FooEvent, callback: ObserverCallback) : ObserverDestructor { + * return this._observer.listenEvent(name, callback); + * } + * + * public static refreshData () { + * HttpService.doSomething().then((response) => { + * this._data = response.data; + * this._observer.triggerEvent(FooEvent.CHANGED); + * }).catch(err => { + * console.error('Error: ', err); + * }); + * } + * + * } + * + * FooService.on(FooEvent.CHANGED, () => { + * + * const currentData = FooService.getData(); + * // ... + * + * }); + * + * FooService.refreshData(); + * + * ``` + * + */ +export class Observer implements Disposable { + + private _name : string; + private _callbacks : ObserverRecord; + + /** + * Returns the name of this `Observer` instance. + */ + getName () : string { + return this._name; + } + + /** + * Creates a new `Observer` instance. + * + * @param name You can name this observer, so that you know where it is used. + */ + public constructor (name: string) { + + this._name = name; + this._callbacks = {} as ObserverRecord; + + } + + public static create (name : string) : Observer { + return new Observer(name); + } + + /** + * Removes all data associated with this `Observer` instance. + * + * After this method is called, the `Observer` instance should no longer be used. + */ + public destroy () { + + // @ts-ignore + this._name = undefined; + + // @ts-ignore + this._callbacks = undefined; + + } + + /** + * Check if eventName has listeners. + * + * @param eventName + */ + public hasCallbacks (eventName : EventName) : boolean { + return has(this._callbacks, eventName); + } + + /** + * Trigger an event + * + * @param eventName + * @param args + */ + public triggerEvent (eventName : EventName, ...args : Array) { + + if (!this.hasCallbacks(eventName)) { + console.warn(`Warning! The observer for "${this._name}" did not have anything listening "${eventName.toString()}"`); + return; + } + + const callbacks = this._callbacks[eventName]; + + forEach(callbacks, (callback: any) => { + try { + callback(eventName, ...args); + } catch( e ) { + console.error(`Observer "${this._name}" and the event handler for "${eventName.toString()}" returned an exception: `, e); + } + }); + + } + + /** + * Start listening events. + * + * Returns destructor function. + * + * @param eventName + * @param callback + */ + public listenEvent (eventName : EventName, callback : ObserverCallback ) : ObserverDestructor { + + if (!this.hasCallbacks(eventName)) { + this._callbacks[eventName] = [ callback ]; + } else { + this._callbacks[eventName].push( callback ); + } + + return () => this.removeListener(eventName, callback); + + } + + /** + * Removes the first found listener callback for eventName + * + * @param eventName + * @param callback + */ + public removeListener (eventName : EventName, callback: ObserverCallback) : void { + + if (!this.hasCallbacks(eventName)) { + console.warn(`Warning! Could not remove callback since the observer for "${this._name}" did not have anything listening "${eventName.toString()}"`); + return; + } + + let removedOnce = false; + + this._callbacks[eventName] = filter(this._callbacks[eventName], (item: any) => { + if ( !removedOnce && item === callback ) { + removedOnce = true; + return false; + } + return true; + + }); + + if (this._callbacks[eventName].length === 0) { + delete this._callbacks[eventName]; + } + + if (!removedOnce) { + console.warn(`Warning! Could not remove the callback since the observer for "${this._name}" did not have that callback`); + return; + } + + } + + /** + * Returns a Promise that is resolved when the specified event is triggered. + * + * @param eventName The name of the event to wait for. + * @param time The maximum amount of time (in milliseconds) to wait for the + * event before timing out. + * @returns A Promise that is resolved when the specified event is triggered, + * or rejected with a timeout error if the event is not triggered + * within the specified timeout. + */ + public async waitForEvent ( + eventName : EventName, + time: number + ): Promise { + await new Promise( + (resolve, reject) => { + try { + let timeout: any | undefined = undefined; + let listener: ObserverDestructor | undefined = undefined; + timeout = setTimeout( + () => { + try { + timeout = undefined; + if ( listener ) { + listener(); + listener = undefined; + } + resolve(); + } catch (err) { + reject(err); + } + }, + time + ); + + listener = this.listenEvent( + eventName, + () => { + try { + if ( timeout ) { + clearTimeout(timeout); + timeout = undefined; + } + if (listener) { + listener(); + listener = undefined; + } + resolve(); + } catch (err) { + reject(err); + } + } + ); + + } catch (err) { + reject(err); + } + } + ); + } + + +} diff --git a/PermissionService.test.ts b/PermissionService.test.ts new file mode 100644 index 0000000..1014700 --- /dev/null +++ b/PermissionService.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { PermissionService } from "./PermissionService"; +import { LogLevel } from "./types/LogLevel"; +import { MockPermissionManager } from "./mocks/MockPermissionManager"; + +PermissionService.setLogLevel(LogLevel.WARN); + +describe('PermissionService', () => { + + const entityId = '123'; + const anotherEntityId = '567'; + const targetId = '1000'; + + describe('#constructor', () => { + + test('can create service', () => { + const manager = new MockPermissionManager(entityId, targetId, []); + const service = new PermissionService(manager); + expect( service ).toBeDefined(); + }); + + }); + + describe('#getEntityPermissionList', () => { + + test('can fetch entity permission list', async () => { + const manager = new MockPermissionManager(entityId, targetId, ['FOO', 'BAR']); + const service = new PermissionService(manager); + const result = await service.getEntityPermissionList(entityId, targetId); + expect( result ).toStrictEqual(['FOO', 'BAR']); + }); + + test('can fetch entity permission list for wrong item', async () => { + const manager = new MockPermissionManager(entityId, targetId, ['FOO', 'BAR']); + const service = new PermissionService(manager); + const result = await service.getEntityPermissionList(entityId, anotherEntityId); + expect( result ).toStrictEqual([]); + }); + + test('can fetch entity permission list for undefined target', async () => { + const manager = new MockPermissionManager(entityId, undefined, ['FOO', 'BAR']); + const service = new PermissionService(manager); + const result = await service.getEntityPermissionList(entityId); + expect( result ).toStrictEqual(['FOO', 'BAR']); + }); + + }); + + describe('#checkEntityPermission', () => { + + test('can check entity permission list', async () => { + const manager = new MockPermissionManager(entityId, targetId, ['FOO', 'BAR']); + const service = new PermissionService(manager); + const result = await service.checkEntityPermission(['FOO'], entityId, targetId); + expect( result ).toStrictEqual({FOO: true}); + }); + + test('can check invalid entity permission list', async () => { + const manager = new MockPermissionManager(entityId, targetId, ['FOO', 'BAR']); + const service = new PermissionService(manager); + const result = await service.checkEntityPermission(['HELLO'], entityId, targetId); + expect( result ).toStrictEqual({HELLO: false}); + }); + + test('can check partial entity permission list', async () => { + const manager = new MockPermissionManager(entityId, targetId, ['FOO', 'BAR']); + const service = new PermissionService(manager); + const result = await service.checkEntityPermission(['HELLO', 'FOO'], entityId, targetId); + expect( result ).toStrictEqual({HELLO: false, FOO: true}); + }); + + test('can check on empty entity permission list', async () => { + const manager = new MockPermissionManager(entityId, targetId, []); + const service = new PermissionService(manager); + const result = await service.checkEntityPermission(['HELLO', 'FOO'], entityId, targetId); + expect( result ).toStrictEqual({HELLO: false, FOO: false}); + }); + + test('can check wrong entity permission list', async () => { + const manager = new MockPermissionManager(entityId, targetId, ['FOO', 'BAR']); + const service = new PermissionService(manager); + const result = await service.checkEntityPermission(['HELLO', 'FOO'], anotherEntityId, targetId); + expect( result ).toStrictEqual({HELLO: false, FOO: false}); + }); + + }); + +}); diff --git a/PermissionService.ts b/PermissionService.ts new file mode 100644 index 0000000..baa01f0 --- /dev/null +++ b/PermissionService.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { PermissionManager } from "./types/PermissionManager"; +import { LogService } from "./LogService"; +import { PermissionList, PermissionObject, PermissionUtils } from "./PermissionUtils"; +import { LogLevel } from "./types/LogLevel"; + +const LOG = LogService.createLogger('PermissionService'); + +/** + * Service which handles permissions + */ +export class PermissionService { + + public static setLogLevel (value : LogLevel) { + LOG.setLogLevel(value); + } + + private readonly _manager : PermissionManager; + + /** + * + * @param manager + */ + public constructor ( + manager: PermissionManager + ) { + this._manager = manager; + } + + /** + * Fetch entity permissions + * + * @param entityId The entity who is performing the action + * @param targetId The entity which is the target of the action + * @returns Promise Promise of a list of permissions this entity has against `targetId` + */ + public async getEntityPermissionList ( + entityId : string, + targetId ?: string + ) : Promise> { + return await this._manager.getEntityPermissionList(entityId, targetId); + } + + /** + * Check permissions in the list + * + * @param checkPermissions List of permissions which are checked + * @param entityId Entity who is performing the action + * @param targetId The entity which is the target of the action + */ + public async checkEntityPermission ( + checkPermissions: PermissionList, + entityId: string, + targetId ?: string + ) : Promise { + LOG.debug(`checkEntityPermission: Checking entity "${entityId}" against permissions "${checkPermissions.join('|')}"`); + const entityPermissions = await this.getEntityPermissionList(entityId, targetId); + LOG.debug(`checkEntityPermission: Checking entity "${entityId}" [${entityPermissions.join('|')}] against permissions [${checkPermissions.join('|')}]`); + const result = PermissionUtils.checkPermissionList( + checkPermissions, + entityPermissions + ); + LOG.info(`checkEntityPermission: Checking entity "${entityId}" [${entityPermissions.join('|')}] against result [${checkPermissions.join('|')}]: `, result); + return result; + } + +} diff --git a/PermissionUtils.test.ts b/PermissionUtils.test.ts new file mode 100644 index 0000000..9353880 --- /dev/null +++ b/PermissionUtils.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { PermissionUtils } from "./PermissionUtils"; + +describe('PermissionUtils', () => { + + describe('#checkPermission', () => { + + test('can test valid permission with single item', () => { + expect( PermissionUtils.checkPermission('FOO', ['FOO']) ).toBe(true); + }); + + test('can test valid permission with multiple items', () => { + expect( PermissionUtils.checkPermission('FOO', ['BAR', 'FOO']) ).toBe(true); + expect( PermissionUtils.checkPermission('FOO', ['BAR', 'FOO', 'HELLO']) ).toBe(true); + expect( PermissionUtils.checkPermission('FOO', ['WORLD', 'BAR', 'FOO']) ).toBe(true); + expect( PermissionUtils.checkPermission('FOO', ['FOO', 'HELLO', 'WORLD']) ).toBe(true); + }); + + test('can test invalid permission when permission list is empty', () => { + expect( PermissionUtils.checkPermission('FOO', []) ).toBe(false); + }); + + test('can test invalid permission when permission list has incorrect type', () => { + expect( PermissionUtils.checkPermission('FOO', ['BAR']) ).toBe(false); + }); + + test('can test invalid permission when permission list has multiple types', () => { + expect( PermissionUtils.checkPermission('FOO', ['BAR', 'HELLO', 'WORLD']) ).toBe(false); + }); + + }); + + describe('#checkPermissionList', () => { + + test('can test invalid permission list when no permissions are accepted', () => { + expect( PermissionUtils.checkPermissionList([], ['FOO']) ).toStrictEqual({}); + }); + + test('can test empty invalid permission list when no permissions are accepted', () => { + expect( PermissionUtils.checkPermissionList( + [], []) ).toStrictEqual({}); + }); + + test('can test empty invalid permission list when one permission is accepted', () => { + expect( PermissionUtils.checkPermissionList( + ['BAR'], []) ).toStrictEqual({BAR: false}); + }); + + test('can test empty invalid permission list when some permissions are accepted', () => { + expect( PermissionUtils.checkPermissionList( + ['BAR', 'FOO'], []) ).toStrictEqual({BAR: false, FOO: false}); + }); + + test('can test valid permission list with single permission', () => { + expect( PermissionUtils.checkPermissionList( + ['FOO', 'BAR'], ['FOO']) ).toStrictEqual({FOO: true, BAR: false}); + }); + + test('can test valid permission list with two permissions', () => { + expect( PermissionUtils.checkPermissionList( + ['FOO', 'BAR'], ['FOO', 'BAR']) ).toStrictEqual({FOO: true, BAR: true}); + }); + + test('can test valid permission list with two permissions against single accepted permission', () => { + expect( PermissionUtils.checkPermissionList( + ['FOO'], ['FOO', 'BAR']) ).toStrictEqual({FOO:true}); + }); + + test('can test valid permission list with multiple permissions', () => { + expect( PermissionUtils.checkPermissionList( + ['FOO', 'BAR', 'HELLO'], ['FOO', 'BAR', 'HELLO']) ).toStrictEqual({FOO: true, BAR: true, HELLO: true}); + }); + + test('can test invalid permission list with single permission', () => { + expect( PermissionUtils.checkPermissionList( + ['HELLO', 'WORLD'], ['FOO']) ).toStrictEqual({HELLO: false, WORLD: false}); + }); + + test('can test invalid permission list with two permissions', () => { + expect( PermissionUtils.checkPermissionList( + ['HELLO', 'WORLD'], ['FOO', 'BAR']) ).toStrictEqual({HELLO: false, WORLD: false}); + }); + + test('can test invalid permission list with multiple permissions when only some match', () => { + expect( PermissionUtils.checkPermissionList( + ['HELLO', 'WORLD', 'XXX'], ['FOO', 'BAR', 'HELLO']) ).toStrictEqual({HELLO: true, WORLD: false, XXX: false}); + }); + + }); + + describe('#everyPermissionAccepted', () => { + + test('can test that no permission matches on zero permission object', () => { + expect( PermissionUtils.everyPermissionAccepted({}) ).toStrictEqual(false); + }); + + test('can test that no permission matches on single false permission', () => { + expect( PermissionUtils.everyPermissionAccepted({FOO: false}) ).toStrictEqual(false); + }); + + test('can test that no permission matches on partially matched permissions', () => { + expect( PermissionUtils.everyPermissionAccepted({FOO: false, BAR: true}) ).toStrictEqual(false); + }); + + test('can test that no permission matches on partially matched with three permissions', () => { + expect( PermissionUtils.everyPermissionAccepted({FOO: true, BAR: false, HELLO: true}) ).toStrictEqual(false); + }); + + + test('can test that every permission matches on on permission', () => { + expect( PermissionUtils.everyPermissionAccepted({FOO: true}) ).toStrictEqual(true); + }); + + test('can test that every permission matches on two permissions', () => { + expect( PermissionUtils.everyPermissionAccepted({FOO: true, BAR: true}) ).toStrictEqual(true); + }); + + test('can test that every permission matches on three permissions', () => { + expect( PermissionUtils.everyPermissionAccepted({FOO: true, BAR: true, HELLO: true}) ).toStrictEqual(true); + }); + + }); + + describe('#getAcceptedPermissionList', () => { + + test('it returns accepted permissions', () => { + expect( PermissionUtils.getAcceptedPermissionList({})).toStrictEqual([]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: false})).toStrictEqual([]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: false, BAR: false})).toStrictEqual([]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: false, BAR: false, WORLD: false})).toStrictEqual([]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: true})).toStrictEqual(["FOO"]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: true, BAR: false})).toStrictEqual(["FOO"]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: true, BAR: true})).toStrictEqual(["FOO", "BAR"]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: false, BAR: true})).toStrictEqual(["BAR"]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: true, HELLO: false, BAR: true})).toStrictEqual(["FOO", "BAR"]); + expect( PermissionUtils.getAcceptedPermissionList({FOO: true, HELLO: true, BAR: true})).toStrictEqual(["FOO", "HELLO", "BAR"]); + }); + + }); + + describe('#somePermissionAccepted', () => { + + test('can test that no permission matches on zero permission object', () => { + expect( PermissionUtils.somePermissionAccepted({}) ).toStrictEqual(false); + }); + + test('can test that no permission matches on single false permission', () => { + expect( PermissionUtils.somePermissionAccepted({FOO: false}) ).toStrictEqual(false); + }); + + test('can test that permission matches on partially matched permissions', () => { + expect( PermissionUtils.somePermissionAccepted({FOO: false, BAR: true}) ).toStrictEqual(true); + }); + + test('can test that permission matches on partially matched with three permissions', () => { + expect( PermissionUtils.somePermissionAccepted({FOO: true, BAR: false, HELLO: true}) ).toStrictEqual(true); + }); + + + test('can test that permission matches on single permission', () => { + expect( PermissionUtils.somePermissionAccepted({FOO: true}) ).toStrictEqual(true); + }); + + test('can test that every permission matches on two permissions', () => { + expect( PermissionUtils.somePermissionAccepted({FOO: true, BAR: true}) ).toStrictEqual(true); + }); + + test('can test that every permission matches on three permissions', () => { + expect( PermissionUtils.somePermissionAccepted({FOO: true, BAR: true, HELLO: true}) ).toStrictEqual(true); + }); + + + }); + +}); diff --git a/PermissionUtils.ts b/PermissionUtils.ts new file mode 100644 index 0000000..41091ac --- /dev/null +++ b/PermissionUtils.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { filter } from "./functions/filter"; +import { map } from "./functions/map"; +import { reduce } from "./functions/reduce"; +import { values } from "./functions/values"; +import { LogService } from "./LogService"; +import { TestCallback, TestCallbackNonStandard } from "./types/TestCallback"; +import { ExplainCallback } from "./types/ExplainCallback"; +import { isString } from "./types/String"; +import { keys } from "./functions/keys"; +import { everyValue } from "./functions/everyValue"; +import { explainArrayOf, isArrayOf } from "./types/Array"; + +const LOG = LogService.createLogger('PermissionUtils'); + +export type PermissionString = T; + +export type PermissionList = readonly PermissionString[]; + +export function isPermissionList ( + value : any, + isItem : TestCallback | undefined = undefined, + minLength : number | undefined = undefined, + maxLength : number | undefined = undefined +) : boolean { + return isArrayOf( + value, + isItem, + minLength, + maxLength + ); +} + +export function explainPermissionList ( + itemTypeName : string, + itemExplain : ExplainCallback, + value : any, + isItem : TestCallback | undefined = undefined, + minLength : number | undefined = undefined, + maxLength : number | undefined = undefined +) : string { + return explainArrayOf( + itemTypeName, + itemExplain, + value, + isItem, + minLength, + maxLength + ); +} + +export type PermissionObject = {readonly [key: string]: boolean}; + +export interface PermissionValueObject { + readonly permission : T; + readonly value : boolean; +} + +export class PermissionUtils { + + /** + * Validate a permission against a list of permissions + * + * @param permission The permission to check for + * @param acceptedPermissionList List of permissions that are accepted + * @return `true` if `permission` is included in the `permissionList` + */ + public static checkPermission ( + permission: PermissionString, + acceptedPermissionList: PermissionList + ) : boolean { + if (!isArrayOf(acceptedPermissionList)) { + LOG.warn(`Provided argument was not an array of strings: `, acceptedPermissionList); + return false; + } + return acceptedPermissionList.includes(permission); + } + + /** + * Checks against multiple permissions + * + * @param checkPermissions List of permissions that will be included in the return object + * @param targetPermissions List of permissions the target has + * @returns PermissionObject State of each permission from `checkPermissions` + */ + public static checkPermissionList ( + checkPermissions: PermissionList, + targetPermissions: PermissionList + ) : PermissionObject { + return reduce( + checkPermissions, + (result: PermissionObject, permission: PermissionString) : PermissionObject => { + return { + ...result, + [permission]: PermissionUtils.checkPermission( + permission, + targetPermissions + ) + }; + }, + {} + ); + } + + /** + * Verify permission object has all permissions enabled + * + * @param permissions + * @returns boolean `true` if every permission in the `permissions` object matches + */ + public static everyPermissionAccepted (permissions: PermissionObject) : boolean { + const permissionCount = values(permissions).length; + if (permissionCount === 0) return false; + return everyValue(permissions, (item: boolean) : boolean => item); + } + + /** + * + * @param permissions + * @param isPermissionKey + * @returns List of permissions that were enabled + */ + public static getAcceptedPermissionList ( + permissions: PermissionObject, + isPermissionKey: TestCallbackNonStandard = isString + ) : PermissionList { + return filter( + keys(permissions), + (key: string) : boolean => isPermissionKey(key) && permissions[key] + ) as unknown as PermissionList; + } + + /** + * Verify permission object has some permissions enabled + * + * @param permissions + * @returns boolean `true` if some permissions in the `permissions` object matches + */ + public static somePermissionAccepted (permissions: PermissionObject) : boolean { + const acceptedPermissions = this.getAcceptedPermissionList(permissions); + return acceptedPermissions.length !== 0; + } + + /** + * Populates an array of permission value objects + * + * @param showPermissions + * @param enabledPermissions + */ + public static createPermissionValueObjectArray ( + showPermissions: readonly T[], + enabledPermissions: readonly T[] + ) : readonly PermissionValueObject[] { + return map( + showPermissions, + (permission : T) : PermissionValueObject => ({ + permission, + value: enabledPermissions.includes(permission) + }) + ); + } + +} diff --git a/PhoneNumberUtils.ts b/PhoneNumberUtils.ts new file mode 100644 index 0000000..c6a9a6b --- /dev/null +++ b/PhoneNumberUtils.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { startsWith } from "./functions/startsWith"; +import { trim } from "./functions/trim"; +import { trimStart } from "./functions/trimStart"; +import { every } from "./functions/every"; + +export class PhoneNumberUtils { + + public static getTelLink (phone: string, countryCode: string = '+358') : string { + return `tel:${countryCode}${trimStart(`${phone ?? ''}`.replace(/[^+0-9]*/ig, ""), "0")}`; + } + + public static getTelLabel (phone: string) : string { + return `${trimStart(`${phone ?? ''}`.replace(/[^+0-9 ]*/ig, ""))}`; + } + + /** + * @fixme Add real country code checks + * @param phone + */ + public static validatePhoneNumber (phone: string) : boolean { + if ( (phone?.length ?? 0) <= 3 ) return false; + const s = phone[0]; + if ( !'+0'.includes(s) ) return false; + const rest = phone.substring(1); + return ( rest.length >= (s === '+' ? 3 : 3) ) && every( + rest, + (char: string) : boolean => '0123456789'.includes(char) + ); + } + + public static normalizePhoneAddress (value: string, defaultPrefix: string) : string { + value = trim(value); + return startsWith(value, '+') ? value : `${defaultPrefix}${startsWith(value, '0') ? value.substring(1) : value}` + } + +} diff --git a/ProcessUtils.test.ts b/ProcessUtils.test.ts new file mode 100644 index 0000000..5b28d0d --- /dev/null +++ b/ProcessUtils.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { ProcessUtils } from "./ProcessUtils"; +import FS from 'fs'; + +jest.mock('fs'); + +describe('ProcessUtils', () => { + + describe('getArguments', () => { + + it('should get process arguments', () => { + process.argv = ['node', 'script.js', 'arg1', 'arg2']; + const args = ProcessUtils.getArguments(); + expect(args).toEqual(['arg1', 'arg2']); + }); + + }); + + describe('parseEnvFileLine', () => { + + it('should parse environment variable line with equal sign', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key=value'); + expect(result).toEqual({ key: 'value' }); + }); + + it('should parse environment variable line without equal sign', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key'); + expect(result).toEqual({ key: '' }); + }); + + it('should parse environment variable line with double quotes sign', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key="value"'); + expect(result).toEqual({ key: 'value' }); + }); + + it('should parse environment variable line with double quotes sign and line breaks', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key="row1\nrow2\n"'); + expect(result).toEqual({ key: 'row1\nrow2\n' }); + }); + + it('should parse environment variable line with double quotes sign and line breaks with multiple keys', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key="row1\nrow2\n"\nkey2=value2'); + expect(result).toEqual({ key: 'row1\nrow2\n', key2: 'value2' }); + }); + + it('should parse environment variable line with escaped line breaks', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key=row1\\nrow2\\n'); + expect(result).toEqual({ key: 'row1\nrow2\n' }); + }); + + it('should parse environment variable line with escaped line breaks and other keys', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key=row1\\nrow2\\n\nkey2=value2'); + expect(result).toEqual({ key: 'row1\nrow2\n', key2: 'value2' }); + }); + + it('should parse environment variables with multiple lines and double quotes', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key="value"\nkey2="value2"'); + expect(result).toEqual({ key: 'value', key2: 'value2' }); + }); + + it('should parse environment variables with multiple lines', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key=value\nkey2=value2'); + expect(result).toEqual({ key: 'value', key2: 'value2' }); + }); + + it('should parse environment variables with multiple lines and mixed types', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key=value\nkey2="value2"'); + expect(result).toEqual({ key: 'value', key2: 'value2' }); + }); + + it('should parse environment variables with multiple lines and mixed types otherway around', () => { + const result = ProcessUtils.parseEnvFileLine({}, 'key="value"\nkey2=value2'); + expect(result).toEqual({ key: 'value', key2: 'value2' }); + }); + + }); + + describe('parseEnvFile', () => { + + it('should read and parse environment variables from file', () => { + (FS.readFileSync as any).mockReturnValue('key1=value1\nkey2=value2'); + const result = ProcessUtils.parseEnvFile('filepath'); + expect(result).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + }); + + describe('parseEnvString', () => { + + it('should parse environment variables from string', () => { + const envString = 'key1=value1\nkey2=value2'; + const result = ProcessUtils.parseEnvString(envString); + expect(result).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + it('should parse environment variables from double quoted string', () => { + const envString = 'key1="value1"\nkey2="value2"'; + const result = ProcessUtils.parseEnvString(envString); + expect(result).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + }); + + describe('initEnvFromRecord', () => { + it('should initialize environment variables from a record', () => { + const record = { key1: 'value1', key2: 'value2' }; + ProcessUtils.initEnvFromRecord(record); + expect(process.env.key1).toEqual('value1'); + expect(process.env.key2).toEqual('value2'); + }); + }); + + describe('setupDestroyHandler', () => { + + // Not correctly written test + it.skip('should set up a handler for process destruction', () => { + + const callback = jest.fn(); + const errorHandler = jest.fn(); + + ProcessUtils.setupDestroyHandler(callback, errorHandler); + + const closeProcess = (reason: any) => { + const event = new Error('Test error'); + process.emit(reason, event); + }; + + closeProcess('exit'); + expect(callback).toHaveBeenCalled(); + expect(errorHandler).toHaveBeenCalledWith(new Error('Test error')); + + callback.mockClear(); + errorHandler.mockClear(); + + closeProcess('SIGTERM'); + expect(callback).toHaveBeenCalled(); + expect(errorHandler).toHaveBeenCalledWith(new Error('Test error')); + + callback.mockClear(); + errorHandler.mockClear(); + + closeProcess('uncaughtException'); + expect(callback).toHaveBeenCalled(); + expect(errorHandler).toHaveBeenCalledWith(new Error('Test error')); + }); + + }); + +}); diff --git a/ProcessUtils.ts b/ProcessUtils.ts new file mode 100644 index 0000000..c47f1f6 --- /dev/null +++ b/ProcessUtils.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2020 Sendanor. All rights reserved. + +import FS from 'fs'; +import PATH from 'path'; +import { trim } from "./functions/trim"; +import { LogService } from './LogService'; +import { LogLevel } from "./types/LogLevel"; +import { indexOf } from "./functions/indexOf"; +import { trimStart } from "./functions/trimStart"; + +const LOG = LogService.createLogger('ProcessUtils'); + +export class ProcessUtils { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static getArguments () : string[] { + return process.argv.slice(2); + } + + public static parseEnvFileLine (obj : Record, input : string) : Record { + + if ( !input || !trimStart(input) ) return obj; + + let key : string; + const equalIndex : number = indexOf(input, '='); + + if (equalIndex < 0) { + key = trim(input); + if (key.length) { + return { + ...obj, + [key]: '' + }; + } else { + return obj; + } + } else { + key = input.substring(0, equalIndex); + key = trim(key); + } + + if (equalIndex === input.length - 1) { + return { + ...obj, + [input]: '' + }; + } + + let block : string; + if (input[equalIndex + 1] === '"') { + const equalIndexEnd : number = indexOf(input, '"', equalIndex + 2); + if (equalIndexEnd >= 0) { + block = input.substring(equalIndex + 2, equalIndexEnd); + return this.parseEnvFileLine( + { + ...obj, + [key]: block + }, + input.substring(equalIndexEnd+1) + ); + } else { + throw new TypeError('ProcessUtils.parseEnvFileLine: No ending for double quote detected'); + } + } + + const lineEnd : number = indexOf(input, '\n', equalIndex); + if (lineEnd < 0) { + block = input.substring(equalIndex + 1).trim(); + block = block.replace(/\\n/g, '\n'); + return { + ...obj, + [key]: block + }; + } else { + block = input.substring(equalIndex + 1, lineEnd).trim(); + block = block.replace(/\\n/g, '\n'); + return this.parseEnvFileLine( + { + ...obj, + [key]: block + }, + input.substring(lineEnd+1) + ); + } + } + + public static parseEnvString (input : string) { + return ProcessUtils.parseEnvFileLine({}, input); + } + + public static parseEnvFile (file: string) : Record { + const input : string = FS.readFileSync(file, {encoding:"utf-8"}); + return this.parseEnvString(input); + } + + public static initEnvFromRecord (params: Record) : void { + process.env = { + ...params, + ...process.env + }; + } + + public static initEnvFromFile (file: string) : void { + const params = ProcessUtils.parseEnvFile(file); + this.initEnvFromRecord(params); + } + + public static initEnvFromDefaultFiles () : void { + const file = PATH.join(process.cwd(), '.env'); + if (FS.existsSync(file)) { + ProcessUtils.initEnvFromFile(file); + } + } + + /** + * + * @param callback + * @param errorHandler + */ + public static setupDestroyHandler ( + callback : () => void, + errorHandler : (err : any) => void + ) : void { + + let destroyed = false; + + const closeProcessInternal = () => { + try { + if (destroyed) return; + destroyed = true; + callback(); + } catch (err) { + errorHandler(err); + } + }; + + const closeProcess = (reason: string) => { + return (err ?: any) => { + ProcessUtils._printErrors(reason, err); + closeProcessInternal(); + }; + }; + + process.on('exit', closeProcess('exit')); + process.on('SIGTERM', closeProcess('SIGTERM')); + process.on('SIGINT', closeProcess('SIGINT')); + process.on('SIGUSR1', closeProcess('SIGUSR1')); + process.on('SIGUSR2', closeProcess('SIGUSR2')); + process.on('uncaughtException', closeProcess('uncaughtException')); + + } + + private static _printErrors (reason: string, err ?: any) : void { + try { + if (reason === "exit") { + if (err) { + LOG.debug(`DEBUG: Closing process because "${reason}" event: `, err); + } else { + LOG.debug(`DEBUG: Closing process because "${reason}" event`); + } + } else if (err) { + LOG.error(`ERROR: Closing process because "${reason}" event: `, err); + } else { + LOG.info(`INFO: Closing process because "${reason}" event`); + } + } catch (err2) { + console.error('Error while printing errors: ', err2); + } + } + +} diff --git a/PromiseUtils.ts b/PromiseUtils.ts new file mode 100644 index 0000000..6e7956e --- /dev/null +++ b/PromiseUtils.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "./functions/map"; + +export class PromiseUtils { + + public static async waitTimeout (time: number) : Promise { + return await new Promise( (resolve, reject) => { + try { + setTimeout( + () => { + resolve(); + }, + time + ); + } catch (err) { + reject(err); + } + }); + } + + /** + * This method calls the `callback` function on each item in the `list`. + * + * It will start the `concurrentSize` amount of concurrent asynchronous jobs. + * + * @param list List of items to process asynchronously. + * @param callback The callback to call with each item as the argument. It + * is expected to return asynchronous promise in normal + * case. If this callback or the promise from it resolves as + * `false`, the processing will be stopped as soon as + * possible. + * @param concurrentSize The amount of concurrent asynchronous actions. + */ + public static async processConcurrently ( + list: readonly T[], + callback: (item: T) => false | undefined | void | Promise, + concurrentSize: number + ) : Promise { + const queue = map(list, (item: T) => item); + let shouldEnd : boolean = false; + while ( queue.length && !shouldEnd ) { + let promises : Promise[] = []; + let i = 0; + for(; i => { + const response = await callback(item); + if (response === false) { + shouldEnd = true; + } + }; + const promise = step(); + promises.push(promise); + } + } + await Promise.allSettled(promises); + } + if (shouldEnd) return false; + } + +} diff --git a/QueryParamUtils.test.ts b/QueryParamUtils.test.ts new file mode 100644 index 0000000..97f2e85 --- /dev/null +++ b/QueryParamUtils.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryParamUtils } from "./QueryParamUtils"; + +describe('QueryParamUtils', () => { + + describe('#stringifyQueryParams', () => { + + it('should return an empty string when no query parameters are provided', () => { + const params = {}; + const result = QueryParamUtils.stringifyQueryParams(params); + expect(result).toBe(''); + }); + + it('should return a query string when one query parameter is provided', () => { + const params = { + test: 'value', + }; + const result = QueryParamUtils.stringifyQueryParams(params); + expect(result).toBe('?test=value'); + }); + + it('should return a query string when multiple query parameters are provided', () => { + const params = { + param1: 'value1', + param2: 'value2', + }; + const result = QueryParamUtils.stringifyQueryParams(params); + expect(result).toBe('?param1=value1¶m2=value2'); + }); + + it('should encode special characters in the values of the query parameters', () => { + const params = { + param: 'value with spaces', + }; + const result = QueryParamUtils.stringifyQueryParams(params); + expect(result).toBe('?param=value+with+spaces'); + }); + + }); + +}); diff --git a/QueryParamUtils.ts b/QueryParamUtils.ts new file mode 100644 index 0000000..2234daf --- /dev/null +++ b/QueryParamUtils.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { forEach } from "./functions/forEach"; +import { keys } from "./functions/keys"; + +export interface QueryParams { + readonly [key:string] : string +} + +export class QueryParamUtils { + + /** + * Stringify query params from an object presentation. + * + * Appends the question mark. + * + * @param params + */ + public static stringifyQueryParams ( + params : QueryParams + ) : string { + let str = QueryParamUtils.stringifyQueryParamsOnly(params); + return str === '' ? '' : `?${str}`; + } + + /** + * Stringify query params from an object presentation. + * + * Does not append the question mark. + * + * @param params + */ + public static stringifyQueryParamsOnly ( + params : QueryParams + ) : string { + const a = new URLSearchParams(); + forEach( + keys(params), + (key: string) : void => { + const value : string = params[key]; + a.append(key, value); + } + ); + return a.toString(); + } + +} diff --git a/README.md b/README.md index 21fc08b..951f407 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,376 @@ -# io.hyperify.core -The core git module for Hyperify Framework +**Join our [Discord](https://discord.gg/UBTrHxA78f) to discuss about our software!** + +# @hyperifyio/io.hyperstack.core + +Common code for Hyperify Framework for TypeScript (as a Git Submodule). + +### Types in hyperstack + +Hyper stack architecture evolves around types. Everything can be presented as a +low level independent JSON data transfer object. Entity classes are higher level +presentations of these DTO types with an easy-to-use public API for the programmer +of Hyper stack application. + +#### Data transfer objects (DTOs) + +* Immutable +* Implemented as a TypeScript interface +* Can be presented as a pure JSON object +* Intended to be constructed and utilized using an entity class +* No methods (only utility functions) + +Data transfer objects are pure JSON-compatible objects which present an immutable +state of a unit of something. Each DTO type has its own counterpart entity class. +Usual way to construct a DTO object and it's utility functions is using the +entity class and its higher level chainable methods. + +#### Entity interfaces + +Entity interfaces describe and document the public API of an entity class. + +#### Entity classes + +Entity classes are implementations of these entity interfaces. These classes +will have the same internal properties as counterpart DTOs, but only provide +access to these properties using setter and getter methods. Names strictly +follow the same pattern based on the properties of the counterpart DTO. + +Entity factory can be used to implement these classes without actually +implementing the class itself. This is possible because of the strict pattern +for naming the functionality. Only the entity interface must be defined by the +programmer. + +### See also + +* [The Main Repository](https://github.com/sendanor/hyperstack) + +### It doesn't have many runtime dependencies + +### We don't have traditional releases + +We don't have traditional releases. This project evolves directly to our git repository in an agile manner. + +This git repository contains only the source code for a compile time use case. It is meant to be used as a git submodule in a NodeJS or webpack project. + +See also [hg.fi](https://hg.fi) for easy NPM package creators for your project and other additional modules from us. + +### License + +Copyright (c) Sendanor and Heusala Group Oy. All rights reserved. Licensed under the MIT License (the "[License](LICENSE.md)"); + +## Index + + * [Install & maintain our library](#install--maintain-our-library) + * [Checking out a project with git submodules](#checking-out-a-project-with-git-submodules) + * [Updating upstream library code](#updating-upstream-library-code) + * [Why git submodules, you may wonder?](#why-git-submodules-you-may-wonder) + * [LogService](#logservice) + * [Observer](#observer) + * [Request](#request) + * [RequestServer](#requestserver) + * [Repository](#repository) + * [ProcessUtils](#processutils) + * [ProcessUtils.initEnvFromDefaultFiles()](#processutilsinitenvfromdefaultfiles) + * [ProcessUtils.setupDestroyHandler(...)](#processutilssetupdestroyhandlershutdownhandler-errorhandler) + +## Install & maintain our library + +Run the installation commands from your project's root directory. Usually it's where your `package.json` is located. + +For these sample commands we expect your source files to be located in `./src` and we'll use `./src/fi/hg/core` for location for our submodule. + +Setup git submodule: + +```shell +mkdir -p src/fi/hg +git submodule add git@github.com:heusalagroup/fi.hg.core.git src/fi/hg/core +git config -f .gitmodules submodule.src/fi/hg/core.branch main +``` + +Next install our required dependencies (newest [lodash library](https://lodash.com/) and [reflect-metadata library](https://www.npmjs.com/package/reflect-metadata)): + +```shell +npm install --save-dev lodash @types/lodash +npm install --save-dev reflect-metadata +``` + +We also use the moment library for time: + +```shell +npm i 'moment-timezone' '@types/moment-timezone' +``` + +If you're going to develop NodeJS app, you might want to install also types for +NodeJS (this should be obvious though): + +```shell +npm install --save-dev @types/node +``` + +### TypeScript configurations + +The `"experimentalDecorators": true,` option must also be enabled in your +TypeScript configuration in your project's `./tsconfig.json`. + +### Checking out a project with git submodules + +Git doesn't automatically clone your sub modules. + +You'll need to command: + +```shell +git clone --recurse-submodules git@github.com:heusalagroup/your-project.git your-project +``` + +...or: + +```shell +git clone git@github.com:heusalagroup/your-project.git your-project +cd your-project +git submodule init +git submodule update +``` + +### Updating upstream library code + +Later when you want to update your submodules, you may do: + +```shell +git pull +git submodule update --remote +``` + +### Why git submodules, you may wonder? + +NPM doesn't provide a good way to implement pure compile time typescript libraries. + +We would have to compile our whole library in our bundle even though you probably don't use everything. + +It wouldn't be possible to use compile time optimizations and other ENV based feature flags. + +## LogService + +Our simple wrapper for `console` which allows naming the log context. + +```typescript +import LogService from "./src/fi/hg/core/LogService"; + +const LOG = LogService.createLogger("FooService"); + +export class FooService { + run(arg: string) { + LOG.debug("Did something: ", arg); + } +} +``` + +## Observer + +This is a simple observer implementation for implementing synchronous in-process events for a local service. + +You'll use it like this: + +```typescript +import Observer from "./src/fi/hg/core/Observer"; + +enum FooEvent { + CHANGED = "FooService:changed", +} + +class FooService { + private static _data: any; + + private static _data : any; + private static _observer : Observer = new Observer("GeoIpService"); + + public static getData () : any { + return this._data; + } + + public static on (name : FooEvent, callback : ObserverCallback) : ObserverDestructor { + return this._observer.listenEvent(name, callback); + } + + public static refreshData() { + HttpService.doSomething() + .then((response) => { + this._data = response.data; + + this._observer.triggerEvent(FooEvent.CHANGED); + }) + .catch((err) => { + console.error("Error: ", err); + }); + } +} + +FooService.on(FooEvent.CHANGED, () => { + const currentData = FooService.getData(); + // ... +}); + +FooService.refreshData(); +``` + +## Request + +HTTP request mapping annotations for TypeScript in the same style as in Java's Spring @RequestMapping. + +```typescript +import Request, { + GetMapping, + PostMapping, + RequestBody, + ResponseEntity, + RequestHeader, + RequestParam, + Headers +} from "./src/fi/hg/core/Request"; + +export interface ListDTO { + pageNumber: number; + pageSize: number; + content: Array; +} + +@RequestMapping("/foo/users") +@RequestMapping("/users") +export class UserController { + private readonly _userService: UserService; + + constructor(userService: UserService) { + this._userService = userService; + } + + @GetMapping("/", "/list") + public async getUserList( + @RequestParam("p", Request.ParamType.INTEGER) + pageNumber: number = 0, + @RequestParam("l", Request.ParamType.INTEGER) + pageSize: number = 10, + @RequestHeader('accept', {defaultValue: '*/*'}) + accept: string + ): Promise>> { + + // const parsedPageNumber = pageNumber ? parseInt(pageNumber, 10) : 0; + // const parsedPageSize = pageSize ? parseInt(pageSize, 10) : 10; + + return ResponseEntity.ok({ + pageNumber: pageNumber, + pageSize: pageSize, + content: await this._userService.getUserList(pageNumber, pageSize), + }); + + } + + @GetMapping("/items/{id}") + public async getUserList( + @PathVariable('id') + id: string + ): Promise> { + + return ResponseEntity.ok({ + itemId: id + }); + + } + + @PostMapping("/addUser") + public async addUser ( + @RequestBody user : Json, + @RequestHeader headers : Headers + ) : Promise> { + + const host = headers.getHost(); + + await this._userService.addUser(user); + + return ResponseEntity.ok({ + user: user, + host: host + }); + + } + +} +``` + +You can also use: + + * `@Request.mapping` instead of `@RequestMapping`, + * `@Request.param` instead of `@RequestParam`, + * `@Request.header` instead of `@RequestHeader`, + * `@Request.body` instead of `@RequestBody`, + * `@Request.getMapping(...)` instead of `GetMapping(...)` or `Request.mapping(Request.Method.GET, ...)` + * `@Request.putMapping(...)` instead of `PutMapping(...)` or `Request.mapping(Request.Method.PUT, ...)` + * `@Request.postMapping(...)` instead of `PostMapping(...)` or `Request.mapping(Request.Method.POST, ...)` + * `@Request.deleteMapping(...)` instead of `DeleteMapping(...)` or `Request.mapping(Request.Method.DELETE, ...)` + +For the actual server implementing REST API, see next chapter. + +## RequestServer + +This project also includes a simple and pure NodeJS implementation for the REST server implementing [our Request annotated controllers](#request): + +```typescript +import RequestServer from "./fi/hg/core/RequestServer"; +const server = new RequestServer("http://0.0.0.0:3000"); +server.attachController(UserController); +server.start(); +``` + +See also our [`ProcessUtils`](#processutils) for best practices implementing complete runtime support. + +## Repository + +We also provide a Spring Data inspired annotation mechanism for entities and `CrudRepository` implementation. + +It's available from [@heusalagroup/fi.hg.repository](https://github.com/heusalagroup/fi.hg.repository). + +## ProcessUtils + +### ProcessUtils.initEnvFromDefaultFiles() + +This utility class includes a simple implementation for runtime `.env` file support. + +```typescript +import ProcessUtils from "./fi/hg/core/ProcessUtils"; + +// Must be first import to define environment variables before anything else +ProcessUtils.initEnvFromDefaultFiles(); +``` + +### ProcessUtils.setupDestroyHandler(shutdownHandler, errorHandler) + +This utility function can be used to implement default shutdown handlers for the common runtime events. + +It will hook into events `exit`, `SIGTERM`, `SIGINT`, `SIGUSR1`, `SIGUSR2` and `uncaughtException`. + +The `shutdownHandler` will be called only once. + +If an exception is thrown, the `errorHandler` will be called with the exception. + +```typescript +import ProcessUtils from "./fi/hg/core/ProcessUtils"; + +const server = new Server(); + +server.start(); + +ProcessUtils.setupDestroyHandler( () => { + server.stop(); +}, (err : any) => { + LOG.error('Error while shutting down the service: ', err); +}); +``` + +### Upgrade from previous sendanor organization + +This project was originally under Sendanor's organization in Github. + +If that's the case for your local submodule, fix your git's remote: + +```shell +git remote set-url origin git@github.com:heusalagroup/fi.hg.core.git +``` + diff --git a/RequestClient.ts b/RequestClient.ts new file mode 100644 index 0000000..1e49c44 --- /dev/null +++ b/RequestClient.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestMethod } from "./request/types/RequestMethod"; +import { JsonAny } from "./Json"; +import { RequestClientAdapter } from "./requestClient/RequestClientAdapter"; +import { ResponseEntity } from "./request/types/ResponseEntity"; + +/** + * Implements higher level extended portable functionality for request clients, + * e.g. functionality shared between different request client adapters. + * + * Before using static methods of this library the implementation must be defined + * and selected using `RequestClientImpl.useClient()`. + * + * - See `HgNode.initialize()` which calls `useClient()` for Node.js projects + * - See `HgFrontend.initialize()` which calls `useClient()` for frontend projects + * + * You may also use it as a standard class: + * + * `const client = new RequestClient( clientImplementation ); + * + */ +export interface RequestClient { + + /** + * Returns the internal request adapter + */ + getClient () : RequestClientAdapter; + + + textEntityRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: string + ) : Promise>; + + getTextEntity ( + url : string, + headers ?: {[key: string]: string} + ) : Promise>; + + postTextEntity ( + url : string, + data ?: string, + headers ?: {[key: string]: string} + ) : Promise>; + + patchTextEntity ( + url : string, + data ?: string, + headers ?: {[key: string]: string} + ) : Promise>; + + putTextEntity ( + url : string, + data ?: string, + headers ?: {[key: string]: string} + ) : Promise>; + + deleteTextEntity ( + url : string, + headers ?: {[key: string]: string}, + data ?: string + ) : Promise>; + + + textRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: string + ) : Promise; + + getText ( + url : string, + headers ?: {[key: string]: string} + ) : Promise; + + postText ( + url : string, + data ?: string, + headers ?: {[key: string]: string} + ) : Promise; + + patchText ( + url : string, + data ?: string, + headers ?: {[key: string]: string} + ) : Promise; + + putText ( + url : string, + data ?: string, + headers ?: {[key: string]: string} + ) : Promise; + + deleteText ( + url : string, + headers ?: {[key: string]: string}, + data ?: string + ) : Promise; + + + jsonRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise; + + getJson ( + url : string, + headers ?: {[key: string]: string} + ) : Promise; + + postJson ( + url : string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ) : Promise; + + patchJson ( + url : string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ) : Promise; + + putJson ( + url : string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ) : Promise; + + deleteJson ( + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise; + + + + jsonEntityRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise>; + + getJsonEntity ( + url : string, + headers ?: {[key: string]: string} + ) : Promise>; + + postJsonEntity ( + url : string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ) : Promise>; + + patchJsonEntity ( + url : string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ) : Promise>; + + putJsonEntity ( + url : string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ) : Promise>; + + deleteJsonEntity ( + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise>; + +} diff --git a/RequestClientImpl.test.ts b/RequestClientImpl.test.ts new file mode 100644 index 0000000..8c20080 --- /dev/null +++ b/RequestClientImpl.test.ts @@ -0,0 +1,649 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { RequestClientImpl } from "./RequestClientImpl"; +import { RequestMethod } from "./request/types/RequestMethod"; +import { RequestClientAdapter } from "./requestClient/RequestClientAdapter"; +import { ResponseEntity } from "./request/types/ResponseEntity"; +import { JsonAny } from "./Json"; +import { LogLevel } from "./types/LogLevel"; + +describe("RequestClientImpl", () => { + + let mockRequestClient : RequestClientAdapter; + + beforeEach( () => { + RequestClientImpl.setLogLevel(LogLevel.NONE); + mockRequestClient = { + jsonEntityRequest: jest.fn<() => Promise>>().mockResolvedValue(ResponseEntity.ok<{}>({})), + textEntityRequest: jest.fn<() => Promise>>().mockResolvedValue(ResponseEntity.ok('')), + jsonRequest: jest.fn<() => Promise<{}>>().mockResolvedValue({}), + textRequest: jest.fn<() => Promise>().mockResolvedValue('') + }; + }); + + describe('instance', () => { + + let requestClient : RequestClientImpl; + + beforeEach( () => { + requestClient = RequestClientImpl.create(mockRequestClient); + }); + + describe("#textEntityRequest", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a GET request with the given method, url, headers, and data", async () => { + const response : ResponseEntity | undefined = await requestClient.textEntityRequest(RequestMethod.GET, "http://example.com", {}, "Hello"); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}, "Hello"); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#getTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a GET text request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.getTextEntity("http://example.com", {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#postTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a POST request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.postTextEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.POST, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#putTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a PUT request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.putTextEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.PUT, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#deleteTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a DELETE request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.deleteTextEntity("http://example.com", {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.DELETE, "http://example.com", {}, undefined); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#patchTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a PATCH request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.patchTextEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.PATCH, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + + describe("#jsonEntityRequest", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a GET request with the given method, url, headers, and data", async () => { + const response : ResponseEntity | undefined = await requestClient.jsonEntityRequest(RequestMethod.GET, "http://example.com", {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}, undefined); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#getJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a GET request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.getJsonEntity("http://example.com", {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#postJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a POST request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.postJsonEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.POST, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#putJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a PUT request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.putJsonEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.PUT, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#deleteJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a DELETE request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.deleteJsonEntity("http://example.com", {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.DELETE, "http://example.com", {}, undefined); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#patchJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a PATCH request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await requestClient.patchJsonEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.PATCH, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + + describe("#textRequest", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a GET request with the given method, url, headers, and data", async () => { + const response : string| undefined = await requestClient.textRequest(RequestMethod.GET, "http://example.com", {}, "Hello"); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}, "Hello"); + expect(response).toBe("Hello World"); + }); + }); + + describe("#getText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a GET text request with the given url and headers", async () => { + const response : string| undefined = await requestClient.getText("http://example.com", {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}); + expect(response).toBe("Hello World"); + }); + }); + + describe("#postText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a POST request with the given url and headers", async () => { + const response : string| undefined = await requestClient.postText("http://example.com", 'Hello', {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.POST, "http://example.com", {}, 'Hello'); + expect(response).toBe("Hello World"); + }); + }); + + describe("#putText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a PUT request with the given url and headers", async () => { + const response : string| undefined = await requestClient.putText("http://example.com", 'Hello', {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.PUT, "http://example.com", {}, 'Hello'); + expect(response).toBe("Hello World"); + }); + }); + + describe("#deleteText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a DELETE request with the given url and headers", async () => { + const response : string| undefined = await requestClient.deleteText("http://example.com", {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.DELETE, "http://example.com", {}, undefined); + expect(response).toBe("Hello World"); + }); + }); + + describe("#patchText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a PATCH request with the given url and headers", async () => { + const response : string| undefined = await requestClient.patchText("http://example.com", 'Hello', {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.PATCH, "http://example.com", {}, 'Hello'); + expect(response).toBe("Hello World"); + }); + }); + + + describe("#jsonRequest", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a GET request with the given method, url, headers, and data", async () => { + const response : JsonAny| undefined = await requestClient.jsonRequest(RequestMethod.GET, "http://example.com", {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}, undefined); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#getJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a GET request with the given url and headers", async () => { + const response : JsonAny| undefined = await requestClient.getJson("http://example.com", {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#postJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a POST request with the given url and headers", async () => { + const response : JsonAny| undefined = await requestClient.postJson("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.POST, "http://example.com", {}, 'Hello'); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#putJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a PUT request with the given url and headers", async () => { + const response : JsonAny| undefined = await requestClient.putJson("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.PUT, "http://example.com", {}, 'Hello'); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#deleteJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a DELETE request with the given url and headers", async () => { + const response : JsonAny| undefined = await requestClient.deleteJson("http://example.com", {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.DELETE, "http://example.com", {}, undefined); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#patchJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a PATCH request with the given url and headers", async () => { + const response : JsonAny| undefined = await requestClient.patchJson("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.PATCH, "http://example.com", {}, 'Hello'); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + }); + + describe('static', () => { + + beforeEach( () => { + RequestClientImpl.setClient(mockRequestClient); + }); + + describe("#textEntityRequest", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a GET request with the given method, url, headers, and data", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.textEntityRequest(RequestMethod.GET, "http://example.com", {}, "Hello"); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}, "Hello"); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#getTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a GET text request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.getTextEntity("http://example.com", {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#postTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a POST request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.postTextEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.POST, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#putTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a PUT request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.putTextEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.PUT, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#deleteTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a DELETE request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.deleteTextEntity("http://example.com", {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.DELETE, "http://example.com", {}, undefined); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + describe("#patchTextEntity", () => { + beforeEach( () => { + (mockRequestClient.textEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok('Hello World'))); + }); + it("makes a PATCH request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.patchTextEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.textEntityRequest).toHaveBeenCalledWith(RequestMethod.PATCH, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toBe("Hello World"); + }); + }); + + + describe("#jsonEntityRequest", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a GET request with the given method, url, headers, and data", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.jsonEntityRequest(RequestMethod.GET, "http://example.com", {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}, undefined); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#getJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a GET request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.getJsonEntity("http://example.com", {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#postJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a POST request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.postJsonEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.POST, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#putJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a PUT request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.putJsonEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.PUT, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#deleteJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a DELETE request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.deleteJsonEntity("http://example.com", {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.DELETE, "http://example.com", {}, undefined); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#patchJsonEntity", () => { + beforeEach( () => { + (mockRequestClient.jsonEntityRequest as any).mockImplementationOnce(() => Promise.resolve(ResponseEntity.ok({'hello': 'world'}))); + }); + it("makes a PATCH request with the given url and headers", async () => { + const response : ResponseEntity | undefined = await RequestClientImpl.patchJsonEntity("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonEntityRequest).toHaveBeenCalledWith(RequestMethod.PATCH, "http://example.com", {}, 'Hello'); + expect(response).toBeDefined(); + expect(response.getStatusCode()).toBe(200); + expect(response.getHeaders()).toEqual({}); + expect(response.getBody()).toStrictEqual({'hello': 'world'}); + }); + }); + + + describe("#textRequest", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a GET request with the given method, url, headers, and data", async () => { + const response : string| undefined = await RequestClientImpl.textRequest(RequestMethod.GET, "http://example.com", {}, "Hello"); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}, "Hello"); + expect(response).toBe("Hello World"); + }); + }); + + describe("#getText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a GET text request with the given url and headers", async () => { + const response : string| undefined = await RequestClientImpl.getText("http://example.com", {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}); + expect(response).toBe("Hello World"); + }); + }); + + describe("#postText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a POST request with the given url and headers", async () => { + const response : string| undefined = await RequestClientImpl.postText("http://example.com", 'Hello', {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.POST, "http://example.com", {}, 'Hello'); + expect(response).toBe("Hello World"); + }); + }); + + describe("#putText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a PUT request with the given url and headers", async () => { + const response : string| undefined = await RequestClientImpl.putText("http://example.com", 'Hello', {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.PUT, "http://example.com", {}, 'Hello'); + expect(response).toBe("Hello World"); + }); + }); + + describe("#deleteText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a DELETE request with the given url and headers", async () => { + const response : string| undefined = await RequestClientImpl.deleteText("http://example.com", {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.DELETE, "http://example.com", {}, undefined); + expect(response).toBe("Hello World"); + }); + }); + + describe("#patchText", () => { + beforeEach( () => { + (mockRequestClient.textRequest as any).mockImplementationOnce(() => Promise.resolve('Hello World')); + }); + it("makes a PATCH request with the given url and headers", async () => { + const response : string| undefined = await RequestClientImpl.patchText("http://example.com", 'Hello', {}); + expect(mockRequestClient.textRequest).toHaveBeenCalledWith(RequestMethod.PATCH, "http://example.com", {}, 'Hello'); + expect(response).toBe("Hello World"); + }); + }); + + + describe("#jsonRequest", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a GET request with the given method, url, headers, and data", async () => { + const response : JsonAny| undefined = await RequestClientImpl.jsonRequest(RequestMethod.GET, "http://example.com", {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}, undefined); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#getJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a GET request with the given url and headers", async () => { + const response : JsonAny| undefined = await RequestClientImpl.getJson("http://example.com", {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.GET, "http://example.com", {}); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#postJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a POST request with the given url and headers", async () => { + const response : JsonAny| undefined = await RequestClientImpl.postJson("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.POST, "http://example.com", {}, 'Hello'); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#putJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a PUT request with the given url and headers", async () => { + const response : JsonAny| undefined = await RequestClientImpl.putJson("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.PUT, "http://example.com", {}, 'Hello'); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#deleteJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a DELETE request with the given url and headers", async () => { + const response : JsonAny| undefined = await RequestClientImpl.deleteJson("http://example.com", {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.DELETE, "http://example.com", {}, undefined); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + describe("#patchJson", () => { + beforeEach( () => { + (mockRequestClient.jsonRequest as any).mockImplementationOnce(() => Promise.resolve({'hello': 'world'})); + }); + it("makes a PATCH request with the given url and headers", async () => { + const response : JsonAny| undefined = await RequestClientImpl.patchJson("http://example.com", 'Hello', {}); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith(RequestMethod.PATCH, "http://example.com", {}, 'Hello'); + expect(response).toStrictEqual({'hello': 'world'}); + }); + }); + + }); + +}); diff --git a/RequestClientImpl.ts b/RequestClientImpl.ts new file mode 100644 index 0000000..b3f0a6e --- /dev/null +++ b/RequestClientImpl.ts @@ -0,0 +1,582 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestMethod } from "./request/types/RequestMethod"; +import { JsonAny } from "./Json"; +import { RequestClientAdapter } from "./requestClient/RequestClientAdapter"; +import { LogService } from "./LogService"; +import { LogLevel } from "./types/LogLevel"; +import { ResponseEntity } from "./request/types/ResponseEntity"; +import { RequestClient } from "./RequestClient"; + +const LOG = LogService.createLogger( 'RequestClientImpl' ); + +/** + * @inheritDoc + */ +export class RequestClientImpl implements RequestClient { + + /** Internal state for static methods. + * + * You must call `.useClient()` to initialize it + * + * @private + */ + private static _client: RequestClient | undefined = undefined; + + /** + * Internal state for normal methods + * @private + */ + private readonly _adapter: RequestClientAdapter; + + /** + * Creates client instance + * + * @param client + * @deprecated Use RequestClientImpl.create(), the direct constructor will be changed to protected later. + */ + protected constructor (client: RequestClientAdapter) { + this._adapter = client; + } + + /** + * @inheritDoc + */ + public getClient (): RequestClientAdapter { + return this._adapter; + } + + /** + * @inheritDoc + */ + public async textEntityRequest ( + method: RequestMethod, + url: string, + headers ?: {[key: string]: string}, + data ?: string + ): Promise> { + return await this._adapter.textEntityRequest( method, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async getTextEntity ( + url: string, + headers ?: {[key: string]: string} + ): Promise> { + return await this._adapter.textEntityRequest( RequestMethod.GET, url, headers ); + } + + /** + * @inheritDoc + */ + public async postTextEntity ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise> { + LOG.debug( '.postJson: ', url, data, headers ); + return await this._adapter.textEntityRequest( RequestMethod.POST, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async patchTextEntity ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise> { + LOG.debug( '.patchJson: ', url, data, headers ); + return await this._adapter.textEntityRequest( RequestMethod.PATCH, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async putTextEntity ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise> { + LOG.debug( '.putJson: ', url, data, headers ); + return await this._adapter.textEntityRequest( RequestMethod.PUT, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async deleteTextEntity ( + url: string, + headers ?: {[key: string]: string}, + data ?: string + ): Promise> { + LOG.debug( '.deleteJson: ', url, data, headers ); + return await this._adapter.textEntityRequest( RequestMethod.DELETE, url, headers, data ); + } + + + /** + * @inheritDoc + */ + public async textRequest ( + method: RequestMethod, + url: string, + headers ?: {[key: string]: string}, + data ?: string + ): Promise { + return await this._adapter.textRequest( method, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async getText ( + url: string, + headers ?: {[key: string]: string} + ): Promise { + return await this._adapter.textRequest( RequestMethod.GET, url, headers ); + } + + /** + * @inheritDoc + */ + public async postText ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise { + LOG.debug( '.postJson: ', url, data, headers ); + return await this._adapter.textRequest( RequestMethod.POST, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async patchText ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise { + LOG.debug( '.patchJson: ', url, data, headers ); + return await this._adapter.textRequest( RequestMethod.PATCH, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async putText ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise { + LOG.debug( '.putJson: ', url, data, headers ); + return await this._adapter.textRequest( RequestMethod.PUT, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async deleteText ( + url: string, + headers ?: {[key: string]: string}, + data ?: string + ): Promise { + LOG.debug( '.deleteJson: ', url, data, headers ); + return await this._adapter.textRequest( RequestMethod.DELETE, url, headers, data ); + } + + + /** + * @inheritDoc + */ + public async jsonRequest ( + method: RequestMethod, + url: string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ): Promise { + return await this._adapter.jsonRequest( method, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async getJson ( + url: string, + headers ?: {[key: string]: string} + ): Promise { + return await this._adapter.jsonRequest( RequestMethod.GET, url, headers ); + } + + /** + * @inheritDoc + */ + public async postJson ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise { + return await this._adapter.jsonRequest( RequestMethod.POST, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async patchJson ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise { + return await this._adapter.jsonRequest( RequestMethod.PATCH, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async putJson ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise { + return await this._adapter.jsonRequest( RequestMethod.PUT, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async deleteJson ( + url: string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ): Promise { + return await this._adapter.jsonRequest( RequestMethod.DELETE, url, headers, data ); + } + + + /** + * @inheritDoc + */ + public async jsonEntityRequest ( + method: RequestMethod, + url: string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ): Promise> { + return await this._adapter.jsonEntityRequest( method, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async getJsonEntity ( + url: string, + headers ?: {[key: string]: string} + ): Promise> { + return await this._adapter.jsonEntityRequest( RequestMethod.GET, url, headers ); + } + + /** + * @inheritDoc + */ + public async postJsonEntity ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise> { + return await this._adapter.jsonEntityRequest( RequestMethod.POST, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async patchJsonEntity ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise> { + return await this._adapter.jsonEntityRequest( RequestMethod.PATCH, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async putJsonEntity ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise> { + return await this._adapter.jsonEntityRequest( RequestMethod.PUT, url, headers, data ); + } + + /** + * @inheritDoc + */ + public async deleteJsonEntity ( + url: string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ): Promise> { + return await this._adapter.jsonEntityRequest( RequestMethod.DELETE, url, headers, data ); + } + + + public static create (client: RequestClientAdapter): RequestClientImpl { + return new RequestClientImpl( client ); + } + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel( level ); + } + + public static setClient ( + client: RequestClientAdapter + ) { + this._client = new RequestClientImpl( client ); + } + + public static hasClient (): boolean { + return !!this._client; + } + + public static getClient (): RequestClientAdapter { + if ( !this._client ) { + throw new TypeError( 'Client has not been initialized yet' ); + } + return this._client.getClient(); + } + + public static async textRequest ( + method: RequestMethod, + url: string, + headers ?: {[key: string]: string}, + data ?: string + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.textRequest( method, url, headers, data ); + } + + public static async getText ( + url: string, + headers ?: {[key: string]: string} + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.getText( url, headers ); + } + + public static async postText ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise { + if ( !this._client ) throw this._createClientError(); + LOG.debug( '.postJson: ', url, data, headers ); + return await this._client.postText( url, data, headers ); + } + + public static async patchText ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise { + if ( !this._client ) throw this._createClientError(); + LOG.debug( '.patchJson: ', url, data, headers ); + return await this._client.patchText( url, data, headers ); + } + + public static async putText ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.putText( url, data, headers ); + } + + public static async deleteText ( + url: string, + headers ?: {[key: string]: string}, + data ?: string + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.deleteText( url, headers, data ); + } + + public static async jsonRequest ( + method: RequestMethod, + url: string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.jsonRequest( method, url, headers, data ); + } + + public static async getJson ( + url: string, + headers ?: {[key: string]: string} + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.getJson( url, headers ); + } + + public static async postJson ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise { + if ( !this._client ) throw this._createClientError(); + LOG.debug( '.postJson: ', url, data, headers ); + return await this._client.postJson( url, data, headers ); + } + + public static async patchJson ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.patchJson( url, data, headers ); + } + + public static async putJson ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.putJson( url, data, headers ); + } + + public static async deleteJson ( + url: string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ): Promise { + if ( !this._client ) throw this._createClientError(); + return await this._client.deleteJson( url, headers, data ); + } + + public static async textEntityRequest ( + method: RequestMethod, + url: string, + headers ?: {[key: string]: string}, + data ?: string + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.textEntityRequest( method, url, headers, data ); + } + + public static async getTextEntity ( + url: string, + headers ?: {[key: string]: string} + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.getTextEntity( url, headers ); + } + + public static async postTextEntity ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise> { + if ( !this._client ) throw this._createClientError(); + LOG.debug( '.postJson: ', url, data, headers ); + return await this._client.postTextEntity( url, data, headers ); + } + + public static async patchTextEntity ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise> { + if ( !this._client ) throw this._createClientError(); + LOG.debug( '.patchJson: ', url, data, headers ); + return await this._client.patchTextEntity( url, data, headers ); + } + + public static async putTextEntity ( + url: string, + data ?: string, + headers ?: {[key: string]: string} + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.putTextEntity( url, data, headers ); + } + + public static async deleteTextEntity ( + url: string, + headers ?: {[key: string]: string}, + data ?: string + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.deleteTextEntity( url, headers, data ); + } + + public static async jsonEntityRequest ( + method: RequestMethod, + url: string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.jsonEntityRequest( method, url, headers, data ); + } + + public static async getJsonEntity ( + url: string, + headers ?: {[key: string]: string} + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.getJsonEntity( url, headers ); + } + + public static async postJsonEntity ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise> { + if ( !this._client ) throw this._createClientError(); + LOG.debug( '.postJson: ', url, data, headers ); + return await this._client.postJsonEntity( url, data, headers ); + } + + public static async patchJsonEntity ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.patchJsonEntity( url, data, headers ); + } + + public static async putJsonEntity ( + url: string, + data ?: JsonAny, + headers ?: {[key: string]: string} + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.putJsonEntity( url, data, headers ); + } + + public static async deleteJsonEntity ( + url: string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ): Promise> { + if ( !this._client ) throw this._createClientError(); + return await this._client.deleteJsonEntity( url, headers, data ); + } + + + /** + * @throw TypeError + * @private + */ + private static _createClientError () { + return new TypeError( `RequestClient: You must initialize implementation first using HgFrontend.initialize() or HgNode.initialize()` ); + } + +} diff --git a/RequestServer.ts b/RequestServer.ts new file mode 100644 index 0000000..fb834ee --- /dev/null +++ b/RequestServer.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ObserverCallback, ObserverDestructor } from "./Observer"; +import { Disposable } from "./types/Disposable"; + +export enum RequestServerEvent { + CONTROLLER_ATTACHED = "RequestServer:controllerAttached", + STARTED = "RequestServer:started", + STOPPED = "RequestServer:stopped" +} + +export type RequestServerDestructor = ObserverDestructor; + +export interface RequestServer extends Disposable { + + on ( + name: RequestServerEvent, + callback: ObserverCallback + ): RequestServerDestructor; + + destroy (): void; + + attachController ( + controller : any + ) : void; + + start () : void; + + stop () : void; + +} diff --git a/SiTranslationUtils.tsx b/SiTranslationUtils.tsx new file mode 100644 index 0000000..5888847 --- /dev/null +++ b/SiTranslationUtils.tsx @@ -0,0 +1,27 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SiUtils } from "./SiUtils"; +import { getCommonShortSi } from "./store/constants/storeTranslation"; +import { TranslationFunction } from "./types/TranslationFunction"; + +export class SiTranslationUtils { + + public static formatSiBinary ( + value: number, + label: string, + t: TranslationFunction + ): string { + const [ size, type ] = SiUtils.getBinaryPair(value); + return `${size.toFixed(2)} ${t(getCommonShortSi(type))}${t(label)}`; + } + + public static formatSiDecimal ( + value: number, + label: string, + t: TranslationFunction + ): string { + const [ size, type ] = SiUtils.getDecimalPair(value); + return `${size.toFixed(2)} ${t(getCommonShortSi(type))}${t(label)}`; + } + +} diff --git a/SiUtils.ts b/SiUtils.ts new file mode 100644 index 0000000..9033ff9 --- /dev/null +++ b/SiUtils.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SiType } from "./types/SiType"; + +export const KILO_IN_BINARY = 1024; +export const MEGA_IN_BINARY = 1024*1024; +export const GIGA_IN_BINARY = 1024*1024*1024; +export const TERA_IN_BINARY = 1024*1024*1024*1024; +export const PETA_IN_BINARY = 1024*1024*1024*1024*1024; +export const EXA_IN_BINARY = 1024*1024*1024*1024*1024*1024; +export const ZETTA_IN_BINARY = 1024*1024*1024*1024*1024*1024*1024; +export const YOTTA_IN_BINARY = 1024*1024*1024*1024*1024*1024*1024*1024; + +export const KILO_IN_DECIMAL = 1000; +export const MEGA_IN_DECIMAL = 1000*1000; +export const GIGA_IN_DECIMAL = 1000*1000*1000; +export const TERA_IN_DECIMAL = 1000*1000*1000*1000; +export const PETA_IN_DECIMAL = 1000*1000*1000*1000*1000; +export const EXA_IN_DECIMAL = 1000*1000*1000*1000*1000*1000; +export const ZETTA_IN_DECIMAL = 1000*1000*1000*1000*1000*1000*1000; +export const YOTTA_IN_DECIMAL = 1000*1000*1000*1000*1000*1000*1000*1000; + +export type SiPair = [number, SiType]; + +export class SiUtils { + + public static getValue (value: number, type: SiType) : number { + switch (type) { + case SiType.NONE : return value; + case SiType.KILO : return value/KILO_IN_BINARY; + case SiType.MEGA : return value/MEGA_IN_BINARY; + case SiType.GIGA : return value/GIGA_IN_BINARY; + case SiType.TERA : return value/TERA_IN_BINARY; + case SiType.PETA : return value/PETA_IN_BINARY; + case SiType.EXA : return value/EXA_IN_BINARY; + case SiType.ZETTA : return value/ZETTA_IN_BINARY; + case SiType.YOTTA : return value/YOTTA_IN_BINARY; + default: throw new TypeError(`SiType not implemented: ${type}`); + } + } + + public static getPair (value: number, type: SiType) : SiPair { + return [SiUtils.getValue(value, type), type]; + } + + public static getBinaryPair (value: number) : SiPair { + if (value < KILO_IN_BINARY) return SiUtils.getPair(value, SiType.NONE); + if (value < MEGA_IN_BINARY) return SiUtils.getPair(value, SiType.KILO); + if (value < GIGA_IN_BINARY) return SiUtils.getPair(value, SiType.MEGA); + if (value < TERA_IN_BINARY) return SiUtils.getPair(value, SiType.GIGA); + if (value < PETA_IN_BINARY) return SiUtils.getPair(value, SiType.TERA); + if (value < EXA_IN_BINARY) return SiUtils.getPair(value, SiType.PETA); + if (value < ZETTA_IN_BINARY) return SiUtils.getPair(value, SiType.EXA); + if (value < YOTTA_IN_BINARY) return SiUtils.getPair(value, SiType.ZETTA); + return SiUtils.getPair(value, SiType.YOTTA); + } + + public static getDecimalPair (value: number) : SiPair { + if (value < KILO_IN_DECIMAL) return SiUtils.getPair(value, SiType.NONE); + if (value < MEGA_IN_DECIMAL) return SiUtils.getPair(value, SiType.KILO); + if (value < GIGA_IN_DECIMAL) return SiUtils.getPair(value, SiType.MEGA); + if (value < TERA_IN_DECIMAL) return SiUtils.getPair(value, SiType.GIGA); + if (value < PETA_IN_DECIMAL) return SiUtils.getPair(value, SiType.TERA); + if (value < EXA_IN_DECIMAL) return SiUtils.getPair(value, SiType.PETA); + if (value < ZETTA_IN_DECIMAL) return SiUtils.getPair(value, SiType.EXA); + if (value < YOTTA_IN_DECIMAL) return SiUtils.getPair(value, SiType.ZETTA); + return SiUtils.getPair(value, SiType.YOTTA); + } + + /** + * Convert value to bytes + * + * @param value The value to convert + * @param type + */ + public static getBytes (value : number, type: SiType) : number { + switch (type) { + case SiType.NONE : return value; + case SiType.KILO : return value*KILO_IN_BINARY; + case SiType.MEGA : return value*MEGA_IN_BINARY; + case SiType.GIGA : return value*GIGA_IN_BINARY; + case SiType.TERA : return value*TERA_IN_BINARY; + case SiType.PETA : return value*PETA_IN_BINARY; + case SiType.EXA : return value*EXA_IN_BINARY; + case SiType.ZETTA : return value*ZETTA_IN_BINARY; + case SiType.YOTTA : return value*YOTTA_IN_BINARY; + default: throw new TypeError('Undefined si type: ' + type); + } + } + +} diff --git a/SmsAuthHttpService.ts b/SmsAuthHttpService.ts new file mode 100644 index 0000000..9719f11 --- /dev/null +++ b/SmsAuthHttpService.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021-2023. Sendanor . All rights reserved. + +import { SmsTokenDTO, isSmsTokenDTO } from "./auth/sms/types/SmsTokenDTO"; +import { Language } from "./types/Language"; +import { LanguageService } from "./LanguageService"; +import { HttpService } from "./HttpService"; +import { LogService } from "./LogService"; +import { createVerifySmsCodeDTO, VerifySmsCodeDTO } from "./auth/sms/types/VerifySmsCodeDTO"; +import { ReadonlyJsonAny } from "./Json"; +import { CallbackWithLanguage, AUTHENTICATE_SMS_URL, VERIFY_SMS_CODE_URL, VERIFY_SMS_TOKEN_URL } from "./auth/sms/sms-auth-constants"; +import { LogLevel } from "./types/LogLevel"; +import { createVerifySmsTokenDTO, VerifySmsTokenDTO } from "./auth/sms/types/VerifySmsTokenDTO"; +import { createAuthenticateSmsDTO } from "./auth/sms/types/AuthenticateSmsDTO"; + +const LOG = LogService.createLogger('SmsAuthHttpService'); + +/** + * This is a client service for sms address based user authentication over + * HTTP protocol. + */ +export class SmsAuthHttpService { + + private static _authenticateSmsUrl : CallbackWithLanguage = AUTHENTICATE_SMS_URL; + private static _verifySmsCodeUrl : CallbackWithLanguage = VERIFY_SMS_CODE_URL; + private static _verifySmsTokenUrl : CallbackWithLanguage = VERIFY_SMS_TOKEN_URL; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static initialize ( + authenticateSmsUrl : CallbackWithLanguage = AUTHENTICATE_SMS_URL, + verifySmsCodeUrl : CallbackWithLanguage = VERIFY_SMS_CODE_URL, + verifySmsTokenUrl : CallbackWithLanguage = VERIFY_SMS_TOKEN_URL + ) { + this._authenticateSmsUrl = authenticateSmsUrl; + this._verifySmsCodeUrl = verifySmsCodeUrl; + this._verifySmsTokenUrl = verifySmsTokenUrl; + } + + public static async authenticateSmsAddress ( + sms : string, + language ?: Language + ) : Promise { + const lang : Language = language ?? LanguageService.getCurrentLanguage(); + const body = createAuthenticateSmsDTO(sms); + return await this._postJson( + this._authenticateSmsUrl(lang), + body as unknown as ReadonlyJsonAny + ); + } + + public static async verifySmsToken ( + smsToken : SmsTokenDTO, + language ?: Language + ) : Promise { + const lang : Language = language ?? LanguageService.getCurrentLanguage(); + const body : VerifySmsTokenDTO = createVerifySmsTokenDTO(smsToken); + return await this._postJson( + this._verifySmsTokenUrl(lang), + body as unknown as ReadonlyJsonAny + ); + } + + public static async verifySmsCode ( + token : SmsTokenDTO, + code: string, + language ?: Language + ) : Promise { + const lang : Language = language ?? LanguageService.getCurrentLanguage(); + const body : VerifySmsCodeDTO = createVerifySmsCodeDTO(token, code); + return await this._postJson( + this._verifySmsCodeUrl(lang), + body as unknown as ReadonlyJsonAny + ); + } + + private static async _postJson ( + url : string, + body : ReadonlyJsonAny + ) : Promise { + const response : unknown = await HttpService.postJson(url, body); + if (!isSmsTokenDTO(response)) { + LOG.debug(`Response: `, response); + throw new TypeError(`Response was not SmsTokenDTO`); + } + return response; + } + +} diff --git a/StoreCartService.ts b/StoreCartService.ts new file mode 100644 index 0000000..202a224 --- /dev/null +++ b/StoreCartService.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { Observer, ObserverCallback, ObserverDestructor } from "./Observer"; +import { createShoppingCart, ShoppingCart } from "./store/types/cart/ShoppingCart"; +import { Product } from "./store/types/product/Product"; +import { ShoppingCartUtils } from "./store/utils/ShoppingCartUtils"; +import { ProductPrice } from "./store/types/product/ProductPrice"; + +export enum StoreCartServiceEvent { + CART_UPDATED = "StoreCartService:cartUpdated", + CART_MENU_UPDATED = "StoreCartService:menuUpdated" +} + +export type StoreCartServiceDestructor = ObserverDestructor; + +/** + * @FIXME: Convert as non-static + */ +export class StoreCartService { + + private static _observer: Observer = new Observer("StoreCartService"); + private static _cart : ShoppingCart = createShoppingCart(); + private static _cartMenuOpen : boolean = false; + + public static Event = StoreCartServiceEvent; + + public static on ( + name: StoreCartServiceEvent, + callback: ObserverCallback + ): StoreCartServiceDestructor { + return this._observer.listenEvent(name, callback); + } + + public static destroy (): void { + this._observer.destroy(); + } + + public static getCart () : ShoppingCart { + return this._cart; + } + + public static addProduct ( + product : Product, + price : ProductPrice, + amount : number = 1 + ) { + this._cart = ShoppingCartUtils.addItemToCart(this._cart, product, price, amount); + this._observer.triggerEvent(StoreCartServiceEvent.CART_UPDATED); + } + + public static removeProduct ( + product : Product, + price : ProductPrice, + amount : number = 1 + ) { + const oldCart = this._cart; + this._cart = ShoppingCartUtils.removeItemFromCart(this._cart, product, price, amount); + if (this._cart !== oldCart) { + this._observer.triggerEvent(StoreCartServiceEvent.CART_UPDATED); + } + } + + public static isCartMenuOpen () : boolean { + return this._cartMenuOpen; + } + + public static setCartMenuOpen (value : boolean) { + if (this._cartMenuOpen !== value) { + this._cartMenuOpen = value; + this._observer.triggerEvent(StoreCartServiceEvent.CART_MENU_UPDATED); + } + } + + public static openCartMenu () { + StoreCartService.setCartMenuOpen(true); + } + + public static closeCartMenu () { + StoreCartService.setCartMenuOpen(false); + } + + public static toggleCartMenu () { + StoreCartService.setCartMenuOpen( + !StoreCartService.isCartMenuOpen() + ); + } + + public static resetCart () { + this._cart = createShoppingCart(); + this._observer.triggerEvent(StoreCartServiceEvent.CART_UPDATED); + } + +} diff --git a/StoreClientService.ts b/StoreClientService.ts new file mode 100644 index 0000000..c312290 --- /dev/null +++ b/StoreClientService.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2022-2023. Sendanor . All rights reserved. +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { ReadonlyJsonAny } from "./Json"; +import { HttpService } from "./HttpService"; +import { isStoreIndexDTO, StoreIndexDTO } from "./store/types/api/StoreIndexDTO"; +import { LogService } from "./LogService"; +import { LogLevel } from "./types/LogLevel"; + +const LOG = LogService.createLogger('StoreClientService'); + +export class StoreClientService { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static async getStoreIndex (url: string) : Promise { + const response: ReadonlyJsonAny | undefined = await HttpService.getJson(url); + if ( !isStoreIndexDTO(response) ) { + LOG.debug(`response = `, response); + throw new TypeError(`Response was not StoreIndexDTO`); + } + return response; + } + +} diff --git a/StoreProductService.ts b/StoreProductService.ts new file mode 100644 index 0000000..26f491f --- /dev/null +++ b/StoreProductService.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2022-2023. Sendanor . All rights reserved. +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { Product } from "./store/types/product/Product"; +import { ProductType } from "./store/types/product/ProductType"; +import { filter } from "./functions/filter"; +import { map } from "./functions/map"; +import { StoreIndexDTO } from "./store/types/api/StoreIndexDTO"; +import { LogService } from "./LogService"; +import { Observer, ObserverCallback, ObserverDestructor } from "./Observer"; +import { LogLevel } from "./types/LogLevel"; +import { StoreClientService } from "./StoreClientService"; + +const DEFAULT_STORE_API_URL = '/api'; +const SERVICE_NAME = "StoreProductService"; + +export enum StoreProductServiceEvent { + UPDATED = "StoreProductService:updated" +} + +export type StoreProductServiceDestructor = ObserverDestructor; + +const LOG = LogService.createLogger(SERVICE_NAME); + +const MY_PRODUCT_LIST_FETCH_RETRY_TIMEOUT_ON_ERROR = 3000; + +/** + * @FIXME: Remove static, convert as a normal class, so that can be used on the + * server side also. Maybe create a StaticStoreProductService for + * frontend use. + */ +export class StoreProductService { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private static _updateTimeout : any = undefined; + private static _apiUrl : string = DEFAULT_STORE_API_URL; + private static _initialized : boolean = false; + private static _loading : boolean = false; + private static _allProducts : readonly Product[] = []; + + // @ts-ignore @todo Should write unit test for this + private static setApiUrl (value: string) { + this._apiUrl = value; + } + + private static _observer: Observer = new Observer(SERVICE_NAME); + + public static Event = StoreProductServiceEvent; + + public static on ( + name: StoreProductServiceEvent, + callback: ObserverCallback + ): StoreProductServiceDestructor { + return this._observer.listenEvent(name, callback); + } + + public static destroy (): void { + this._observer.destroy(); + } + + public static isInitialized () : boolean { + return this._initialized; + } + + public static isLoading () : boolean { + return this._loading; + } + + public static hasErrors () : boolean { + return this._updateTimeout !== undefined; + } + + public static refreshProducts () { + this._updateProducts().catch(err => { + LOG.error(`Could not load products: `, err); + }); + } + + public static getAllProducts () : Product[] { + if ( !this._initialized && !this._loading ) { + this.refreshProducts(); + } + return map(this._allProducts, (item) => item); + } + + public static getProductsByType (type: ProductType) : Product[] { + return filter( + this.getAllProducts(), + (item : Product) : boolean => item?.type === type + ); + } + + private static async _updateProducts () : Promise { + this._loading = true; + try { + const response: StoreIndexDTO = await StoreClientService.getStoreIndex(this._apiUrl); + this._allProducts = response?.products?.items ?? []; + this._initialized = true; + this._observer.triggerEvent(StoreProductServiceEvent.UPDATED); + } catch (err) { + LOG.error(`Error: `, err); + this._triggerUpdateLaterAfterError(); + } finally { + this._loading = false; + } + } + + /** + * Schedules an update later when errors happen + * + * @private + */ + private static _triggerUpdateLaterAfterError () { + if (this._updateTimeout) { + clearTimeout(this._updateTimeout); + this._updateTimeout = undefined; + } + this._updateTimeout = setTimeout( + () => this._onUpdateErrorTimeout(), + MY_PRODUCT_LIST_FETCH_RETRY_TIMEOUT_ON_ERROR + ); + } + + /** + * Called after an error happens to try again + * + * @private + */ + private static _onUpdateErrorTimeout () { + this._updateTimeout = undefined; + if (!this.isLoading()) { + this.refreshProducts(); + } else { + LOG.debug(`We were already loading again`); + } + } + +} diff --git a/StringUtils.test.ts b/StringUtils.test.ts new file mode 100644 index 0000000..ec9585f --- /dev/null +++ b/StringUtils.test.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { StringUtils } from "./StringUtils"; + +describe('StringUtils', () => { + + describe('.toString', () => { + + test('can stringify array', () => { + + expect( StringUtils.toString('') ).toBe(''); + expect( StringUtils.toString(false) ).toBe('false'); + expect( StringUtils.toString(true) ).toBe('true'); + expect( StringUtils.toString(null) ).toBe('null'); + expect( StringUtils.toString(undefined) ).toBe('undefined'); + expect( StringUtils.toString('hello') ).toBe('hello'); + expect( StringUtils.toString(123) ).toBe('123'); + expect( StringUtils.toString([]) ).toBe(''); + expect( StringUtils.toString([1, 2, 3]) ).toBe('1,2,3'); + expect( StringUtils.toString(1, 2, 3) ).toBe('123'); + expect( StringUtils.toString(1, 'hello', 3) ).toBe('1hello3'); + + }); + + }); + + describe('.processVariables', () => { + + test('can process empty string value', () => { + expect( StringUtils.processVariables('', + {}, + '${', + '}' + ) ).toStrictEqual(''); + }); + + test('can process empty string value with extra spaces', () => { + expect( StringUtils.processVariables( + ' ', + {}, + '${', + '}') + ).toStrictEqual(' '); + }); + + test('can process non-empty string value', () => { + expect( StringUtils.processVariables( + 'hello world', + {}, + '${', + '}') + ).toStrictEqual('hello world'); + }); + + test('can process single variable in non-empty string value', () => { + expect( StringUtils.processVariables( + 'Welcome, ${name}, nice day!', + { + name: 'Nick' + }, + '${', + '}' + ) ).toStrictEqual('Welcome, Nick, nice day!'); + }); + + test('can process two variables in non-empty string value', () => { + expect( StringUtils.processVariables( + 'Welcome, ${name}, nice ${time}!', + { + name: 'Nick', + time: 'evening' + }, + '${', + '}' + ) ).toStrictEqual('Welcome, Nick, nice evening!'); + }); + + test('can process single variable in non-empty string value as typed value', () => { + expect( StringUtils.processVariables( + '${enabled}', + { + enabled: false + }, + '${', + '}' + ) ).toStrictEqual(false); + }); + + test('can process variables using a function as typed value', () => { + expect( StringUtils.processVariables( + '${enabled}', + ( + // @ts-ignore + key: string + ) => false, + '${', + '}' + ) ).toStrictEqual(false); + }); + + test('can process variables inside strings using a function as typed value', () => { + expect( StringUtils.processVariables( + 'Hello, ${enabled}', + (key: string) => key, + '${', + '}' + ) ).toStrictEqual('Hello, enabled'); + }); + + test('can process variables inside strings using a function as typed value with extra spaces', () => { + expect( StringUtils.processVariables( + 'Hello, ${ enabled }', + (key: string) => key, + '${', + '}' + ) ).toStrictEqual('Hello, enabled'); + }); + + test('can process variables inside arrays using a function as typed value', () => { + expect( StringUtils.processVariables( + ['Hello, ${enabled}', 'Nice ${time}.'], + (key: string) => key, + '${', + '}' + ) ).toStrictEqual(['Hello, enabled', 'Nice time.']); + }); + + test('can process variables inside arrays using a function as typed value - test 2', () => { + expect( StringUtils.processVariables( + [ '${jsonString}' ], + (key: string) => { + if (key === 'jsonString') return 'hello world'; + return undefined; + }, + '${', + '}' + ) ).toStrictEqual(['hello world']); + }); + + test('can process variable from string using a function as typed value - test 2', () => { + expect( StringUtils.processVariables( + '${jsonString}', + (key: string) => { + if (key === 'jsonString') return 'hello world'; + return undefined; + }, + '${', + '}' + ) ).toStrictEqual('hello world'); + }); + + test('can process variables inside objects using a function as typed value', () => { + expect( StringUtils.processVariables( + { + foo: 'Hello, ${enabled}', + bar: 'Nice ${time}.' + }, + (key: string) => key, + '${', + '}' + ) ).toStrictEqual({ + foo: 'Hello, enabled', + bar: 'Nice time.' + }); + }); + + test('can process variables inside objects and arrays using a function as typed value', () => { + expect( StringUtils.processVariables( + { + id: '${id}', + count: 2, + list: ['Hello, ${name}', 'Nice ${time}.'] + }, + { + id: 123, + name: 'Nick', + time: 'evening' + }, + '${', + '}' + ) ).toStrictEqual({ + id: 123, + count: 2, + list: ['Hello, Nick', 'Nice evening.'] + }); + }); + + test('can process variables inside property keywords', () => { + expect( StringUtils.processVariables( + { + '${keyword}': '${value}' + }, + { + keyword: 'foo', + value: 'hello world' + }, + '${', + '}' + ) ).toStrictEqual({ + foo: 'hello world' + }); + }); + + test('can process variables inside property keywords with extra content', () => { + expect( StringUtils.processVariables( + { + 'xxx${{keyword}}xxx': 'xxx${{value}}xxx' + }, + { + keyword: 'foo', + value: 'hello world' + }, + '${{', + '}}' + ) ).toStrictEqual({ + xxxfooxxx: 'xxxhello worldxxx' + }); + }); + + test('processes only correct prefix and suffix without spaces', () => { + + expect( StringUtils.processVariables( + '${{keyword}}${value}', + { + keyword: 'foo', + value: 'hello world' + }, + '${{', + '}}' + ) ).toStrictEqual('foo${value}'); + }); + + test('processes only correct prefix and suffix with extra leeding space', () => { + expect( StringUtils.processVariables( + ' ${{keyword}}${value}', + { + keyword: 'foo', + value: 'hello world' + }, + '${{', + '}}' + ) ).toStrictEqual(' foo${value}'); + }); + + test('processes only correct prefix and suffix with more spaces', () => { + + expect( StringUtils.processVariables( + ' ${value} ${{keyword}}', + { + keyword: 'foo', + value: 'hello world' + }, + '${{', + '}}' + ) ).toStrictEqual(' ${value} foo'); + }); + + test('processes only correct prefix and suffix with spaces everywhere', () => { + + expect( StringUtils.processVariables( + ' ${value} ${{keyword}} ', + { + keyword: 'foo', + value: 'hello world' + }, + '${{', + '}}' + ) ).toStrictEqual(' ${value} foo '); + + }); + + test('processes only correct prefix and suffix with spaces everywhere and similar syntax', () => { + + expect( StringUtils.processVariables( + ' ${value} ${{keyword}} ', + { + keyword: 'foo', + value: 'hello world' + }, + '${', + '}' + ) ).toStrictEqual(' hello world ${{keyword}} '); + + }); + + test('can process variables deeper inside objects using a function as typed value', () => { + expect( StringUtils.processVariables( + '${ foo.bar.key }', + { + foo: { + bar: { + key: 123 + } + } + }, + '${', + '}' + ) ).toStrictEqual(123); + }); + + }); + +}); diff --git a/StringUtils.ts b/StringUtils.ts new file mode 100644 index 0000000..6cbd523 --- /dev/null +++ b/StringUtils.ts @@ -0,0 +1,335 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { + JsonAny, + isReadonlyJsonArray, + isReadonlyJsonObject, + JsonObject, + ReadonlyJsonAny +} from "./Json"; + +import { get } from "./functions/get"; +import { endsWith } from "./functions/endsWith"; +import { startsWith } from "./functions/startsWith"; +import { trim } from "./functions/trim"; +import { map } from "./functions/map"; +import { reduce } from "./functions/reduce"; +import { isNull } from "./types/Null"; +import { isString } from "./types/String"; +import { isFunction } from "./types/Function"; +import { keys } from "./functions/keys"; +import { every } from "./functions/every"; + +const ACCEPTED_KEYWORD_CHARACTERS = 'QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm._1234567890'; +const ACCEPTED_START_KEYWORD_CHARACTERS = 'QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm._'; + +export interface VariableResolverCallback { + (key: string) : ReadonlyJsonAny | undefined; +} + +export class StringUtils { + + /** + * Converts arguments as a string. + * + * This is a helper function to make sure every value's string presentation is actually used. + * + * JavaScript uses .valueOf() in many instances instead of .toString(). + * + * See also https://stackoverflow.com/a/2485794/901430 + * + * @param values + */ + public static toString (...values : any[]) : string { + return map(values, item => { + if (isNull(item)) return 'null'; + return `${item}`; + }).join(""); + } + + /** + * Convert any found variables in the input to corresponding values. + * + * The variable keyword may be a path to a variable inner in the `variables` structure. + * Eg. when variables is `{"foo":{"bar":123}}`, the inner value `123` can be referenced using + * `{variablePrefix}foo.bar{variableSuffix}` in the input. + * + * The input may be any JSON structure. Only string items will be processed. That means + * keywords and scalar string values inside the structure. + * + * Returned structure is a partial (copy on write) version of the input structure. + * + */ + public static processVariables ( + input : ReadonlyJsonAny | undefined, + resolveVariable : VariableResolverCallback | JsonObject, + variablePrefix : string, + variableSuffix : string, + defaultValue : JsonAny | undefined = undefined + ) : ReadonlyJsonAny | undefined { + + if (isReadonlyJsonArray(input)) { + return map( + input, + (item : ReadonlyJsonAny) => StringUtils.processVariables( + item, + resolveVariable, + variablePrefix, + variableSuffix, + defaultValue + ) as ReadonlyJsonAny + ); + } + + if (isReadonlyJsonObject(input)) { + return reduce( + keys(input), + (obj: JsonObject, itemKey : string) => { + + const itemValue : ReadonlyJsonAny | undefined = input[itemKey]; + + const parsedItemKey : string = `${StringUtils.processVariables( + itemKey, + resolveVariable, + variablePrefix, + variableSuffix, + defaultValue + )}`; + + obj[parsedItemKey] = StringUtils.processVariables( + itemValue, + resolveVariable, + variablePrefix, + variableSuffix, + defaultValue + ) as JsonAny; + + return obj; + + }, + {} + ) as ReadonlyJsonAny; + } + + if (isString(input)) { + return StringUtils.processVariablesInString( + input, + resolveVariable, + variablePrefix, + variableSuffix, + defaultValue + ); + } + + return input; + + } + + /** + * + * @fixme This probably should be inside Pipeline code, not here, and configurable in processVariablesInString(). + * @param variableKey + */ + public static isValidKeyword (variableKey : string) : boolean { + + if ( variableKey.length <= 0 ) return false; + + if ( ACCEPTED_START_KEYWORD_CHARACTERS.includes(variableKey[0]) ) { + return true; + } + + return every(variableKey, (item: string) => ACCEPTED_KEYWORD_CHARACTERS.includes(item)); + + } + + /** + * Convert any found variables in the input to corresponding values. + * + * The variable keyword may be a path to a variable inner in the `variables` structure. + * Eg. when variables is `{"foo":{"bar":123}}`, the inner value `123` can be referenced using + * `{variablePrefix}foo.bar{variableSuffix}` in the input. + * + * Returns the string with any found variables converted. + * + */ + public static processVariablesInString ( + input : string, + resolveVariable : VariableResolverCallback | JsonObject, + variablePrefix : string, + variableSuffix : string, + defaultValue : JsonAny | undefined = undefined + ) : ReadonlyJsonAny | undefined { + + if (input.length === 0) return ''; + + let resolver : VariableResolverCallback | undefined; + if (!isFunction(resolveVariable)) { + resolver = (key: string) : ReadonlyJsonAny => get(resolveVariable, key, defaultValue) as ReadonlyJsonAny; + } else { + resolver = resolveVariable; + } + + // Special case which will support typed variables, when the full string is. + if ( startsWith(input, variablePrefix) && endsWith(input, variableSuffix) ) { + + let variableKey = input.substr(variablePrefix.length, input.length - variablePrefix.length - variableSuffix.length); + + // Make sure we don't have multiple variables in the string + if ( variableKey.indexOf(variablePrefix) < 0 ) { + + variableKey = trim(variableKey); + + if (StringUtils.isValidKeyword(variableKey)) { + const resolvedValue = resolver(variableKey); + // LOG.debug(`Variable "${variableKey}" resolved as `, resolvedValue); + return resolvedValue; + } + + } + + } + + let output = ''; + let index = 0; + while ( (index >= 0) && (index < input.length) ) { + + const currentParsingStartIndex = index; + + index = input.indexOf(variablePrefix, currentParsingStartIndex); + + if ( index < 0 ) { + + output += input.substr(currentParsingStartIndex); + + index = input.length; + + } else { + + const keyTokenStartIndex = index; + + const keyNameStartIndex = index + variablePrefix.length; + + const keyNameEndIndex = input.indexOf(variableSuffix, keyNameStartIndex); + if (keyNameEndIndex < 0) { + throw new TypeError(`Parse error near "${input.substr(keyTokenStartIndex).substr(0, 20)}". End of variable not detected.`); + } + + const keyTokenEndIndex = keyNameEndIndex + variableSuffix.length; + + const variableKey = trim( input.substr(keyNameStartIndex, keyNameEndIndex - keyNameStartIndex) ); + + if (!StringUtils.isValidKeyword(variableKey)) { + + output += `${input.substr(currentParsingStartIndex, keyTokenEndIndex - currentParsingStartIndex)}`; + + index = keyTokenEndIndex; + + } else { + + const resolvedValue : ReadonlyJsonAny | undefined = resolver(variableKey); + // LOG.debug(`Variable "${variableKey}" at ${keyTokenStartIndex}-${keyTokenEndIndex} resolved as "${resolvedValue}": `, resolvedValue); + + output += `${input.substr(currentParsingStartIndex, keyTokenStartIndex - currentParsingStartIndex)}${resolvedValue}`; + + index = keyTokenEndIndex; + + } + + } + + } + + return output; + + } + + /** + * Stringify a number + * + * @param x + * @param thousandSeparator + * @param digitSeparator + */ + public static formatNumber ( + x : number, + thousandSeparator : string = ' ', + digitSeparator : string = '.' + ) : string { + return x.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator).replace(/\./, digitSeparator); + } + + /** + * + * @param value The value to test + * @param acceptedChars The first character must match one of these + */ + public static endsWithCharacters ( + value : string, + acceptedChars: string + ) : boolean { + const len = value.length; + return len >= 1 ? acceptedChars.includes(value[len-1]) : true; + } + + /** + * + * @param value The value to test + * @param acceptedChars The first character must match one of these + */ + public static startsWithCharacters ( + value : string, + acceptedChars: string + ) : boolean { + return value.length >= 1 ? acceptedChars.includes(value[0]) : true; + } + + /** + * + * @param value The value to test + * @param acceptedChars Every character must match one of these + */ + public static hasOnlyCharacters ( + value : string, + acceptedChars: string + ) : boolean { + return value.length === 0 ? true : every(value, (char: string) :boolean => acceptedChars.includes(char)); + } + + /** + * + * @param value The value to test + * @param acceptedStartChars If defined, the first character must match one of these. + * @param acceptedMiddleChars If defined, every character must match this. Defaults to `acceptedStartChars`. + * @param acceptedEndChars If defined, every character must match this. Defaults to `acceptedMiddleChars`. + * @param minLength The minimum length of the string. Defaults to 0. + * @param maxLength The maximum length of the string. Defaults to no limit. + */ + public static validateStringCharacters ( + value : string, + acceptedStartChars: string | undefined = undefined, + acceptedMiddleChars: string | undefined = acceptedStartChars, + acceptedEndChars: string | undefined = acceptedMiddleChars, + minLength: number = 0, + maxLength: number | undefined = undefined + ) : boolean { + const len = value?.length ?? 0; + return ( + ( acceptedStartChars !== undefined ? StringUtils.startsWithCharacters(value, acceptedStartChars) : true) + && ( acceptedMiddleChars !== undefined ? StringUtils.hasOnlyCharacters(value.substring(1, len-1), acceptedMiddleChars) : true) + && ( acceptedEndChars !== undefined ? StringUtils.endsWithCharacters(value, acceptedEndChars) : true) + && ( len >= minLength ) + && ( maxLength === undefined ? true : len <= maxLength ) + ); + } + + public static truncateLongString ( + value : string, + maxLength : number, + suffix : string = '...' + ) : string { + if (maxLength < suffix.length) throw new TypeError('StringUtils.truncateLongString: maxLength must be greater than length of the suffix'); + return value.length <= maxLength ? value : value.substring(0, maxLength-3) + suffix; + } + +} diff --git a/StyleCompiler.ts b/StyleCompiler.ts new file mode 100644 index 0000000..981e0c8 --- /dev/null +++ b/StyleCompiler.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ColorScheme } from "./style/types/ColorScheme"; +import { StyleLayout } from "./style/StyleLayout"; +import { StyleUtils } from "./StyleUtils"; +import { Styles } from "./style/Styles"; +import { Disposable } from "./types/Disposable"; + +export class StyleCompiler implements Disposable { + + private _preferredColorScheme : ColorScheme | undefined; + private _layouts : readonly StyleLayout[]; + private _previousLayout : StyleLayout | undefined; + private _previousStyles : Styles | undefined; + + public constructor ( + layouts : readonly StyleLayout[] = [], + preferredColorScheme : ColorScheme | undefined = undefined + ) { + this._preferredColorScheme = preferredColorScheme; + this._layouts = layouts; + this._previousLayout = undefined; + this._previousStyles = undefined; + } + + public destroy () { + this._preferredColorScheme = undefined; + this._layouts = []; + this._previousLayout = undefined; + this._previousStyles = undefined; + } + + public clearCache () { + this._previousLayout = undefined; + this._previousStyles = undefined; + } + + public getPreferredColorScheme () : ColorScheme | undefined { + return this._preferredColorScheme; + } + + public setPreferredColorScheme (value: ColorScheme | undefined) : void { + this._preferredColorScheme = value; + } + + public getLayoutList () : readonly StyleLayout[] { + return this._layouts; + } + + public addLayout (value : StyleLayout) : void { + this._layouts = [...this._layouts, value]; + } + + public compileStyles () : Styles { + const colorScheme = this._preferredColorScheme; + const layout = colorScheme ? StyleUtils.findLayoutByColorScheme(this._layouts, colorScheme) : ((this._layouts?.length) ? this._layouts[0] : undefined); + if (layout && this._previousStyles && this._previousLayout === layout) { + return this._previousStyles; + } + if (!layout) throw new TypeError('StyleLayoutManager: Could not find layout'); + const styles = StyleUtils.compileStylesByLayout(layout); + this._previousLayout = layout; + this._previousStyles = styles; + return styles; + } + +} diff --git a/StyleUtils.ts b/StyleUtils.ts new file mode 100644 index 0000000..74ecffb --- /dev/null +++ b/StyleUtils.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ColorMapping, ComponentStyleLayoutMapping, FontMapping, SizeMapping, StyleLayout } from "./style/StyleLayout"; +import { Styles } from "./style/Styles"; +import { filter } from "./functions/filter"; +import { get } from "./functions/get"; +import { reduce } from "./functions/reduce"; +import { ComponentStyleLayout } from "./style/layout/ComponentStyleLayout"; +import { createTextStyle, TextStyle } from "./style/compiled/TextStyle"; +import { BorderStyle, createBorderStyle } from "./style/compiled/BorderStyle"; +import { BackgroundStyle, createBackgroundStyle } from "./style/compiled/BackgroundStyle"; +import { Size } from "./style/types/Size"; +import { ComponentStyle, createComponentStyle } from "./style/compiled/ComponentStyle"; +import { TextStyleLayout } from "./style/layout/TextStyleLayout"; +import { BackgroundStyleLayout } from "./style/layout/BackgroundStyleLayout"; +import { BorderStyleLayout } from "./style/layout/BorderStyleLayout"; +import { BorderType } from "./style/types/BorderType"; +import { ColorScheme } from "./style/types/ColorScheme"; +import { keys } from "./functions/keys"; + +export class StyleUtils { + + public static compileTextStyle ( + item : TextStyleLayout | undefined, + fonts : FontMapping, + sizes : SizeMapping, + colors : ColorMapping + ) : TextStyle { + const fontId : string | undefined = item?.font; + const colorId : string | undefined = item?.color; + const sizeId : string | undefined = item?.size; + return createTextStyle( + fontId ? get(fonts , fontId ) : undefined, + colorId ? get(colors , colorId ) : undefined, + sizeId ? get(sizes , sizeId ) : undefined + ); + } + + public static compileBorderStyle ( + item : BorderStyleLayout | undefined, + sizes : SizeMapping, + colors : ColorMapping + ) : BorderStyle { + const sizeId : string | undefined = item?.size; + const type : BorderType | undefined = item?.type; + const radiusId : string | undefined = item?.radius; + const colorId : string | undefined = item?.color; + return createBorderStyle( + sizeId ? get(sizes, sizeId ) : undefined, + colorId ? get(colors, colorId ) : undefined, + type, + radiusId ? get(sizes, radiusId ) : undefined + ); + } + + public static compileBackgroundStyle ( + item : BackgroundStyleLayout | undefined, + colors : ColorMapping + ) : BackgroundStyle { + const colorId = item?.color; + return createBackgroundStyle( + colorId ? get(colors, colorId) : undefined + ); + } + + public static compileSize ( + sizeId : string | undefined, + sizes : SizeMapping + ) : Size | undefined { + return sizeId ? get(sizes, sizeId) : undefined; + } + + public static compilePadding ( + item : string | undefined, + sizes : SizeMapping + ) : Size | undefined { + return StyleUtils.compileSize(item, sizes); + } + + public static compileComponentStyle ( + item : ComponentStyleLayout, + fonts : FontMapping, + sizes : SizeMapping, + colors : ColorMapping + ) : ComponentStyle { + return createComponentStyle( + StyleUtils.compileTextStyle(item?.text, fonts, sizes, colors), + StyleUtils.compileBorderStyle(item?.border, sizes, colors), + StyleUtils.compileBackgroundStyle(item?.background, colors), + StyleUtils.compilePadding(item?.padding, sizes) + ); + } + + public static compileStyles ( + componentLayouts : ComponentStyleLayoutMapping, + fonts : FontMapping, + sizes : SizeMapping, + colors : ColorMapping + ) : Styles { + return reduce( + keys(componentLayouts), + (styles: Styles, key: string) : Styles => { + const item : ComponentStyleLayout = componentLayouts[key]; + styles = { + ...styles, + [key]: StyleUtils.compileComponentStyle(item, fonts, sizes, colors) + }; + return styles; + }, + {} + ); + } + + public static compileStylesByLayout (layout : StyleLayout) : Styles { + return StyleUtils.compileStyles( + layout?.components ?? {}, + layout?.fonts ?? {}, + layout?.sizes ?? {}, + layout?.colors ?? {} + ); + } + + public static findLayoutByColorScheme ( + layouts : readonly StyleLayout[], + colorScheme : ColorScheme + ) : StyleLayout | undefined { + const list = filter(layouts, (item : StyleLayout) : boolean => item.colorScheme === colorScheme); + return list.length ? list[0] : undefined; + } + +} diff --git a/SyncFileUtils.ts b/SyncFileUtils.ts new file mode 100644 index 0000000..d6c5edf --- /dev/null +++ b/SyncFileUtils.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import fs from "fs"; +import path from "path"; +import { LogService } from "./LogService"; +import { ReadonlyJsonAny } from "./Json"; +import { replaceTemplate } from "./functions/replaceTemplate"; + +const LOG = LogService.createLogger('SyncFileUtils'); + +export class SyncFileUtils { + + static isDirectory (dirPath: string) : boolean { + return fs.statSync(dirPath).isDirectory(); + } + + static directoryExits (dirPath: string) : boolean { + return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory(); + } + + static mkdirp (dirPath: string) { + + LOG.debug(`mkdirp: Creating directory: `, dirPath); + + const paths = []; + while (!SyncFileUtils.directoryExits(dirPath)) { + paths.push(dirPath); + const parentPath = path.dirname(dirPath); + if (dirPath === parentPath) break; + dirPath = parentPath; + } + + while ( paths.length >= 1 ) { + const dir : string | undefined = paths.pop(); + if (!dir) throw new TypeError('No dir'); + LOG.debug(`mkdirp: Creating missing directory: `, dir); + fs.mkdirSync(dir); + } + + } + + static readTextFile ( + sourceFile: string + ) : string { + return fs.readFileSync(sourceFile, "utf8"); + } + + static fileExists (targetPath: string) : boolean { + return fs.existsSync(targetPath) + } + + static readJsonFile ( + sourceFile: string + ) : ReadonlyJsonAny { + return JSON.parse(SyncFileUtils.readTextFile(sourceFile)); + } + + static writeTextFile ( + targetPath: string, + targetDataString : string + ) { + fs.writeFileSync(targetPath, targetDataString, {encoding: 'utf8'}); + } + + static writeJsonFile ( + targetPath: string, + targetData : ReadonlyJsonAny + ) { + const targetDataString = JSON.stringify(targetData, null, 2); + SyncFileUtils.writeTextFile(targetPath, targetDataString); + } + + static copyTextFileWithReplacements ( + sourceFile: string, + toFile: string, + replacements: {readonly [name: string]: string} + ) { + const fileContentString = SyncFileUtils.readTextFile(sourceFile); + const contentString = replaceTemplate(fileContentString, replacements); + SyncFileUtils.writeTextFile(toFile, contentString); + } + + static copyTextFileWithReplacementsIfMissing ( + sourceFile: string, + toFile: string, + replacements: {readonly [name: string]: string} + ) { + + if (!SyncFileUtils.fileExists(toFile)) { + SyncFileUtils.copyTextFileWithReplacements(sourceFile, toFile, replacements); + } else { + LOG.warn(`Warning! File already exists: `, toFile); + } + + } + +} diff --git a/SystemService.ts b/SystemService.ts new file mode 100644 index 0000000..5887cfd --- /dev/null +++ b/SystemService.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ChildProcessService, CommandOptions, CommandResponse } from "./ChildProcessService"; +import { LogService } from "./LogService"; + +const LOG = LogService.createLogger('SystemService'); + +export class SystemService { + + private static _childProcessService : ChildProcessService | undefined; + + public static destroy () { + if (this._childProcessService) { + this._childProcessService.destroy(); + this._childProcessService = undefined; + } + } + + public static initialize ( + childProcessService : ChildProcessService + ) { + if (this._childProcessService === undefined) { + this._childProcessService = childProcessService; + } else { + LOG.warn(`Warning! Child process service was already initialized`); + } + } + + private static _getChildProcessService () : ChildProcessService { + if (!this._childProcessService) { + throw new TypeError(`You must call HgNode.initialize() before using this service`); + } + return this._childProcessService; + } + + /** + * Starts a new child process to run a command. + * + * @param name + * @param args + * @param opts + */ + public static async executeCommand ( + name : string, + args ?: readonly string[], + opts ?: CommandOptions + ) : Promise { + return this._getChildProcessService().executeCommand(name, args, opts); + } + + public static countChildProcesses (): Promise { + return this._getChildProcessService().countChildProcesses(); + } + + public static shutdownChildProcesses (): Promise { + return this._getChildProcessService().shutdownChildProcesses(); + } + + public static waitAllChildProcessesStopped (): Promise { + return this._getChildProcessService().waitAllChildProcessesStopped(); + } + +} diff --git a/Test.ts b/Test.ts new file mode 100644 index 0000000..b6c6762 --- /dev/null +++ b/Test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. +// 2020 Jaakko Heusala +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { isArray } from "./types/Array"; +import { isString } from "./types/String"; +import { isNumber } from "./types/Number"; +import { isObject } from "./types/Object"; +import { keys } from "./functions/keys"; +import { every } from "./functions/every"; + +/** + * + */ +export class Test { + + /** + * + * @param value + * @deprecated Use `isString` + */ + static isString (value: any) : value is string { + return isString(value); + } + + /** + * + * @param value + * @deprecated Use `isNumber` + */ + static isNumber (value: any) : value is number { + return isNumber(value); + } + + /** + * Test if it is an regular object (eg. all keys are strings). + * + * @param value + * @deprecated Use `isRegularObject` + */ + static isRegularObject (value: any) : value is { [name: string]: any } { + return isObject(value) && !isArray(value) && every(keys(value), (key : any) => isString(key)); + } + + /** + * Test if the value is an array + * + * @param value + * @deprecated Use `isArray` + */ + static isArray (value: any) : value is Array { + return isArray(value); + } + + /** + * + * @param value + * @deprecated Use `isPromise` + */ + static isPromise (value: any) : value is Promise { + // @ts-ignore + return isObject(value) && !!value.then && !!value.catch; + } + +} diff --git a/TicketClientService.ts b/TicketClientService.ts new file mode 100644 index 0000000..e913c0f --- /dev/null +++ b/TicketClientService.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2022-2023. Sendanor . All rights reserved. +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { JsonAny, ReadonlyJsonAny, ReadonlyJsonObject } from "./Json"; +import { HttpService } from "./HttpService"; +import { LogService } from "./LogService"; +import { LogLevel } from "./types/LogLevel"; +import { isTicketDTO, TicketDTO } from "./store/types/ticket/TicketDTO"; +import { AuthorizationUtils } from "./AuthorizationUtils"; +import { isTicketListDTO, TicketListDTO } from "./store/types/ticket/TicketListDTO"; +import { NewTicketDTO } from "./store/types/ticket/NewTicketDTO"; +import { ResponseEntity } from "./request/types/ResponseEntity"; + +const LOG = LogService.createLogger('TicketClientService'); + +export class TicketClientService { + + private static _authorizationHeaderName = 'Authorization'; + private static _addTicketPath = '/tickets'; + private static _ticketListPath = '/tickets'; + private static _getTicketPath = (ticketId: string) : string => `${TicketClientService._ticketListPath}/${encodeURIComponent(ticketId)}`; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static async addTicket ( + data: NewTicketDTO, + url: string, + token ?: string + ) : Promise { + const response: ResponseEntity | undefined = await HttpService.postJsonEntity( + `${url}${this._addTicketPath}`, + data as unknown as ReadonlyJsonObject, + token ? this._createHeaders(token) : undefined + ); + if (!response) { + LOG.debug(`response = `, response); + throw new TypeError(`Response was not ResponseEntity`); + } + + const body = response.getBody(); + if ( !isTicketDTO(body) ) { + LOG.debug(`body = `, body); + throw new TypeError(`Body was not TicketDTO`); + } + return body; + } + + public static async getTicket ( + url: string, + ticketId : string, + token: string + ) : Promise { + const response: ReadonlyJsonAny | undefined = await HttpService.getJson( + `${url}${this._getTicketPath(ticketId)}`, + this._createHeaders(token) + ); + if ( !isTicketDTO(response) ) { + LOG.debug(`response = `, response); + throw new TypeError(`Response was not TicketDTO`); + } + return response; + } + + public static async getTicketList ( + url: string, + token: string + ) : Promise { + const response: ReadonlyJsonAny | undefined = await HttpService.getJson( + `${url}${this._ticketListPath}`, + this._createHeaders(token) + ); + if ( !isTicketListDTO(response) ) { + LOG.debug(`response = `, response); + throw new TypeError(`Response was not TicketListDTO`); + } + return response; + } + + private static _createHeaders ( + sessionToken: string + ) : {[key: string]: string} { + return { + [this._authorizationHeaderName]: AuthorizationUtils.createBearerHeader(sessionToken) + }; + } + +} diff --git a/TimeService.ts b/TimeService.ts new file mode 100644 index 0000000..36affba --- /dev/null +++ b/TimeService.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { moment } from "./modules/moment"; + +/** + * + * ***Note!*** Keep moment as private entity; do not expose outside of the + * `TimeService`. We might refactor this as an interface with multiple + * implementations later. + */ +export class TimeService { + + public static getCurrentTimeString () : string { + const now = new Date(); + return now.toISOString(); + } + + public static getTimeAfterMonths ( + time: string, + months: number + ) : string { + return moment(time).add(months, "months").toISOString(); + } + + public static parseISOString ( + time: string, + offSet?:boolean // Added offset so the selected day is correct and not 1 behind + ) : string { + return moment(time).toISOString(offSet); + } + +} diff --git a/TranslationUtils.test.ts b/TranslationUtils.test.ts new file mode 100644 index 0000000..82b557b --- /dev/null +++ b/TranslationUtils.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { TranslationUtils } from "./TranslationUtils"; +import { Language } from "./types/Language"; +import { TranslationResourceObject } from "./types/TranslationResourceObject"; + +describe('TranslationUtils', () => { + + describe('getConfig', () => { + it('should return i18n compatible configuration', () => { + const resources : TranslationResourceObject = { + en: { + 'en.foo.bar': 'En Foo Bar' + }, + fi: { + 'fi.foo.bar': 'Fi Foo Bar' + } + }; + const expectedResult = { + en: { + translation: { + 'en.foo.bar': 'En Foo Bar' + } + }, + fi: { + translation: { + 'fi.foo.bar': 'Fi Foo Bar' + } + } + }; + const result = TranslationUtils.getConfig(resources); + expect(result).toEqual(expectedResult); + }); + }); + + describe('getLanguageStringForI18n', () => { + it('should return i18n compatible language string', () => { + expect(TranslationUtils.getLanguageStringForI18n(Language.ENGLISH)).toEqual('en'); + expect(TranslationUtils.getLanguageStringForI18n(Language.FINNISH)).toEqual('fi'); + }); + + it('should return undefined for unsupported language', () => { + // @ts-ignore + expect(TranslationUtils.getLanguageStringForI18n('unsupported_language')).toEqual(undefined); + }); + }); + + describe('translateKeys', () => { + it('should translate keys', () => { + const t = jest.fn().mockImplementation((key: any, params: any) : string => `${key}:${params.key}`); + const keys = ['key1', 'key2']; + const translationParams = { key: 'value' }; + const expectedResult = { key1: 'key1:value', key2: 'key2:value' }; + const result = TranslationUtils.translateKeys(t, keys, translationParams); + expect(t).toHaveBeenCalledTimes(keys.length); + expect(result).toEqual(expectedResult); + }); + }); + +}); diff --git a/TranslationUtils.ts b/TranslationUtils.ts new file mode 100644 index 0000000..c8cf6a9 --- /dev/null +++ b/TranslationUtils.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2021. Heusala Group Oy . All rights reserved. + +import { reduce } from "./functions/reduce"; +import { ReadonlyJsonObject } from "./Json"; +import { Language } from "./types/Language"; +import { TranslationResourceObject } from "./types/TranslationResourceObject"; +import { TranslatedObject } from "./types/TranslatedObject"; +import { TranslationFunction } from "./types/TranslationFunction"; +import { I18NextResource } from "./types/I18NextResource"; +import { keys } from "./functions/keys"; + +export class TranslationUtils { + + public static getConfig ( + resources : TranslationResourceObject + ) : I18NextResource { + return reduce( + keys(resources) as Language[], + (prev : I18NextResource, key: Language) : I18NextResource => { + return { + ...prev, + [key]: { translation: resources[key]} + }; + }, + {} + ); + } + + /** + * Returns the language as i18n compatible language string. + * + * @param lang + */ + public static getLanguageStringForI18n (lang : Language) : string | undefined { + switch (lang) { + case Language.FINNISH: return 'fi'; + case Language.ENGLISH: return 'en'; + } + return undefined; + } + + public static translateKeys ( + t: TranslationFunction, + keys: string[], + translationParams: ReadonlyJsonObject + ): TranslatedObject { + return reduce( + keys, + (result: TranslatedObject, key: string): TranslatedObject => { + result[key] = t(key, translationParams); + return result; + }, + {} as TranslatedObject + ); + } + +} diff --git a/VariableUtils.ts b/VariableUtils.ts new file mode 100644 index 0000000..4168b68 --- /dev/null +++ b/VariableUtils.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export class VariableUtils { + + + +} diff --git a/WindowObjectService.ts b/WindowObjectService.ts new file mode 100644 index 0000000..03c322a --- /dev/null +++ b/WindowObjectService.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export class WindowObjectService { + + public static hasWindow (): boolean { + return !!_getWindow(); + } + + public static getWindow (): Window | undefined { + return _getWindow(); + } + + public static getParent (): Window | undefined { + return _getWindow()?.parent; + } + +} + +function _getWindow () : Window | undefined { + if ( typeof window === "undefined" ) return undefined; + return window; +} diff --git a/auth/EmailAuthController.ts b/auth/EmailAuthController.ts new file mode 100644 index 0000000..444c679 --- /dev/null +++ b/auth/EmailAuthController.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2022-2023. . All rights reserved. + +import { ReadonlyJsonAny } from "../Json"; +import { ResponseEntity } from "../request/types/ResponseEntity"; +import { ErrorDTO } from "../types/ErrorDTO"; +import { Language } from "../types/Language"; +import { EmailTokenDTO } from "./email/types/EmailTokenDTO"; + +/** + * This HTTP backend controller can be used to validate the ownership of user's + * email address. + * + * 1. Call .authenticateEmail(body, lang) to send the authentication email + * 2. Call .verifyEmailCode(body) to verify user supplied code from the email and create a session JWT + * 3. Call .verifyEmailToken(body) to verify validity of the previously created session JWT and to refresh the session + * + * The .verifyTokenAndReturnSubject(token) can be used to validate internally API calls in your own APIs. + */ +export interface EmailAuthController { + + /** + * Set default language for messages sent to the user by email. + * @param value + */ + setDefaultLanguage (value: Language) : void; + + /** + * Handles POST HTTP request to initiate an email address authentication by + * sending one time code to the user as a email message. + * + * The message should be in format `AuthenticateEmailDTO`. + * + * @param body {AuthenticateEmailDTO} + * @param langString {Language} The optional language of the message + */ + authenticateEmail ( + body: ReadonlyJsonAny, + langString: string + ): Promise>; + + /** + * Handles HTTP POST request which validates the user supplied code and + * generates a valid JWT token, which can be used to keep the session active. + * + * @param body {VerifyEmailCodeDTO} + */ + verifyEmailCode ( + body: ReadonlyJsonAny + ): Promise>; + + /** + * Handles HTTP POST request which validates previously validated session and + * if valid, generates a new refreshed session token. + * + * @param body {VerifyEmailTokenDTO} + */ + verifyEmailToken ( + body: ReadonlyJsonAny + ): Promise>; + + /** + * Can be used internally in APIs to validate and return the subject of this token. + * @FIXME: This should be in a service that's not specific to email addresses + */ + verifyTokenAndReturnSubject ( + token: string + ): Promise; + +} diff --git a/auth/EmailAuthMessageService.ts b/auth/EmailAuthMessageService.ts new file mode 100644 index 0000000..f8fcf1c --- /dev/null +++ b/auth/EmailAuthMessageService.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2022-2023. . All rights reserved. + +import { Language } from "../types/Language"; + +export interface EmailAuthMessageService { + + sendAuthenticationCode ( + lang: Language, + email: string, + code: string + ): Promise; + +} diff --git a/auth/EmailTokenService.ts b/auth/EmailTokenService.ts new file mode 100644 index 0000000..2034273 --- /dev/null +++ b/auth/EmailTokenService.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { EmailTokenDTO } from "./email/types/EmailTokenDTO"; + +export type EmailTokenServiceAlgorithm = ( + 'HS256' | 'HS384' | 'HS512' | + 'RS256' | 'RS384' | 'RS512' | + 'PS256' | 'PS384' | 'PS512' | + 'ES256' | 'ES384' | 'ES512' | "none" +); + +/** + * + */ +export interface EmailTokenService { + + /** + * + * @param email + * @param token + * @param requireVerifiedToken + * @param alg + */ + verifyToken ( + email: string, + token: string, + requireVerifiedToken: boolean, + alg ?: EmailTokenServiceAlgorithm + ): boolean; + + /** + * + * @param token + * @param email + * @param alg + */ + verifyValidTokenForSubject ( + token: string, + email: string, + alg ?: EmailTokenServiceAlgorithm + ): boolean; + + /** + * + * @param token + * @param alg + */ + isTokenValid ( + token: string, + alg ?: EmailTokenServiceAlgorithm + ): boolean; + + /** + * + * @param token + * @param requireVerifiedToken + * @param alg + */ + verifyTokenOnly ( + token : string, + requireVerifiedToken : boolean, + alg ?: EmailTokenServiceAlgorithm + ): boolean; + + createUnverifiedEmailToken ( + email: string, + alg ?: EmailTokenServiceAlgorithm + ): EmailTokenDTO; + + createVerifiedEmailToken ( + email: string, + alg ?: EmailTokenServiceAlgorithm + ): EmailTokenDTO; + +} diff --git a/auth/EmailVerificationService.ts b/auth/EmailVerificationService.ts new file mode 100644 index 0000000..b4735aa --- /dev/null +++ b/auth/EmailVerificationService.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { Disposable } from "../types/Disposable"; + +export interface EmailVerificationService extends Disposable { + + destroy (): void; + + verifyCode ( + email : string, + code : string + ) : boolean; + + removeVerificationCode ( + email : string, + code : string + ): void; + + createVerificationCode ( + email: string + ) : string; + +} diff --git a/auth/SmsAuthController.ts b/auth/SmsAuthController.ts new file mode 100644 index 0000000..fc679fd --- /dev/null +++ b/auth/SmsAuthController.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2022-2023. . All rights reserved. + +import { ReadonlyJsonAny } from "../Json"; +import { ResponseEntity } from "../request/types/ResponseEntity"; +import { ErrorDTO } from "../types/ErrorDTO"; +import { Language } from "../types/Language"; +import { SmsTokenDTO } from "./sms/types/SmsTokenDTO"; + +/** + * This HTTP backend controller can be used to validate the ownership of user's + * sms address. + * + * 1. Call .authenticateSms(body, lang) to send the authentication sms + * 2. Call .verifySmsCode(body) to verify user supplied code from the sms and create a session JWT + * 3. Call .verifySmsToken(body) to verify validity of the previously created session JWT and to refresh the session + * + * The .verifyTokenAndReturnSubject(token) can be used to validate internally API calls in your own APIs. + */ +export interface SmsAuthController { + + /** + * Set default language for messages sent to the user by sms. + * @param value + */ + setDefaultLanguage (value: Language) : void; + + /** + * Set default phone prefix, e.g. `+358` + * @param value + */ + setDefaultPhonePrefix (value: string) : void; + + /** + * Handles POST HTTP request to initiate an sms address authentication by + * sending one time code to the user as a sms message. + * + * The message should be in format `AuthenticateSmsDTO`. + * + * @param body {AuthenticateSmsDTO} + * @param langString {Language} The optional language of the message + */ + authenticateSms ( + body: ReadonlyJsonAny, + langString: string + ): Promise>; + + /** + * Handles HTTP POST request which validates the user supplied code and + * generates a valid JWT token, which can be used to keep the session active. + * + * @param body {VerifySmsCodeDTO} + */ + verifySmsCode ( + body: ReadonlyJsonAny + ): Promise>; + + /** + * Handles HTTP POST request which validates previously validated session and + * if valid, generates a new refreshed session token. + * + * @param body {VerifySmsTokenDTO} + */ + verifySmsToken ( + body: ReadonlyJsonAny + ): Promise>; + + /** + * Can be used internally in APIs to validate and return the subject of this token. + */ + verifyTokenAndReturnSubject ( + token: string + ): Promise; + +} diff --git a/auth/SmsAuthMessageService.ts b/auth/SmsAuthMessageService.ts new file mode 100644 index 0000000..8db34a6 --- /dev/null +++ b/auth/SmsAuthMessageService.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2022-2023. . All rights reserved. + +import { Language } from "../types/Language"; + +export interface SmsAuthMessageService { + + sendAuthenticationCode ( + lang: Language, + sms: string, + code: string + ): Promise; + +} diff --git a/auth/SmsTokenService.ts b/auth/SmsTokenService.ts new file mode 100644 index 0000000..b1726e6 --- /dev/null +++ b/auth/SmsTokenService.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { SmsTokenDTO } from "./sms/types/SmsTokenDTO"; + +export type SmsTokenServiceAlgorithm = ( + 'HS256' | 'HS384' | 'HS512' | + 'RS256' | 'RS384' | 'RS512' | + 'PS256' | 'PS384' | 'PS512' | + 'ES256' | 'ES384' | 'ES512' | "none" +); + +/** + * + */ +export interface SmsTokenService { + + /** + * + * @param sms + * @param token + * @param requireVerifiedToken + * @param alg + */ + verifyToken ( + sms: string, + token: string, + requireVerifiedToken: boolean, + alg ?: SmsTokenServiceAlgorithm + ): boolean; + + /** + * + * @param token + * @param sms + * @param alg + */ + verifyValidTokenForSubject ( + token: string, + sms: string, + alg ?: SmsTokenServiceAlgorithm + ): boolean; + + /** + * + * @param token + * @param alg + */ + isTokenValid ( + token: string, + alg ?: SmsTokenServiceAlgorithm + ): boolean; + + /** + * + * @param token + * @param requireVerifiedToken + * @param alg + */ + verifyTokenOnly ( + token : string, + requireVerifiedToken : boolean, + alg ?: SmsTokenServiceAlgorithm + ): boolean; + + createUnverifiedSmsToken ( + sms: string, + alg ?: SmsTokenServiceAlgorithm + ): SmsTokenDTO; + + createVerifiedSmsToken ( + sms: string, + alg ?: SmsTokenServiceAlgorithm + ): SmsTokenDTO; + +} diff --git a/auth/SmsVerificationService.ts b/auth/SmsVerificationService.ts new file mode 100644 index 0000000..0036ef7 --- /dev/null +++ b/auth/SmsVerificationService.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { Disposable } from "../types/Disposable"; + +export interface SmsVerificationService extends Disposable { + + destroy (): void; + + verifyCode ( + sms : string, + code : string + ) : boolean; + + removeVerificationCode ( + sms : string, + code : string + ): void; + + createVerificationCode ( + sms: string + ) : string; + +} diff --git a/auth/email/email-auth-constants.ts b/auth/email/email-auth-constants.ts new file mode 100644 index 0000000..741b1a7 --- /dev/null +++ b/auth/email/email-auth-constants.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { Language } from "../../types/Language"; +import { AuthEmailQueryParam } from "./types/AuthEmailQueryParam"; + +/** + * Callback that uses AuthEmailQueryParam.LANGUAGE as language query parameter + */ +export interface CallbackWithLanguage { + (lang : Language) : string; +} + +export const AUTHENTICATE_EMAIL_URL : CallbackWithLanguage = (lang: Language) => `/api/authenticateEmail?${AuthEmailQueryParam.LANGUAGE}=${q(lang)}`; +export const VERIFY_EMAIL_CODE_URL : CallbackWithLanguage = (lang: Language) => `/api/verifyEmailCode?${AuthEmailQueryParam.LANGUAGE}=${q(lang)}`; +export const VERIFY_EMAIL_TOKEN_URL : CallbackWithLanguage = (lang: Language) => `/api/verifyEmailToken?${AuthEmailQueryParam.LANGUAGE}=${q(lang)}`; + +function q (value: string) : string { + return encodeURIComponent(value); +} diff --git a/auth/email/email-auth-translations.ts b/auth/email/email-auth-translations.ts new file mode 100644 index 0000000..c7250a2 --- /dev/null +++ b/auth/email/email-auth-translations.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { Language } from "../../types/Language"; + +export const DEFAULT_LANGUAGE : Language = Language.ENGLISH; + +// Email message translation codes (used at backend code) + +export const T_M_COMMON_SITE_NAME = 'm.common.siteName'; +export const T_M_COMMON_COMPANY_NAME = 'm.common.companyName'; + +export const T_M_COMMON_HEADER_TEXT = 'm.common.headerText'; +export const T_M_COMMON_FOOTER_TEXT = 'm.common.footerText'; + +export const T_M_COMMON_HEADER_HTML = 'm.common.headerHtml'; +export const T_M_COMMON_FOOTER_HTML = 'm.common.footerHtml'; + +// Auth code email + +export const T_M_AUTH_CODE_SUBJECT = 'm.authCode.subject'; + +export const T_M_AUTH_CODE_HEADER_TEXT = 'm.authCode.headerText'; +export const T_M_AUTH_CODE_BODY_TEXT = 'm.authCode.bodyText'; +export const T_M_AUTH_CODE_FOOTER_TEXT = 'm.authCode.footerText'; + +export const T_M_AUTH_CODE_HEADER_HTML = 'm.authCode.headerHtml'; +export const T_M_AUTH_CODE_BODY_HTML = 'm.authCode.bodyHtml'; +export const T_M_AUTH_CODE_FOOTER_HTML = 'm.authCode.footerHtml'; diff --git a/auth/email/translations/fi.hg.auth.email-en.json b/auth/email/translations/fi.hg.auth.email-en.json new file mode 100644 index 0000000..7ab5f01 --- /dev/null +++ b/auth/email/translations/fi.hg.auth.email-en.json @@ -0,0 +1,19 @@ +{ + "common.siteName" : "example.com", + "common.companyName" : "Example Inc", + "m.common.siteName" : "$t(common.siteName)", + "m.common.companyName" : "$t(common.companyName)", + "m.common.headerText" : "Hi,\n", + "m.common.headerHtml" : "

Hi,

\n", + "m.common.footerText" : "-- \n$t(m.common.siteName)\n$t(m.common.companyName)\n", + "m.common.footerHtml" : "
\n$t(m.common.siteName)
\n$t(m.common.companyName)
\n", + "m.common.authCodeIs" : "Auth code is: ", + "m.common.authDisclaimer" : "If you didn't log in to our website, somebody might be trying to login as you.\nIn that case, do nothing.", + "m.authCode.subject" : "Verify your email address to our service:", + "m.authCode.headerText" : "$t(m.common.headerText)", + "m.authCode.bodyText" : "$t(m.common.authCodeIs) {{CODE}}\n\n$t(m.common.authDisclaimer)", + "m.authCode.footerText" : "$t(m.common.footerText)", + "m.authCode.headerHtml" : "$t(m.common.headerHtml)", + "m.authCode.bodyHtml" : "

$t(m.common.authCodeIs) {{CODE}}

\n\n

$t(m.common.authDisclaimer)

", + "m.authCode.footerHtml" : "$t(m.common.footerHtml)" +} diff --git a/auth/email/translations/fi.hg.auth.email-fi.json b/auth/email/translations/fi.hg.auth.email-fi.json new file mode 100644 index 0000000..b6d6272 --- /dev/null +++ b/auth/email/translations/fi.hg.auth.email-fi.json @@ -0,0 +1,19 @@ +{ + "common.siteName" : "example.com", + "common.companyName" : "Example Inc", + "m.common.siteName" : "$t(common.siteName)", + "m.common.companyName" : "$t(common.companyName)", + "m.common.headerText" : "Hei,\n", + "m.common.headerHtml" : "

Hei,

\n", + "m.common.footerText" : "-- \n$t(m.common.siteName)\n$t(m.common.companyName)\n", + "m.common.footerHtml" : "
\n$t(m.common.siteName)
\n$t(m.common.companyName)
\n", + "m.common.authCodeIs" : "Vahvistuskoodisi on: ", + "m.common.authDisclaimer" : "Jos et tehnyt pyyntöä itse, saattaa joku muu yrittää luvatta tehdä tilausta verkkokauppaamme.\nSiinä tapauksessa älä tee mitään.", + "m.authCode.subject" : "Vahvista sähköpostiosoitteesi palveluumme", + "m.authCode.headerText" : "$t(m.common.headerText)", + "m.authCode.bodyText" : "$t(m.common.authCodeIs) {{CODE}}\n\n$t(m.common.authDisclaimer)", + "m.authCode.footerText" : "$t(m.common.footerText)", + "m.authCode.headerHtml" : "$t(m.common.headerHtml)", + "m.authCode.bodyHtml" : "

$t(m.common.authCodeIs) {{CODE}}

\n\n

$t(m.common.authDisclaimer)

", + "m.authCode.footerHtml" : "$t(m.common.footerHtml)" +} diff --git a/auth/email/translations/index.ts b/auth/email/translations/index.ts new file mode 100644 index 0000000..eee8d50 --- /dev/null +++ b/auth/email/translations/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { TranslationResourceObject } from "../../../types/TranslationResourceObject"; +import { default as en } from "./fi.hg.auth.email-en.json"; +import { default as fi } from "./fi.hg.auth.email-fi.json"; + +export const TRANSLATIONS : TranslationResourceObject = { + en, + fi +}; diff --git a/auth/email/types/AuthEmailQueryParam.test.ts b/auth/email/types/AuthEmailQueryParam.test.ts new file mode 100644 index 0000000..77df71d --- /dev/null +++ b/auth/email/types/AuthEmailQueryParam.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { AuthEmailQueryParam, isAuthEmailQueryParam, parseAuthEmailQueryParam, stringifyAuthEmailQueryParam } from "./AuthEmailQueryParam"; + +describe('AuthEmailQueryParam', () => { + + describe('isAuthEmailQueryParam', () => { + it('should return true when the value is a valid AuthEmailQueryParam', () => { + const result = isAuthEmailQueryParam(AuthEmailQueryParam.LANGUAGE); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid AuthEmailQueryParam', () => { + const result = isAuthEmailQueryParam('invalidValue'); + expect(result).toBe(false); + }); + }); + + describe('stringifyAuthEmailQueryParam', () => { + it('should return a string representation of the AuthEmailQueryParam', () => { + const result = stringifyAuthEmailQueryParam(AuthEmailQueryParam.LANGUAGE); + expect(result).toBe('LANGUAGE'); + }); + + it('should throw TypeError for unsupported AuthEmailQueryParam values', () => { + // @ts-ignore + expect(() => stringifyAuthEmailQueryParam('invalidValue')).toThrowError(TypeError); + }); + }); + + describe('parseAuthEmailQueryParam', () => { + it('should return an AuthEmailQueryParam when given a valid string', () => { + let result = parseAuthEmailQueryParam('l'); + expect(result).toBe(AuthEmailQueryParam.LANGUAGE); + + result = parseAuthEmailQueryParam('LANGUAGE'); + expect(result).toBe(AuthEmailQueryParam.LANGUAGE); + }); + + it('should return undefined when given an invalid string', () => { + const result = parseAuthEmailQueryParam('invalidValue'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/auth/email/types/AuthEmailQueryParam.ts b/auth/email/types/AuthEmailQueryParam.ts new file mode 100644 index 0000000..f1caf4f --- /dev/null +++ b/auth/email/types/AuthEmailQueryParam.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2022-2023. Heusala Group . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum AuthEmailQueryParam { + + /** + * Query key: `l` + */ + LANGUAGE = "l" + +} + +export function isAuthEmailQueryParam (value: any): value is AuthEmailQueryParam { + switch (value) { + case AuthEmailQueryParam.LANGUAGE: + return true; + + default: + return false; + + } +} + +export function stringifyAuthEmailQueryParam (value: AuthEmailQueryParam): string { + switch (value) { + case AuthEmailQueryParam.LANGUAGE : return 'LANGUAGE'; + } + throw new TypeError(`Unsupported AuthEmailQueryParam value: ${value}`); +} + +export function parseAuthEmailQueryParam (value: any): AuthEmailQueryParam | undefined { + + switch (`${value}`.toUpperCase()) { + + case 'L' : + case 'LANGUAGE': + return AuthEmailQueryParam.LANGUAGE; + + default : + return undefined; + + } + +} diff --git a/auth/email/types/AuthenticateEmailDTO.test.ts b/auth/email/types/AuthenticateEmailDTO.test.ts new file mode 100644 index 0000000..c815149 --- /dev/null +++ b/auth/email/types/AuthenticateEmailDTO.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createAuthenticateEmailDTO, isAuthenticateEmailDTO, parseAuthenticateEmailDTO, stringifyAuthenticateEmailDTO } from "./AuthenticateEmailDTO"; + +describe('AuthenticateEmailDTO', () => { + let email : string; + + beforeEach(() => { + email = 'test@gmail.com'; + }); + + describe('createAuthenticateEmailDTO', () => { + it('should create a valid AuthenticateEmailDTO object', () => { + const result = createAuthenticateEmailDTO(email); + expect(result).toEqual({email}); + }); + }); + + describe('isAuthenticateEmailDTO', () => { + it('should return true when the object is a valid AuthenticateEmailDTO', () => { + const result = isAuthenticateEmailDTO({email}); + expect(result).toBe(true); + }); + + it('should return false when the object is not a valid AuthenticateEmailDTO', () => { + const result = isAuthenticateEmailDTO({invalidKey: 'invalidValue'}); + expect(result).toBe(false); + }); + }); + + describe('stringifyAuthenticateEmailDTO', () => { + it('should return a string representation of the AuthenticateEmailDTO object', () => { + const result = stringifyAuthenticateEmailDTO({email}); + expect(result).toBe(`{"email":"${email}"}`); + }); + }); + + describe('parseAuthenticateEmailDTO', () => { + it('should return an AuthenticateEmailDTO object when given a valid object', () => { + const result = parseAuthenticateEmailDTO({email}); + expect(result).toEqual({email}); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseAuthenticateEmailDTO({invalidKey: 'invalidValue'}); + expect(result).toBeUndefined(); + }); + }); + +}); diff --git a/auth/email/types/AuthenticateEmailDTO.ts b/auth/email/types/AuthenticateEmailDTO.ts new file mode 100644 index 0000000..31f4c21 --- /dev/null +++ b/auth/email/types/AuthenticateEmailDTO.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2022-2023. . All rights reserved. +// Copyright (c) 2022-2023. . All rights reserved. + +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface AuthenticateEmailDTO { + readonly email : string; +} + +export function createAuthenticateEmailDTO (email: string) : AuthenticateEmailDTO { + return {email}; +} + +export function isAuthenticateEmailDTO (value: any): value is AuthenticateEmailDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'email' + ]) + && isString(value?.email) + ); +} + +export function stringifyAuthenticateEmailDTO (value: AuthenticateEmailDTO): string { + return JSON.stringify(value); +} + +export function parseAuthenticateEmailDTO (value: any): AuthenticateEmailDTO | undefined { + if ( isAuthenticateEmailDTO(value) ) return value; + return undefined; +} diff --git a/auth/email/types/EmailTokenDTO.test.ts b/auth/email/types/EmailTokenDTO.test.ts new file mode 100644 index 0000000..b8ae710 --- /dev/null +++ b/auth/email/types/EmailTokenDTO.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createEmailTokenDTO, explainEmailTokenDTOOrUndefined, isEmailTokenDTO, isEmailTokenDTOOrUndefined, parseEmailTokenDTO, stringifyEmailTokenDTO } from "./EmailTokenDTO"; + +describe('EmailTokenDTO', () => { + + const validDTO = { + token: '123', + email: 'test@example.com', + verified: true, + }; + + describe('createEmailTokenDTO', () => { + it('should return a valid EmailTokenDTO', () => { + const result = createEmailTokenDTO('123', 'test@example.com', true); + expect(result).toEqual(validDTO); + }); + }); + + describe('isEmailTokenDTO', () => { + + it('should return true when the value is a valid EmailTokenDTO', () => { + const result = isEmailTokenDTO(validDTO); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid EmailTokenDTO', () => { + const result = isEmailTokenDTO({token: 123, email: 'test@example.com'}); + expect(result).toBe(false); + }); + + }); + + describe('stringifyEmailTokenDTO', () => { + it('should return a string representation of the EmailTokenDTO', () => { + const result = stringifyEmailTokenDTO(validDTO); + expect(result).toBe(JSON.stringify(validDTO)); + }); + }); + + describe('parseEmailTokenDTO', () => { + + it('should return an EmailTokenDTO when given a valid object', () => { + const result = parseEmailTokenDTO(validDTO); + expect(result).toEqual(validDTO); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseEmailTokenDTO({token: 123, email: 'test@example.com'}); + expect(result).toBeUndefined(); + }); + + }); + + describe('isEmailTokenDTOOrUndefined', () => { + + it('should return true when the value is a valid EmailTokenDTO or undefined', () => { + let result = isEmailTokenDTOOrUndefined(validDTO); + expect(result).toBe(true); + + result = isEmailTokenDTOOrUndefined(undefined); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid EmailTokenDTO and not undefined', () => { + const result = isEmailTokenDTOOrUndefined({token: 123, email: 'test@example.com'}); + expect(result).toBe(false); + }); + + }); + + describe('explainEmailTokenDTOOrUndefined', () => { + + it('should return "Ok" when the value is a valid EmailTokenDTO or undefined', () => { + let result = explainEmailTokenDTOOrUndefined(validDTO); + expect(result).toBe('OK'); + + result = explainEmailTokenDTOOrUndefined(undefined); + expect(result).toBe('OK'); + }); + + it('should return explanation when the value is not a valid EmailTokenDTO and not undefined', () => { + const result = explainEmailTokenDTOOrUndefined({token: 123, email: 'test@example.com'}); + expect(result).toBe('not EmailTokenDTO or undefined'); + }); + + }); + +}); diff --git a/auth/email/types/EmailTokenDTO.ts b/auth/email/types/EmailTokenDTO.ts new file mode 100644 index 0000000..d70ac43 --- /dev/null +++ b/auth/email/types/EmailTokenDTO.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2022-2023. . All rights reserved. +// Copyright (c) 2022-2023. . All rights reserved. + +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isUndefined } from "../../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; + +export interface EmailTokenDTO { + readonly token : string; + readonly email : string; + readonly verified ?: boolean | undefined; +} + +export function createEmailTokenDTO ( + token : string, + email : string, + verified ?: boolean | undefined, +) { + return { + token, + email, + verified, + }; +} + +export function isEmailTokenDTO (value: any): value is EmailTokenDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'token', + 'email', + 'verified' + ]) + && isString(value?.token) + && isString(value?.email) + && isBooleanOrUndefined(value?.verified) + ); +} + +export function stringifyEmailTokenDTO (value: EmailTokenDTO): string { + return JSON.stringify(value); +} + +export function parseEmailTokenDTO (value: any): EmailTokenDTO | undefined { + if ( isEmailTokenDTO(value) ) return value; + return undefined; +} + +export function isEmailTokenDTOOrUndefined (value: unknown): value is EmailTokenDTO | undefined { + return isUndefined(value) || isEmailTokenDTO(value); +} + +export function explainEmailTokenDTOOrUndefined (value: unknown): string { + return isEmailTokenDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['EmailTokenDTO', 'undefined'])); +} diff --git a/auth/email/types/SendEmailCodeDTO.test.ts b/auth/email/types/SendEmailCodeDTO.test.ts new file mode 100644 index 0000000..a64db97 --- /dev/null +++ b/auth/email/types/SendEmailCodeDTO.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + + +import { Language } from "../../../types/Language"; +import { + createSendEmailCodeDTO, + isSendEmailCodeDTO, + parseSendEmailCodeDTO, + stringifySendEmailCodeDTO +} from "./SendEmailCodeDTO"; + +describe('SendEmailCodeDTO', () => { + const validEmailTokenDTO = { + token: '123', + email: 'test@example.com', + verified: true, + }; + + const validDTO = { + token: validEmailTokenDTO, + code: 'ABC123', + lang: Language.ENGLISH + }; + + describe('createSendEmailCodeDTO', () => { + it('should return a valid SendEmailCodeDTO', () => { + const result = createSendEmailCodeDTO(validEmailTokenDTO, 'ABC123', Language.ENGLISH); + expect(result).toEqual(validDTO); + }); + }); + + describe('isSendEmailCodeDTO', () => { + it('should return true when the value is a valid SendEmailCodeDTO', () => { + const result = isSendEmailCodeDTO(validDTO); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid SendEmailCodeDTO', () => { + const result = isSendEmailCodeDTO({token: validEmailTokenDTO}); + expect(result).toBe(false); + }); + }); + + describe('stringifySendEmailCodeDTO', () => { + it('should return a string representation of the SendEmailCodeDTO', () => { + const result = stringifySendEmailCodeDTO(validDTO); + expect(result).toBe('SendEmailCode([object Object])'); + }); + }); + + describe('parseSendEmailCodeDTO', () => { + it('should return a SendEmailCodeDTO when given a valid object', () => { + const result = parseSendEmailCodeDTO(validDTO); + expect(result).toEqual(validDTO); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseSendEmailCodeDTO({token: validEmailTokenDTO}); + expect(result).toBeUndefined(); + }); + }); + +}); diff --git a/auth/email/types/SendEmailCodeDTO.ts b/auth/email/types/SendEmailCodeDTO.ts new file mode 100644 index 0000000..c238471 --- /dev/null +++ b/auth/email/types/SendEmailCodeDTO.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022-2023. . All rights reserved. + +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isLanguage, Language } from "../../../types/Language"; +import { EmailTokenDTO, isEmailTokenDTO } from "./EmailTokenDTO"; + +export interface sendEmailCodeDTO { + readonly token : EmailTokenDTO; + readonly code : string; + readonly lang : Language; +} + +export function createSendEmailCodeDTO ( + token : EmailTokenDTO, + code : string, + lang : Language +) { + return { + token, + code, + lang + }; +} + +export function isSendEmailCodeDTO (value: any): value is sendEmailCodeDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'token', + 'code', + 'lang' + ]) + && isEmailTokenDTO(value?.token) + && isString(value?.code) + && isLanguage(value?.lang) + ); +} + +export function stringifySendEmailCodeDTO (value: sendEmailCodeDTO): string { + return `SendEmailCode(${value})`; +} + +export function parseSendEmailCodeDTO (value: any): sendEmailCodeDTO | undefined { + if ( isSendEmailCodeDTO(value) ) return value; + return undefined; +} diff --git a/auth/email/types/VerifyEmailCodeDTO.test.ts b/auth/email/types/VerifyEmailCodeDTO.test.ts new file mode 100644 index 0000000..f282803 --- /dev/null +++ b/auth/email/types/VerifyEmailCodeDTO.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createVerifyEmailCodeDTO, isVerifyEmailCodeDTO, parseVerifyEmailCodeDTO, stringifyVerifyEmailCodeDTO } from "./VerifyEmailCodeDTO"; + +describe('VerifyEmailCodeDTO', () => { + const validEmailTokenDTO = { + token: '123', + email: 'test@example.com', + verified: true, + }; + + const validDTO = { + token: validEmailTokenDTO, + code: 'ABC123', + }; + + describe('createVerifyEmailCodeDTO', () => { + it('should return a valid VerifyEmailCodeDTO', () => { + const result = createVerifyEmailCodeDTO(validEmailTokenDTO, 'ABC123'); + expect(result).toEqual(validDTO); + }); + }); + + describe('isVerifyEmailCodeDTO', () => { + it('should return true when the value is a valid VerifyEmailCodeDTO', () => { + const result = isVerifyEmailCodeDTO(validDTO); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid VerifyEmailCodeDTO', () => { + const result = isVerifyEmailCodeDTO({token: validEmailTokenDTO}); + expect(result).toBe(false); + }); + }); + + describe('stringifyVerifyEmailCodeDTO', () => { + it('should return a string representation of the VerifyEmailCodeDTO', () => { + const result = stringifyVerifyEmailCodeDTO(validDTO); + expect(result).toBe('VerifyEmailCodeDTO([object Object])'); + }); + }); + + describe('parseVerifyEmailCodeDTO', () => { + it('should return a VerifyEmailCodeDTO when given a valid object', () => { + const result = parseVerifyEmailCodeDTO(validDTO); + expect(result).toEqual(validDTO); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseVerifyEmailCodeDTO({token: validEmailTokenDTO}); + expect(result).toBeUndefined(); + }); + }); + +}); diff --git a/auth/email/types/VerifyEmailCodeDTO.ts b/auth/email/types/VerifyEmailCodeDTO.ts new file mode 100644 index 0000000..ac8a142 --- /dev/null +++ b/auth/email/types/VerifyEmailCodeDTO.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2022-2023. . All rights reserved. +// Copyright (c) 2022-2023. . All rights reserved. + +import { + EmailTokenDTO, + isEmailTokenDTO +} from "./EmailTokenDTO"; +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface VerifyEmailCodeDTO { + readonly token : EmailTokenDTO; + readonly code : string; +} + +export function createVerifyEmailCodeDTO ( + token : EmailTokenDTO, + code : string +) { + return { + token, + code + }; +} + +export function isVerifyEmailCodeDTO (value: any): value is VerifyEmailCodeDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'token', + 'code' + ]) + && isEmailTokenDTO(value?.token) + && isString(value?.code) + ); +} + +export function stringifyVerifyEmailCodeDTO (value: VerifyEmailCodeDTO): string { + return `VerifyEmailCodeDTO(${value})`; +} + +export function parseVerifyEmailCodeDTO (value: any): VerifyEmailCodeDTO | undefined { + if ( isVerifyEmailCodeDTO(value) ) return value; + return undefined; +} diff --git a/auth/email/types/VerifyEmailTokenDTO.test.ts b/auth/email/types/VerifyEmailTokenDTO.test.ts new file mode 100644 index 0000000..586e0fe --- /dev/null +++ b/auth/email/types/VerifyEmailTokenDTO.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createVerifyEmailTokenDTO, isVerifyEmailTokenDTO, parseVerifyEmailTokenDTO, stringifyVerifyEmailTokenDTO } from "./VerifyEmailTokenDTO"; + +describe('VerifyEmailTokenDTO', () => { + const validEmailTokenDTO = { + token: '123', + email: 'test@example.com', + verified: true, + }; + + const validDTO = { + token: validEmailTokenDTO, + }; + + describe('createVerifyEmailTokenDTO', () => { + it('should return a valid VerifyEmailTokenDTO', () => { + const result = createVerifyEmailTokenDTO(validEmailTokenDTO); + expect(result).toEqual(validDTO); + }); + }); + + describe('isVerifyEmailTokenDTO', () => { + it('should return true when the value is a valid VerifyEmailTokenDTO', () => { + const result = isVerifyEmailTokenDTO(validDTO); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid VerifyEmailTokenDTO', () => { + const result = isVerifyEmailTokenDTO({token: '123'}); + expect(result).toBe(false); + }); + }); + + describe('stringifyVerifyEmailTokenDTO', () => { + it('should return a string representation of the VerifyEmailTokenDTO', () => { + const result = stringifyVerifyEmailTokenDTO(validDTO); + expect(result).toBe('VerifyEmailTokenDTO([object Object])'); + }); + }); + + describe('parseVerifyEmailTokenDTO', () => { + it('should return a VerifyEmailTokenDTO when given a valid object', () => { + const result = parseVerifyEmailTokenDTO(validDTO); + expect(result).toEqual(validDTO); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseVerifyEmailTokenDTO({token: '123'}); + expect(result).toBeUndefined(); + }); + }); + +}); diff --git a/auth/email/types/VerifyEmailTokenDTO.ts b/auth/email/types/VerifyEmailTokenDTO.ts new file mode 100644 index 0000000..2a3e131 --- /dev/null +++ b/auth/email/types/VerifyEmailTokenDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022-2023. . All rights reserved. +// Copyright (c) 2022-2023. . All rights reserved. + +import { + EmailTokenDTO, + isEmailTokenDTO +} from "./EmailTokenDTO"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface VerifyEmailTokenDTO { + readonly token : EmailTokenDTO; +} + +export function createVerifyEmailTokenDTO ( + token: EmailTokenDTO +) : VerifyEmailTokenDTO { + return {token}; +} + +export function isVerifyEmailTokenDTO (value: unknown): value is VerifyEmailTokenDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'token' + ]) + && isEmailTokenDTO(value?.token) + ); +} + +export function stringifyVerifyEmailTokenDTO (value: VerifyEmailTokenDTO): string { + return `VerifyEmailTokenDTO(${value})`; +} + +export function parseVerifyEmailTokenDTO (value: any): VerifyEmailTokenDTO | undefined { + if ( isVerifyEmailTokenDTO(value) ) return value; + return undefined; +} diff --git a/auth/sms/sms-auth-constants.ts b/auth/sms/sms-auth-constants.ts new file mode 100644 index 0000000..4a09897 --- /dev/null +++ b/auth/sms/sms-auth-constants.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { Language } from "../../types/Language"; +import { AuthSmsQueryParam } from "./types/AuthSmsQueryParam"; + +/** + * Callback that uses AuthSmsQueryParam.LANGUAGE as language query parameter + */ +export interface CallbackWithLanguage { + (lang : Language) : string; +} + +export const AUTHENTICATE_SMS_URL : CallbackWithLanguage = (lang: Language) => `/api/authenticateSms?${AuthSmsQueryParam.LANGUAGE}=${q(lang)}`; +export const VERIFY_SMS_CODE_URL : CallbackWithLanguage = (lang: Language) => `/api/verifySmsCode?${AuthSmsQueryParam.LANGUAGE}=${q(lang)}`; +export const VERIFY_SMS_TOKEN_URL : CallbackWithLanguage = (lang: Language) => `/api/verifySmsToken?${AuthSmsQueryParam.LANGUAGE}=${q(lang)}`; + +function q (value: string) : string { + return encodeURIComponent(value); +} diff --git a/auth/sms/sms-auth-translations.ts b/auth/sms/sms-auth-translations.ts new file mode 100644 index 0000000..3f826c5 --- /dev/null +++ b/auth/sms/sms-auth-translations.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Language } from "../../types/Language"; + +export const DEFAULT_LANGUAGE : Language = Language.ENGLISH; + +// SMS message translation codes (used at backend code) + +export const T_M_COMMON_SITE_NAME = 'sms.m.common.siteName'; +export const T_M_COMMON_COMPANY_NAME = 'sms.m.common.companyName'; + +export const T_M_COMMON_HEADER_TEXT = 'sms.m.common.headerText'; +export const T_M_COMMON_FOOTER_TEXT = 'sms.m.common.footerText'; + +export const T_M_COMMON_HEADER_HTML = 'sms.m.common.headerHtml'; +export const T_M_COMMON_FOOTER_HTML = 'sms.m.common.footerHtml'; + +// Auth code sms + +export const T_M_AUTH_CODE_SUBJECT = 'sms.m.authCode.subject'; + +export const T_M_AUTH_CODE_HEADER_TEXT = 'sms.m.authCode.headerText'; +export const T_M_AUTH_CODE_BODY_TEXT = 'sms.m.authCode.bodyText'; +export const T_M_AUTH_CODE_FOOTER_TEXT = 'sms.m.authCode.footerText'; + +export const T_M_AUTH_CODE_HEADER_HTML = 'sms.m.authCode.headerHtml'; +export const T_M_AUTH_CODE_BODY_HTML = 'sms.m.authCode.bodyHtml'; +export const T_M_AUTH_CODE_FOOTER_HTML = 'sms.m.authCode.footerHtml'; diff --git a/auth/sms/translations/fi.hg.auth.sms-en.json b/auth/sms/translations/fi.hg.auth.sms-en.json new file mode 100644 index 0000000..b300bc8 --- /dev/null +++ b/auth/sms/translations/fi.hg.auth.sms-en.json @@ -0,0 +1,14 @@ +{ + "common.siteName" : "example.com", + "common.companyName" : "Example Inc", + "sms.m.common.siteName" : "$t(common.siteName)", + "sms.m.common.companyName" : "$t(common.companyName)", + "sms.m.common.headerText" : "", + "sms.m.common.footerText" : "-- $t(sms.m.common.siteName)", + "sms.m.common.authCodeIs" : "Code is: ", + "sms.m.common.authDisclaimer" : "If you didn't request this code, do nothing.", + "sms.m.authCode.subject" : "Verify your address to our service", + "sms.m.authCode.headerText" : "$t(sms.m.common.headerText)", + "sms.m.authCode.bodyText" : "$t(sms.m.common.authCodeIs) {{CODE}} $t(sms.m.common.authDisclaimer)", + "sms.m.authCode.footerText" : "$t(sms.m.common.footerText)" +} diff --git a/auth/sms/translations/fi.hg.auth.sms-fi.json b/auth/sms/translations/fi.hg.auth.sms-fi.json new file mode 100644 index 0000000..94a0fb8 --- /dev/null +++ b/auth/sms/translations/fi.hg.auth.sms-fi.json @@ -0,0 +1,14 @@ +{ + "common.siteName" : "example.com", + "common.companyName" : "Example Inc", + "sms.m.common.siteName" : "$t(common.siteName)", + "sms.m.common.companyName" : "$t(common.companyName)", + "sms.m.common.headerText" : "", + "sms.m.common.footerText" : "-- $t(m.common.siteName)", + "sms.m.common.authCodeIs" : "Koodi: ", + "sms.m.common.authDisclaimer" : "Jos et pyytänyt koodia itse, älä tee mitään.", + "sms.m.authCode.subject" : "Vahvista osoitteesi palveluumme", + "sms.m.authCode.headerText" : "$t(sms.m.common.headerText)", + "sms.m.authCode.bodyText" : "$t(sms.m.common.authCodeIs) {{CODE}} $t(sms.m.common.authDisclaimer)", + "sms.m.authCode.footerText" : "$t(sms.m.common.footerText)" +} diff --git a/auth/sms/translations/index.ts b/auth/sms/translations/index.ts new file mode 100644 index 0000000..affc1f4 --- /dev/null +++ b/auth/sms/translations/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { TranslationResourceObject } from "../../../types/TranslationResourceObject"; +import { default as en } from "./fi.hg.auth.sms-en.json"; +import { default as fi } from "./fi.hg.auth.sms-fi.json"; + +export const TRANSLATIONS : TranslationResourceObject = { + en, + fi +}; diff --git a/auth/sms/types/AuthSmsQueryParam.test.ts b/auth/sms/types/AuthSmsQueryParam.test.ts new file mode 100644 index 0000000..c666945 --- /dev/null +++ b/auth/sms/types/AuthSmsQueryParam.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { AuthSmsQueryParam, isAuthSmsQueryParam, parseAuthSmsQueryParam, stringifyAuthSmsQueryParam } from "./AuthSmsQueryParam"; + +describe('AuthSmsQueryParam', () => { + + describe('isAuthSmsQueryParam', () => { + it('should return true when the value is a valid AuthSmsQueryParam', () => { + const result = isAuthSmsQueryParam(AuthSmsQueryParam.LANGUAGE); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid AuthSmsQueryParam', () => { + const result = isAuthSmsQueryParam('invalidValue'); + expect(result).toBe(false); + }); + }); + + describe('stringifyAuthSmsQueryParam', () => { + it('should return a string representation of the AuthSmsQueryParam', () => { + const result = stringifyAuthSmsQueryParam(AuthSmsQueryParam.LANGUAGE); + expect(result).toBe('LANGUAGE'); + }); + + it('should throw TypeError for unsupported AuthSmsQueryParam values', () => { + // @ts-ignore + expect(() => stringifyAuthSmsQueryParam('invalidValue')).toThrowError(TypeError); + }); + }); + + describe('parseAuthSmsQueryParam', () => { + it('should return an AuthSmsQueryParam when given a valid string', () => { + let result = parseAuthSmsQueryParam('l'); + expect(result).toBe(AuthSmsQueryParam.LANGUAGE); + + result = parseAuthSmsQueryParam('LANGUAGE'); + expect(result).toBe(AuthSmsQueryParam.LANGUAGE); + }); + + it('should return undefined when given an invalid string', () => { + const result = parseAuthSmsQueryParam('invalidValue'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/auth/sms/types/AuthSmsQueryParam.ts b/auth/sms/types/AuthSmsQueryParam.ts new file mode 100644 index 0000000..5e58d07 --- /dev/null +++ b/auth/sms/types/AuthSmsQueryParam.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2022-2023. Heusala Group . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum AuthSmsQueryParam { + + /** + * Query key: `l` + */ + LANGUAGE = "l" + +} + +export function isAuthSmsQueryParam (value: any): value is AuthSmsQueryParam { + switch (value) { + case AuthSmsQueryParam.LANGUAGE: + return true; + + default: + return false; + + } +} + +export function stringifyAuthSmsQueryParam (value: AuthSmsQueryParam): string { + switch (value) { + case AuthSmsQueryParam.LANGUAGE : return 'LANGUAGE'; + } + throw new TypeError(`Unsupported AuthSmsQueryParam value: ${value}`); +} + +export function parseAuthSmsQueryParam (value: any): AuthSmsQueryParam | undefined { + + switch (`${value}`.toUpperCase()) { + + case 'L' : + case 'LANGUAGE': + return AuthSmsQueryParam.LANGUAGE; + + default : + return undefined; + + } + +} diff --git a/auth/sms/types/AuthenticateSmsDTO.test.ts b/auth/sms/types/AuthenticateSmsDTO.test.ts new file mode 100644 index 0000000..5d259c3 --- /dev/null +++ b/auth/sms/types/AuthenticateSmsDTO.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createAuthenticateSmsDTO, isAuthenticateSmsDTO, parseAuthenticateSmsDTO, stringifyAuthenticateSmsDTO } from "./AuthenticateSmsDTO"; + +describe('AuthenticateSmsDTO', () => { + let sms : string; + + beforeEach(() => { + sms = '+3587099704'; + }); + + describe('createAuthenticateSmsDTO', () => { + it('should create a valid AuthenticateSmsDTO object', () => { + const result = createAuthenticateSmsDTO(sms); + expect(result).toEqual({sms}); + }); + }); + + describe('isAuthenticateSmsDTO', () => { + it('should return true when the object is a valid AuthenticateSmsDTO', () => { + const result = isAuthenticateSmsDTO({sms}); + expect(result).toBe(true); + }); + + it('should return false when the object is not a valid AuthenticateSmsDTO', () => { + const result = isAuthenticateSmsDTO({invalidKey: 'invalidValue'}); + expect(result).toBe(false); + }); + }); + + describe('stringifyAuthenticateSmsDTO', () => { + it('should return a string representation of the AuthenticateSmsDTO object', () => { + const result = stringifyAuthenticateSmsDTO({sms}); + expect(result).toBe(`{"sms":"${sms}"}`); + }); + }); + + describe('parseAuthenticateSmsDTO', () => { + it('should return an AuthenticateSmsDTO object when given a valid object', () => { + const result = parseAuthenticateSmsDTO({sms}); + expect(result).toEqual({sms}); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseAuthenticateSmsDTO({invalidKey: 'invalidValue'}); + expect(result).toBeUndefined(); + }); + }); + +}); diff --git a/auth/sms/types/AuthenticateSmsDTO.ts b/auth/sms/types/AuthenticateSmsDTO.ts new file mode 100644 index 0000000..e3a6694 --- /dev/null +++ b/auth/sms/types/AuthenticateSmsDTO.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2022-2023. . All rights reserved. +// Copyright (c) 2022-2023. . All rights reserved. + +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface AuthenticateSmsDTO { + readonly sms : string; +} + +export function createAuthenticateSmsDTO (sms: string) : AuthenticateSmsDTO { + return {sms}; +} + +export function isAuthenticateSmsDTO (value: any): value is AuthenticateSmsDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'sms' + ]) + && isString(value?.sms) + ); +} + +export function stringifyAuthenticateSmsDTO (value: AuthenticateSmsDTO): string { + return JSON.stringify(value); +} + +export function parseAuthenticateSmsDTO (value: any): AuthenticateSmsDTO | undefined { + if ( isAuthenticateSmsDTO(value) ) return value; + return undefined; +} diff --git a/auth/sms/types/SmsTokenDTO.test.ts b/auth/sms/types/SmsTokenDTO.test.ts new file mode 100644 index 0000000..006f3f5 --- /dev/null +++ b/auth/sms/types/SmsTokenDTO.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createSmsTokenDTO, explainSmsTokenDTOOrUndefined, isSmsTokenDTO, isSmsTokenDTOOrUndefined, parseSmsTokenDTO, stringifySmsTokenDTO } from "./SmsTokenDTO"; + +describe('SmsTokenDTO', () => { + + const validDTO = { + token: '123', + sms: '+3587099704', + verified: true, + }; + + describe('createSmsTokenDTO', () => { + it('should return a valid SmsTokenDTO', () => { + const result = createSmsTokenDTO('123', '+3587099704', true); + expect(result).toEqual(validDTO); + }); + }); + + describe('isSmsTokenDTO', () => { + + it('should return true when the value is a valid SmsTokenDTO', () => { + const result = isSmsTokenDTO(validDTO); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid SmsTokenDTO', () => { + const result = isSmsTokenDTO({token: 123, sms: '+3587099704'}); + expect(result).toBe(false); + }); + + }); + + describe('stringifySmsTokenDTO', () => { + it('should return a string representation of the SmsTokenDTO', () => { + const result = stringifySmsTokenDTO(validDTO); + expect(result).toBe(JSON.stringify(validDTO)); + }); + }); + + describe('parseSmsTokenDTO', () => { + + it('should return an SmsTokenDTO when given a valid object', () => { + const result = parseSmsTokenDTO(validDTO); + expect(result).toEqual(validDTO); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseSmsTokenDTO({token: 123, sms: '+3587099704'}); + expect(result).toBeUndefined(); + }); + + }); + + describe('isSmsTokenDTOOrUndefined', () => { + + it('should return true when the value is a valid SmsTokenDTO or undefined', () => { + let result = isSmsTokenDTOOrUndefined(validDTO); + expect(result).toBe(true); + + result = isSmsTokenDTOOrUndefined(undefined); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid SmsTokenDTO and not undefined', () => { + const result = isSmsTokenDTOOrUndefined({token: 123, sms: '+3587099704'}); + expect(result).toBe(false); + }); + + }); + + describe('explainSmsTokenDTOOrUndefined', () => { + + it('should return "Ok" when the value is a valid SmsTokenDTO or undefined', () => { + let result = explainSmsTokenDTOOrUndefined(validDTO); + expect(result).toBe('OK'); + + result = explainSmsTokenDTOOrUndefined(undefined); + expect(result).toBe('OK'); + }); + + it('should return explanation when the value is not a valid SmsTokenDTO and not undefined', () => { + const result = explainSmsTokenDTOOrUndefined({token: 123, sms: '+3587099704'}); + expect(result).toBe('not SmsTokenDTO or undefined'); + }); + + }); + +}); diff --git a/auth/sms/types/SmsTokenDTO.ts b/auth/sms/types/SmsTokenDTO.ts new file mode 100644 index 0000000..c64178a --- /dev/null +++ b/auth/sms/types/SmsTokenDTO.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2022-2023. . All rights reserved. +// Copyright (c) 2022-2023. . All rights reserved. + +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isUndefined } from "../../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; + +export interface SmsTokenDTO { + readonly token : string; + readonly sms : string; + readonly verified ?: boolean | undefined; +} + +export function createSmsTokenDTO ( + token : string, + sms : string, + verified ?: boolean | undefined, +) { + return { + token, + sms, + verified, + }; +} + +export function isSmsTokenDTO (value: any): value is SmsTokenDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'token', + 'sms', + 'verified' + ]) + && isString(value?.token) + && isString(value?.sms) + && isBooleanOrUndefined(value?.verified) + ); +} + +export function stringifySmsTokenDTO (value: SmsTokenDTO): string { + return JSON.stringify(value); +} + +export function parseSmsTokenDTO (value: any): SmsTokenDTO | undefined { + if ( isSmsTokenDTO(value) ) return value; + return undefined; +} + +export function isSmsTokenDTOOrUndefined (value: unknown): value is SmsTokenDTO | undefined { + return isUndefined(value) || isSmsTokenDTO(value); +} + +export function explainSmsTokenDTOOrUndefined (value: unknown): string { + return isSmsTokenDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['SmsTokenDTO', 'undefined'])); +} diff --git a/auth/sms/types/VerifySmsCodeDTO.test.ts b/auth/sms/types/VerifySmsCodeDTO.test.ts new file mode 100644 index 0000000..67e7eba --- /dev/null +++ b/auth/sms/types/VerifySmsCodeDTO.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createVerifySmsCodeDTO, isVerifySmsCodeDTO, parseVerifySmsCodeDTO, stringifyVerifySmsCodeDTO } from "./VerifySmsCodeDTO"; + +describe('VerifySmsCodeDTO', () => { + const validSmsTokenDTO = { + token: '123', + sms: '+3587099704', + verified: true, + }; + + const validDTO = { + token: validSmsTokenDTO, + code: 'ABC123', + }; + + describe('createVerifySmsCodeDTO', () => { + it('should return a valid VerifySmsCodeDTO', () => { + const result = createVerifySmsCodeDTO(validSmsTokenDTO, 'ABC123'); + expect(result).toEqual(validDTO); + }); + }); + + describe('isVerifySmsCodeDTO', () => { + it('should return true when the value is a valid VerifySmsCodeDTO', () => { + const result = isVerifySmsCodeDTO(validDTO); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid VerifySmsCodeDTO', () => { + const result = isVerifySmsCodeDTO({token: validSmsTokenDTO}); + expect(result).toBe(false); + }); + }); + + describe('stringifyVerifySmsCodeDTO', () => { + it('should return a string representation of the VerifySmsCodeDTO', () => { + const result = stringifyVerifySmsCodeDTO(validDTO); + expect(result).toBe('VerifySmsCodeDTO([object Object])'); + }); + }); + + describe('parseVerifySmsCodeDTO', () => { + it('should return a VerifySmsCodeDTO when given a valid object', () => { + const result = parseVerifySmsCodeDTO(validDTO); + expect(result).toEqual(validDTO); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseVerifySmsCodeDTO({token: validSmsTokenDTO}); + expect(result).toBeUndefined(); + }); + }); + +}); diff --git a/auth/sms/types/VerifySmsCodeDTO.ts b/auth/sms/types/VerifySmsCodeDTO.ts new file mode 100644 index 0000000..21dc59c --- /dev/null +++ b/auth/sms/types/VerifySmsCodeDTO.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2022-2023. . All rights reserved. +// Copyright (c) 2022-2023. . All rights reserved. + +import { + SmsTokenDTO, + isSmsTokenDTO +} from "./SmsTokenDTO"; +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface VerifySmsCodeDTO { + readonly token : SmsTokenDTO; + readonly code : string; +} + +export function createVerifySmsCodeDTO ( + token : SmsTokenDTO, + code : string +) { + return { + token, + code + }; +} + +export function isVerifySmsCodeDTO (value: any): value is VerifySmsCodeDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'token', + 'code' + ]) + && isSmsTokenDTO(value?.token) + && isString(value?.code) + ); +} + +export function stringifyVerifySmsCodeDTO (value: VerifySmsCodeDTO): string { + return `VerifySmsCodeDTO(${value})`; +} + +export function parseVerifySmsCodeDTO (value: any): VerifySmsCodeDTO | undefined { + if ( isVerifySmsCodeDTO(value) ) return value; + return undefined; +} diff --git a/auth/sms/types/VerifySmsTokenDTO.test.ts b/auth/sms/types/VerifySmsTokenDTO.test.ts new file mode 100644 index 0000000..f84f196 --- /dev/null +++ b/auth/sms/types/VerifySmsTokenDTO.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createVerifySmsTokenDTO, isVerifySmsTokenDTO, parseVerifySmsTokenDTO, stringifyVerifySmsTokenDTO } from "./VerifySmsTokenDTO"; + +describe('VerifySmsTokenDTO', () => { + const validSmsTokenDTO = { + token: '123', + sms: '+3587099704', + verified: true, + }; + + const validDTO = { + token: validSmsTokenDTO, + }; + + describe('createVerifySmsTokenDTO', () => { + it('should return a valid VerifySmsTokenDTO', () => { + const result = createVerifySmsTokenDTO(validSmsTokenDTO); + expect(result).toEqual(validDTO); + }); + }); + + describe('isVerifySmsTokenDTO', () => { + it('should return true when the value is a valid VerifySmsTokenDTO', () => { + const result = isVerifySmsTokenDTO(validDTO); + expect(result).toBe(true); + }); + + it('should return false when the value is not a valid VerifySmsTokenDTO', () => { + const result = isVerifySmsTokenDTO({token: '123'}); + expect(result).toBe(false); + }); + }); + + describe('stringifyVerifySmsTokenDTO', () => { + it('should return a string representation of the VerifySmsTokenDTO', () => { + const result = stringifyVerifySmsTokenDTO(validDTO); + expect(result).toBe('VerifySmsTokenDTO([object Object])'); + }); + }); + + describe('parseVerifySmsTokenDTO', () => { + it('should return a VerifySmsTokenDTO when given a valid object', () => { + const result = parseVerifySmsTokenDTO(validDTO); + expect(result).toEqual(validDTO); + }); + + it('should return undefined when given an invalid object', () => { + const result = parseVerifySmsTokenDTO({token: '123'}); + expect(result).toBeUndefined(); + }); + }); + +}); diff --git a/auth/sms/types/VerifySmsTokenDTO.ts b/auth/sms/types/VerifySmsTokenDTO.ts new file mode 100644 index 0000000..2e5bc54 --- /dev/null +++ b/auth/sms/types/VerifySmsTokenDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022-2023. . All rights reserved. +// Copyright (c) 2022-2023. . All rights reserved. + +import { + SmsTokenDTO, + isSmsTokenDTO +} from "./SmsTokenDTO"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface VerifySmsTokenDTO { + readonly token : SmsTokenDTO; +} + +export function createVerifySmsTokenDTO ( + token: SmsTokenDTO +) : VerifySmsTokenDTO { + return {token}; +} + +export function isVerifySmsTokenDTO (value: unknown): value is VerifySmsTokenDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'token' + ]) + && isSmsTokenDTO(value?.token) + ); +} + +export function stringifyVerifySmsTokenDTO (value: VerifySmsTokenDTO): string { + return `VerifySmsTokenDTO(${value})`; +} + +export function parseVerifySmsTokenDTO (value: any): VerifySmsTokenDTO | undefined { + if ( isVerifySmsTokenDTO(value) ) return value; + return undefined; +} diff --git a/cmd/ai/HgAiCommandService.ts b/cmd/ai/HgAiCommandService.ts new file mode 100644 index 0000000..9bdc063 --- /dev/null +++ b/cmd/ai/HgAiCommandService.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CommandExitStatus } from "../types/CommandExitStatus"; + +/** + * @TODO Improve code + * @TODO Rewrite code with correct style + * @TODO Rewrite code with using idiomatic constructs + * @TODO Simplify code + * @TODO Explore alternatives + * @TODO Write documentation + */ +export interface HgAiCommandService { + + setIterations(value: number | undefined) : void; + setLanguage(value: string | undefined) : void; + setSuffix(value: string | undefined) : void; + setModel(value: string | undefined) : void; + setStop(value: string | undefined) : void; + setUser(value: string | undefined) : void; + setLogProbs(value: number | undefined) : void; + setBestOf(value: number | undefined) : void; + setPresencePenalty(value: number | undefined) : void; + setFrequencyPenalty(value: number | undefined) : void; + setEcho(value: boolean | undefined) : void; + setN(value: number | undefined) : void; + setTopP(value: number | undefined) : void; + setTemperature(value: number | undefined) : void; + setMaxTokens(value: number | undefined) : void; + + getIterations(): number | undefined; + getLanguage(): string | undefined; + getSuffix(): string | undefined; + getModel(): string | undefined; + getStop(): string | undefined; + getUser(): string | undefined; + getLogProbs(): number | undefined; + getBestOf(): number | undefined; + getPresencePenalty(): number | undefined; + getFrequencyPenalty(): number | undefined; + getEcho(): boolean | undefined; + getN(): number | undefined; + getTopP(): number | undefined; + getTemperature(): number | undefined; + getMaxTokens(): number | undefined; + + /** + * The main command line handler + * + * Example 1: `main(['completion', ...])` will call `completion([...])` + * Example 2: `main(['comp', ...])` will call `completion([...])` + * Example 3: `main(['c', ...])` will call `completion([...])` + * Example 4: `main(['edit', ...])` will call `edit([...])` + * Example 5: `main(['e', ...])` will call `edit([...])` + * Example 6: `main(['test', ...])` will call `test([...])` + * Example 7: `main(['t', ...])` will call `test([...])` + * + * @param args + */ + main (args: readonly string[]) : Promise; + + /** + * OpenAI completion + * + * Example 1: `completion(['Say this is a test'])` will print out `"\n\nThis is indeed a test"` + * + * @param args + */ + completion (args: readonly string[]) : Promise; + + /** + * OpenAI edit action + * + * Example 1: `edit(['Fix the spelling mistakes', 'What day of the wek is it?'])` + * will print out `"What day of the week is it?"`. + * + * Example 2: `edit(['Write a function in python that calculates fibonacci'])` + * will print out Python implementation of fibonacci function. + * + * Example 3: `edit(['Rename the function to fib', 'def fibonacci(num): + * if num <= 1: + * return num + * else: + * return fib(num-1) + fib(num-2) + * print(fibonacci(10))'])` will print out: + * ```python + * def fib(num): + * if num <= 1: + * return num + * else: + * return fib(num-1) + fib(num-2) + * print(fib(10)) + * ``` + * + * @param args + */ + edit (args: readonly string[]) : Promise; + + /** + * Write test cases + * + * Example `writeTests('./FooService.ts')` will print out unit tests for the + * `FooService` written in TypeScript and Jest framework. + * + * Tests should look like: + * + * ```typescript + * describe("Class", () => { + * + * describe("Method", () => { + * + * it('should ...', () => { + * // ... here test implementation ... + * }); + * + * }); + * + * }); + * ``` + * + * @param args + */ + test (args: readonly string[]) : Promise; + + /** + * Writes descriptions about code. + * + * Example `describe('./keys.ts')` will print out description about the code: + * + * ``` + * This TypeScript code is an exported function called "keys" that takes two + * parameters, "value" and "isKey". The "value" parameter is of type "any" + * and the "isKey" parameter is of type "TestCallbackNonStandard". The + * function returns an array of type "T" which is a generic type that extends + * the type "keyof any". + * + * The function starts by checking if the "value" parameter is an array. If + * it is, it uses the "map" function to create an array of indexes from the + * "value" array. It then uses the "filter" function to filter out the + * indexes that pass the "isKey" test. The filtered indexes are then + * returned as an array of type "T". + * + * If the "value" parameter is an object, the function uses the + * "Reflect.ownKeys" function to get an array of all the keys of the object. + * It then uses the "filter" function to filter out the keys that pass the + * "isKey" test. The filtered keys are then returned as an array of type "T". + * + * If the "value" parameter is neither an array nor an object, the function + * returns an empty array of type "T". + * ``` + * + * @param args + */ + describe (args: readonly string[]) : Promise; + + + /** + * Write changelog based on git diff output + * + * @param args + */ + changelog (args: readonly string[]) : Promise; + +} diff --git a/cmd/ai/HgAiCommandServiceImpl.test.ts b/cmd/ai/HgAiCommandServiceImpl.test.ts new file mode 100644 index 0000000..8e2db75 --- /dev/null +++ b/cmd/ai/HgAiCommandServiceImpl.test.ts @@ -0,0 +1,379 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from "@jest/globals"; +import { HgAiCommandServiceImpl } from "./HgAiCommandServiceImpl"; +import { MockOpenAiClient } from "../../mocks/MockOpenAiClient"; +import { createOpenAiCompletionResponseDTO } from "../../openai/dto/OpenAiCompletionResponseDTO"; +import { createOpenAiEditResponseDTO, OpenAiEditResponseDTO } from "../../openai/dto/OpenAiEditResponseDTO"; +import { createOpenAiCompletionResponseChoice } from "../../openai/dto/OpenAiCompletionResponseChoice"; +import { createOpenAiCompletionResponseUsage } from "../../openai/dto/OpenAiCompletionResponseUsage"; +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { createOpenAiEditResponseChoice } from "../../openai/dto/OpenAiEditResponseChoice"; +import { createOpenAiEditResponseUsage } from "../../openai/dto/OpenAiEditResponseUsage"; +import { OpenAiErrorDTO } from "../../openai/dto/OpenAiErrorDTO"; +import { writeTestsInstruction } from "../../openai/instructions/writeTestsInstruction"; +import { exampleTypeScriptTest } from "../../openai/instructions/exampleTypeScriptTest"; +import { LogLevel } from "../../types/LogLevel"; + +describe("HgAiCommandServiceImpl", () => { + let service: HgAiCommandServiceImpl; + let client: MockOpenAiClient; + let consoleSpy : jest.SpiedFunction; + let warnConsoleSpy : jest.SpiedFunction; + let errorConsoleSpy : jest.SpiedFunction; + + beforeEach(() => { + HgAiCommandServiceImpl.setLogLevel(LogLevel.NONE); + client = new MockOpenAiClient(); + service = new HgAiCommandServiceImpl(client); + consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + warnConsoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + errorConsoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + warnConsoleSpy.mockRestore(); + errorConsoleSpy.mockRestore(); + }); + + describe("setModel", () => { + it("should set the model property correctly", () => { + const model = "text-davinci-002"; + service.setModel(model); + expect(service["_model"]).toEqual(model); + }); + }); + + describe("setStop", () => { + it("should set the stop property correctly", () => { + const stop = "."; + service.setStop(stop); + expect(service["_stop"]).toEqual(stop); + }); + }); + + describe("setUser", () => { + it("should set the user property correctly", () => { + const user = "user1"; + service.setUser(user); + expect(service["_user"]).toEqual(user); + }); + }); + + describe("setLogProbs", () => { + it("should set the logProbs property correctly", () => { + const logProbs = 10; + service.setLogProbs(logProbs); + expect(service["_logProbs"]).toEqual(logProbs); + }); + }); + + describe("setBestOf", () => { + it("should set the bestOf property correctly", () => { + const bestOf = 2; + service.setBestOf(bestOf); + expect(service["_bestOf"]).toEqual(bestOf); + }); + }); + + describe("setPresencePenalty", () => { + it("should set the presencePenalty property correctly", () => { + const presencePenalty = 0.5; + service.setPresencePenalty(presencePenalty); + expect(service["_presencePenalty"]).toEqual(presencePenalty); + }); + }); + + describe("setFrequencyPenalty", () => { + it("should set the 'frequencyPenalty' property correctly", () => { + const frequencyPenalty = 0.2; + service.setFrequencyPenalty(frequencyPenalty); + expect(service["_frequencyPenalty"]).toEqual(frequencyPenalty); + }); + }); + + describe("setEcho", () => { + it("should set the 'echo' property correctly", () => { + const value = true; + service.setEcho(value); + expect(service["_echo"]).toEqual(value); + }); + }); + + describe("setN", () => { + it("should set the 'n' property correctly", () => { + const value = 1; + service.setN(value); + expect(service["_n"]).toEqual(value); + }); + }); + + describe("setTopP", () => { + it("should set the 'topP' property correctly", () => { + const value = 0.2; + service.setTopP(value); + expect(service["_topP"]).toEqual(value); + }); + }); + + describe("setTemperature", () => { + it("should set the 'temperature' property correctly", () => { + const value = 0.2; + service.setTemperature(value); + expect(service["_temperature"]).toEqual(value); + }); + }); + + describe("setMaxTokens", () => { + it("should set the 'maxTokens' property correctly", () => { + const value = 2000; + service.setMaxTokens(value); + expect(service["_maxTokens"]).toEqual(value); + }); + }); + + describe("completion", () => { + + it("should call the getCompletion method on the OpenAiClient with the correct parameters", async () => { + const spy = jest.spyOn(client, "getCompletion"); + const prompt = "The quick brown fox jumps over the lazy dog."; + const model = "text-davinci-002"; + const maxTokens = 100; + const temperature = 0.5; + const topP = 0.8; + const frequencyPenalty = 0.2; + const presencePenalty = 0.9; + service.setModel(model); + service.setMaxTokens(maxTokens); + service.setTemperature(temperature); + service.setTopP(topP); + service.setFrequencyPenalty(frequencyPenalty); + service.setPresencePenalty(presencePenalty); + await service.completion([prompt]); + expect(spy).toHaveBeenCalledWith( + prompt, + model, + maxTokens, + temperature, + topP, + frequencyPenalty, + presencePenalty + ); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("should return the correct value from the getCompletion method", async () => { + + const response = createOpenAiCompletionResponseDTO( + "abc123", + 'test-object', + 12345678, + 'text-davinci-002', + [ + createOpenAiCompletionResponseChoice( + 'The quick brown fox jumps over the lazy dog and goes back to his den.', + 0, + null, + 'stop', + ) + ], + createOpenAiCompletionResponseUsage( + 100, + 100, + 100 + ) + ); + + jest.spyOn(client, "getCompletion").mockResolvedValue(response); + + const resultCode = await service.completion(["The quick brown fox jumps over the lazy dog."]); + + expect(resultCode).toEqual(CommandExitStatus.OK); + + expect(consoleSpy).toHaveBeenCalledWith("The quick brown fox jumps over the lazy dog and goes back to his den."); + expect(consoleSpy).toHaveBeenCalledTimes(1); + + }); + + }); + + describe("edit", () => { + + it("should call the getEdit method on the OpenAiClient", async () => { + const spy = jest.spyOn(client, "getEdit"); + const input = "The quick brown fox jumps over the lazy dog."; + const instruction = "Change 'quick' to 'fast' and 'lazy' to 'sleepy'"; + await service.edit([instruction, input]); + expect(spy).toHaveBeenCalledWith(instruction, input); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("should call the getEdit method on the OpenAiClient with the correct parameters", async () => { + const spy = jest.spyOn(client, "getEdit"); + const input = "The quick brown fox jumps over the lazy dog."; + const instruction = "Change 'quick' to 'fast' and 'lazy' to 'sleepy'"; + const model = "text-davinci-002"; + const n = 100; + const temperature = 0.5; + const topP = 0.8; + const frequencyPenalty = 0.2; + const presencePenalty = 0.9; + + service.setModel(model); + service.setN(n); + service.setTemperature(temperature); + service.setTopP(topP); + service.setFrequencyPenalty(frequencyPenalty); + service.setPresencePenalty(presencePenalty); + + await service.edit([instruction, input]); + expect(spy).toHaveBeenCalledWith( + instruction, + input, + model, + n, + temperature, + topP + ); + expect(spy).toHaveBeenCalledTimes(1); + + }); + + it("should return the correct value from the getEdit method", async () => { + const response: OpenAiEditResponseDTO = createOpenAiEditResponseDTO( + 'test-object', + 1234567, + [ + createOpenAiEditResponseChoice( + 'The fast brown fox jumps over the sleepy dog.', + 0, + null, + 'stop' + ), + createOpenAiEditResponseChoice( + 'The quick brown fox jumps over the sleepy dog.', + 1, + null, + 'stop' + ) + ], + createOpenAiEditResponseUsage( + 100, + 100, + 100 + ) + ); + jest.spyOn(client, "getEdit").mockResolvedValue(response); + const resultCode = await service.edit( + [ + "The quick brown fox jumps over the lazy dog.", + "Change 'quick' to 'fast' and 'lazy' to 'sleepy'" + ] + ); + + expect(resultCode).toEqual(CommandExitStatus.OK); + + expect(consoleSpy).toHaveBeenCalledWith("The fast brown fox jumps over the sleepy dog."); + expect(consoleSpy).toHaveBeenCalledTimes(1); + + }); + + }); + + describe("test", () => { + + it("should call the getEdit method on the OpenAiClient with the correct parameters", async () => { + const spyGetEdit = jest.spyOn(client, "getEdit"); + + const input = `export class FooService { + + private _foo : readonly Foo[]; + + public constructor () { + this._foo = []; + } + + public addFoo (name : string) : Foo { + const obj = createFoo(name); + this._foo.push(foo); + EventService.triggerEvent("added:foo", name); + return createFoo(obj.name); + } + +} +`; + + const examples = exampleTypeScriptTest('ExampleClassName', 'exampleMethodName', 'should ...'); + const instruction = writeTestsInstruction('TypeScript', 'Jest', examples); + + await service.test([input]); + + expect(spyGetEdit).toHaveBeenCalledWith( + instruction, + input, + "code-davinci-edit-001", + 1, + 0 + ); + expect(spyGetEdit).toHaveBeenCalledTimes(1); + + }); + + it("should return CommandExitStatus.OK if the getEdit method returns a valid response", async () => { + const spy = jest.spyOn(client, "getEdit").mockResolvedValue(createOpenAiEditResponseDTO( + 'test-object', + 1234567, + [ + createOpenAiEditResponseChoice( + 'The quick brown fox jumps over the lazy dog.', + 0, + null, + 'stop' + ), + createOpenAiEditResponseChoice( + 'The quick brown fox jumps over the lazy cat.', + 1, + null, + 'stop' + ) + ], + createOpenAiEditResponseUsage( + 100, + 100, + 100 + ) + )); + + // Act + const result = await service.test(["The quick brown fox jumps over the lazy dog."]); + + // Assert + expect(result).toEqual(CommandExitStatus.OK); + expect(spy).toHaveBeenCalledTimes(1); + + }); + + it("should not return CommandExitStatus.OK if the getEdit method returns an error response", async () => { + const error: OpenAiErrorDTO = { + error: { + message: 'An error occurred', + type: 'unknown_error' + } + }; + jest.spyOn(client, "getEdit").mockRejectedValue(error); + const resultCode = await service.test( + [ + 'instruction', + ] + ); + expect(resultCode).not.toEqual(CommandExitStatus.OK); + }); + + }); + + describe.skip("main", () => { + // TODO: + }); + +}); diff --git a/cmd/ai/HgAiCommandServiceImpl.ts b/cmd/ai/HgAiCommandServiceImpl.ts new file mode 100644 index 0000000..fdfdf29 --- /dev/null +++ b/cmd/ai/HgAiCommandServiceImpl.ts @@ -0,0 +1,853 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { HgAiCommandService } from "./HgAiCommandService"; +import { OpenAiClient } from "../../openai/OpenAiClient"; +import { isOpenAiErrorDTO } from "../../openai/dto/OpenAiErrorDTO"; +import { OpenAiErrorDTO } from "../../openai/dto/OpenAiErrorDTO"; +import { map } from "../../functions/map"; +import { OpenAiEditResponseDTO } from "../../openai/dto/OpenAiEditResponseDTO"; +import { isOpenAiEditResponseChoice, OpenAiEditResponseChoice } from "../../openai/dto/OpenAiEditResponseChoice"; +import { OpenAiCompletionResponseDTO } from "../../openai/dto/OpenAiCompletionResponseDTO"; +import { isOpenAiCompletionResponseChoice, OpenAiCompletionResponseChoice } from "../../openai/dto/OpenAiCompletionResponseChoice"; +import { readFileSync, existsSync } from "fs"; +import { OpenAiModel } from "../../openai/types/OpenAiModel"; +import { filter } from "../../functions/filter"; +import { OpenAiError } from "../../openai/dto/OpenAiError"; +import { writeTestsInstruction } from "../../openai/instructions/writeTestsInstruction"; +import { exampleTypeScriptTest } from "../../openai/instructions/exampleTypeScriptTest"; +import { documentCodeInstruction } from "../../openai/instructions/documentCodeInstruction"; +import { describeCodeInstruction } from "../../openai/instructions/describeCodeInstruction"; +import { LogService } from "../../LogService"; +import { aiDocumentCodeInstruction } from "../../openai/instructions/aiDocumentCodeInstruction"; +import { changelogInstruction } from "../../openai/instructions/changelogInstruction"; +import { diffReader } from "../../functions/diffReader"; +import { reduce } from "../../functions/reduce"; +import { LogLevel } from "../../types/LogLevel"; + +const DEFAULT_LANGUAGE = 'TypeScript'; + +const DEFAULT_DESC_MODEL = OpenAiModel.DAVINCI; +const DEFAULT_DESC_MAX_TOKENS = 3600; +const DEFAULT_DESC_TEMPERATURE = 0.1; +const DEFAULT_DESC_TOP_P = 0.9; + +const DEFAULT_CHANGELOG_MODEL = OpenAiModel.DAVINCI; +const DEFAULT_CHANGELOG_MAX_TOKENS = 2000; +const DEFAULT_CHANGELOG_TEMPERATURE = 0.1; +const DEFAULT_CHANGELOG_TOP_P = 0.9; + +const DEFAULT_TEST_FRAMEWORK = 'Jest'; +const DEFAULT_TEST_CLASS_NAME = 'ExampleClassName'; +const DEFAULT_TEST_METHOD_NAME = 'exampleMethodName'; +const DEFAULT_TEST_TEST_NAME = 'should ...'; +const DEFAULT_TEST_MODEL = OpenAiModel.DAVINCI_EDIT_CODE; +const DEFAULT_TEST_N = 1; +const DEFAULT_TEST_TEMPERATURE = 0; + +const DEFAULT_DOC_FRAMEWORK = 'JSDoc'; +const DEFAULT_DOC_MODEL = OpenAiModel.DAVINCI_EDIT_CODE; +const DEFAULT_DOC_N = 1; +const DEFAULT_DOC_TEMPERATURE = 0.1; +const DEFAULT_DOC_TOP_P = 0.9; +const DEFAULT_DOC_ITERATIONS = 4; + +const LOG = LogService.createLogger('HgAiCommandServiceImpl'); + +export class HgAiCommandServiceImpl implements HgAiCommandService { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private _iterations : number | undefined; + private _client: OpenAiClient; + private _suffix: string | undefined; + private _language: string | undefined; + private _model: OpenAiModel | string | undefined; + private _echo: boolean | undefined; + private _user: string | undefined; + private _stop: string | undefined; // String or Array of strings + private _logProbs: number | undefined; // Integer + private _bestOf: number | undefined; // Integer + private _maxTokens: number | undefined; // Integer + private _n: number | undefined; // Integer + private _frequencyPenalty: number | undefined; // Float + private _presencePenalty: number | undefined; // Float + private _topP: number | undefined; // Float + private _temperature: number | undefined; // Float + + /** + * Construct a command line service for `hg ai` command + * + * @param client + */ + public constructor ( + client: OpenAiClient + ) { + this._client = client; + } + + public setIterations (value : number | undefined) : void { + this._iterations = value; + } + + public getIterations () : number | undefined { + return this._iterations; + } + + public setLanguage (value: string|undefined): void { + this._language = value; + } + + public getLanguage (): string|undefined { + return this._language; + } + + public setSuffix (value: string|undefined): void { + this._suffix = value; + } + + public getSuffix (): string|undefined { + return this._suffix; + } + + /** + * Sets the model to use for the next call to OpenAI API + * @param value + */ + public setModel (value: string|undefined): void { + this._model = value; + } + + /** + * Gets the model to use for the next call to OpenAI API + */ + public getModel (): string|undefined { + return this._model; + } + + /** + * Sets the stop option for the next call to OpenAI API + * @param value + */ + public setStop (value: string|undefined): void { + this._stop = value; + } + + /** + * Gets the stop option for the next call to OpenAI API + */ + public getStop (): string|undefined { + return this._stop; + } + + /** + * Sets the user option for the next call to OpenAI API + * @param value + */ + public setUser (value: string|undefined): void { + this._user = value; + } + + /** + * Gets the user option for the next call to OpenAI API + */ + public getUser (): string|undefined { + return this._user; + } + + /** + * Sets the logProbs option for the next call to OpenAI API + * @param value + */ + public setLogProbs (value: number | undefined): void { + this._logProbs = value; + } + + /** + * Gets the logProbs option for the next call to OpenAI API + */ + public getLogProbs (): number | undefined { + return this._logProbs; + } + + /** + * Sets the best of option for the next call to OpenAI API + * @param value + */ + public setBestOf (value: number | undefined): void { + this._bestOf = value; + } + + /** + * Gets the best of option for the next call to OpenAI API + */ + public getBestOf (): number | undefined { + return this._bestOf; + } + + /** + * Sets the presence penalty option for the next call to OpenAI API + * @param value + */ + public setPresencePenalty (value: number | undefined): void { + this._presencePenalty = value; + } + + /** + * Gets the presence penalty option for the next call to OpenAI API + */ + public getPresencePenalty (): number | undefined { + return this._presencePenalty; + } + + /** + * Sets the frequency penalty property for the next call to OpenAI API + * @param value + */ + public setFrequencyPenalty (value: number | undefined): void { + this._frequencyPenalty = value; + } + + /** + * Gets the frequency penalty property for the next call to OpenAI API + */ + public getFrequencyPenalty (): number | undefined { + return this._frequencyPenalty; + } + + /** + * Sets the echo property for the next call to OpenAI API + * @param value + */ + public setEcho (value: boolean): void { + this._echo = value; + } + + /** + * Gets the echo property for the next call to OpenAI API + */ + public getEcho (): boolean | undefined { + return this._echo; + } + + /** + * Sets the n property for the next call to OpenAI API + * @param value + */ + public setN (value: number | undefined): void { + this._n = value; + } + + /** + * Gets the n property for the next call to OpenAI API + */ + public getN (): number | undefined { + return this._n; + } + + /** + * Sets the topP property for the next call to OpenAI API + * + * @param value + */ + public setTopP (value: number | undefined): void { + this._topP = value; + } + + /** + * Gets the topP property for the next call to OpenAI API + */ + public getTopP (): number | undefined { + return this._topP; + } + + /** + * Sets the temperature property for next call to OpenAI API + * @param value + */ + public setTemperature (value: number | undefined): void { + this._temperature = value; + } + + /** + * Gets the temperature property for next call to OpenAI API + */ + public getTemperature (): number | undefined { + return this._temperature; + } + + /** + * Set's the max tokens property for next call to OpenAI API + * + * @param value + */ + public setMaxTokens (value: number | undefined): void { + this._maxTokens = value; + } + + /** + * Get's the max tokens property for next call to OpenAI API + */ + public getMaxTokens (): number | undefined { + return this._maxTokens; + } + + /** + * The main command line handler. + * + * It is intended to be called when user calls `hg ai ...` with the remaining + * arguments as `args` option. + * + * Example 1: `main(['completion', ...])` will call `completion([...])` + * Example 2: `main(['comp', ...])` will call `completion([...])` + * Example 3: `main(['c', ...])` will call `completion([...])` + * Example 4: `main(['edit', ...])` will call `edit([...])` + * Example 5: `main(['e', ...])` will call `edit([...])` + * Example 6: `main(['test', ...])` will call `test([...])` + * Example 7: `main(['t', ...])` will call `test([...])` + * + * @param args + */ + public async main (args: readonly string[]): Promise { + if ( args.length === 0 ) { + return CommandExitStatus.USAGE; + } + try { + const [ arg, ...freeArgs ] = args; + switch (arg) { + + case 'c': + case 'comp': + case 'completion': + return await this.completion(freeArgs); + + case 'e': + case 'edit': + return await this.edit(freeArgs); + + case 't': + case 'test': + return await this.test(freeArgs); + + case 'd': + case 'doc': + case 'document': + return await this.document(freeArgs); + + case 'dd': + case 'desc': + case 'describe': + return await this.describe(freeArgs); + + case 'cl': + case 'changelog': + return await this.changelog(freeArgs); + + } + console.error(`Unknown command: ${arg}`); + return CommandExitStatus.COMMAND_NOT_FOUND; + } catch (err) { + const body: unknown | OpenAiErrorDTO = (err as any)?.body; + if ( isOpenAiErrorDTO(body) ) { + console.error(`ERROR: [${body.error.type}] ${body.error.message}`); + return CommandExitStatus.GENERAL_ERRORS; + } else { + throw err; + } + } + } + + /** + * Write test cases + * + * Example `writeTests('./FooService.ts')` will print out unit tests for the + * `FooService` written in TypeScript and Jest framework. + * + * Tests should look like: + * + * ```typescript + * describe("Class", () => { + * + * describe("Method", () => { + * + * it('should ...', () => { + * // ... here test implementation ... + * }); + * + * }); + * + * }); + * ``` + * + * @param args + */ + public async test (args: readonly string[]): Promise { + + if (this._model === undefined) this.setModel(DEFAULT_TEST_MODEL); + if (this._n === undefined) this.setN(DEFAULT_TEST_N); + if (this._temperature === undefined) this.setTemperature(DEFAULT_TEST_TEMPERATURE); + + // TODO: Add automatic detection for class names, etc. + + const language = this._language ?? DEFAULT_LANGUAGE; + LOG.debug(`test: language: `, language); + + const examples = exampleTypeScriptTest( + DEFAULT_TEST_CLASS_NAME, + DEFAULT_TEST_METHOD_NAME, + DEFAULT_TEST_TEST_NAME + ); + const instruction = writeTestsInstruction(language, DEFAULT_TEST_FRAMEWORK, examples); + return this.edit([ instruction, ...args ]); + } + + /** + * Documents TypeScript code using JSDoc + * @param args + */ + public async document (args: readonly string[]): Promise { + + if (args.length === 0) { + return CommandExitStatus.USAGE; + } + LOG.debug(`document: args: `, args); + + const language = this._language ?? DEFAULT_LANGUAGE; + LOG.debug(`document: language: `, language); + + const framework = DEFAULT_DOC_FRAMEWORK; + LOG.debug(`document: framework: `, framework); + + if (this._model === undefined) this.setModel(DEFAULT_DOC_MODEL); + if (this._n === undefined) this.setN(DEFAULT_DOC_N); + if (this._temperature === undefined) this.setTemperature(DEFAULT_DOC_TEMPERATURE); + if (this._topP === undefined) this.setTopP(DEFAULT_DOC_TOP_P); + if (this._iterations === undefined) this.setIterations(DEFAULT_DOC_ITERATIONS); + + const describePrompt = aiDocumentCodeInstruction( + language, + framework, + false, + (await this._populateFiles(args)).join('\n\n') + ); + LOG.debug(`document: aiDocumentPrompt: `, describePrompt); + + const result: OpenAiCompletionResponseDTO = await this._client.getCompletion( + describePrompt, + DEFAULT_DESC_MODEL, + DEFAULT_DESC_MAX_TOKENS, + DEFAULT_DESC_TEMPERATURE, + DEFAULT_DESC_TOP_P, + this._frequencyPenalty, + this._presencePenalty + ); + LOG.debug(`result = `, result); + + const { + textChoice, + hasText + } = this._parseCompletionResponse(result); + + const instruction = documentCodeInstruction(language, framework); + + if (args.length === 0) { + throw new TypeError('No args detected anymore'); + } + + if (hasText) { + return this.edit( + [ + instruction + (textChoice?.text ? '\n\n' + textChoice?.text : ''), + ...args + ] + ); + } + + return this.edit([ instruction, ...args ]); + } + + /** + * Writes descriptions about code. + * + * Example `describe('./keys.ts')` will print out description about the code: + * + * ``` + * This TypeScript code is an exported function called "keys" that takes two + * parameters, "value" and "isKey". The "value" parameter is of type "any" + * and the "isKey" parameter is of type "TestCallbackNonStandard". The + * function returns an array of type "T" which is a generic type that extends + * the type "keyof any". + * + * The function starts by checking if the "value" parameter is an array. If + * it is, it uses the "map" function to create an array of indexes from the + * "value" array. It then uses the "filter" function to filter out the + * indexes that pass the "isKey" test. The filtered indexes are then + * returned as an array of type "T". + * + * If the "value" parameter is an object, the function uses the + * "Reflect.ownKeys" function to get an array of all the keys of the object. + * It then uses the "filter" function to filter out the keys that pass the + * "isKey" test. The filtered keys are then returned as an array of type "T". + * + * If the "value" parameter is neither an array nor an object, the function + * returns an empty array of type "T". + * ``` + * + * @param args + */ + public async describe (args: readonly string[]) : Promise { + + if (args.length === 0) { + return CommandExitStatus.USAGE; + } + + LOG.debug(`describe: args: `, args); + + if (this._model === undefined) this.setModel(DEFAULT_DESC_MODEL); + if (this._maxTokens === undefined) this.setMaxTokens(DEFAULT_DESC_MAX_TOKENS); + if (this._temperature === undefined) this.setTemperature(DEFAULT_DESC_TEMPERATURE); + if (this._topP === undefined) this.setTopP(DEFAULT_DESC_TOP_P); + + const language = this._language ?? DEFAULT_LANGUAGE; + LOG.debug(`describe: language: `, language); + + let verbose = false; + if (args[0] === 'verbose') { + const [, ...restArgs] = args; + args = restArgs; + verbose = true; + if (args.length === 0) { + return CommandExitStatus.USAGE; + } + LOG.debug(`describe: params: `, args, verbose); + } + + const instruction = describeCodeInstruction(language, verbose); + LOG.debug(`describe: instruction: `, instruction); + + return this.completion([ instruction, ...args ]); + + } + + /** + * Write changelog based on git diff output + * + * @param args + */ + public async changelog (args: readonly string[]) : Promise { + + if (args.length === 0) { + return CommandExitStatus.USAGE; + } + + LOG.debug(`changelog: args: `, args); + + if (this._model === undefined) this.setModel(DEFAULT_CHANGELOG_MODEL); + if (this._maxTokens === undefined) this.setMaxTokens(DEFAULT_CHANGELOG_MAX_TOKENS); + if (this._temperature === undefined) this.setTemperature(DEFAULT_CHANGELOG_TEMPERATURE); + if (this._topP === undefined) this.setTopP(DEFAULT_CHANGELOG_TOP_P); + + const instruction = changelogInstruction(); + LOG.debug(`changelog: instruction: "${instruction}"`); + + // FIXME: Write a buffering function to do this + const aiChunkSize = 6000; // 4097 max tokens (prompt + completion) + LOG.debug(`changelog: aiChunkSize size of "${aiChunkSize}"`); + + let nextAiChunk = ''; + const diffString = (await this._populateFiles(args)).join('\n'); + LOG.debug(`changelog: diff size of "${diffString.length}"`); + if (diffString.length === 0) return CommandExitStatus.OK; + + // FIXME: Add this as it's own function and unit test + const diffChunks = reduce( + diffReader( diffString ), + (chunks: string[], chunk: string) => { + if (chunk.length > aiChunkSize) { + return [ + ...chunks, + ...splitString(chunk, aiChunkSize) + ]; + } + return [...chunks, chunk]; + }, + [] + ); + LOG.debug(`changelog: chunks size of "${diffChunks.length}"`); + + if (diffChunks.length === 0) return CommandExitStatus.OK; + + do { + const chunk : string | undefined = diffChunks.shift(); + if (chunk !== undefined) { + LOG.debug(`changelog: processing chunk size of "${chunk.length}"`); + if ( (nextAiChunk.length !== 0) && (nextAiChunk.length + chunk.length > aiChunkSize) ) { + LOG.debug(`changelog: Sending "${nextAiChunk.length}" characters to completion`); + await this.completion([ instruction, nextAiChunk ]); + nextAiChunk = ''; + } + nextAiChunk += chunk; + } + if (nextAiChunk.length >= aiChunkSize) { + LOG.debug(`changelog: Sending ${nextAiChunk.length} characters to completion`); + await this.completion([ instruction, nextAiChunk ]); + nextAiChunk = ''; + } + } while (diffChunks.length); + + return CommandExitStatus.OK; + + } + + /** + * OpenAI edit action + * + * Example 1: `edit(['Fix the spelling mistakes', 'What day of the wek is it?'])` + * will print out `"What day of the week is it?"`. + * + * Example 2: `edit(['Write a function in python that calculates fibonacci'])` + * will print out Python implementation of fibonacci function. + * + * Example 3: `edit(['Rename the function to fib', 'def fibonacci(num): + * if num <= 1: + * return num + * else: + * return fib(num-1) + fib(num-2) + * print(fibonacci(10))'])` will print out: + * ```python + * def fib(num): + * if num <= 1: + * return num + * else: + * return fib(num-1) + fib(num-2) + * print(fib(10)) + * ``` + * + * @param args + */ + public async edit (args: readonly string[]): Promise { + if ( args.length === 0 ) { + return CommandExitStatus.USAGE; + } + + const [ instruction, ...freeArgs ] = await this._populateFiles(args); + const input: string = freeArgs.join('\n\n'); + + const model: string | undefined = this._model; + const temperature: number | undefined = this._temperature; + const topP: number | undefined = this._topP; + const n: number | undefined = this._n; + + const hasModel: boolean = model !== undefined; + const hasN: boolean = n !== undefined; + const hasTemperature: boolean = temperature !== undefined; + const hasTopP: boolean = topP !== undefined; + + try { + + const result: OpenAiEditResponseDTO = await (hasTopP + ? this._client.getEdit(instruction, input, model, n, temperature, topP) + : (hasTemperature + ? this._client.getEdit(instruction, input, model, n, temperature) + : (hasN + ? this._client.getEdit(instruction, input, model, n) + : ( + hasModel + ? this._client.getEdit(instruction, input, model) + : this._client.getEdit(instruction, input) + ) + ) + ) + ); + + const errorChoices = filter( + result.choices, + (result: OpenAiEditResponseChoice | OpenAiError): boolean => { + return !isOpenAiEditResponseChoice(result); + } + ); + + const textChoices: OpenAiEditResponseChoice[] = filter( + result.choices, + (item: OpenAiEditResponseChoice | OpenAiError): boolean => { + return isOpenAiEditResponseChoice(item); + } + ) as OpenAiEditResponseChoice[]; + + const firstText = textChoices.shift(); + const hasText = firstText !== undefined; + const hasErrors = !!errorChoices.length; + const hasAlternativeTexts = !!textChoices.length; + + if ( hasText ) { + console.log(firstText?.text ?? ''); + } + + if ( hasAlternativeTexts ) { + console.warn(`Alternative choices: ${JSON.stringify(textChoices, null, 2)}`); + } + + if ( hasErrors ) { + console.error(`Other items detected: ${JSON.stringify(errorChoices, null, 2)}`); + } + + return (!hasErrors && hasText) ? CommandExitStatus.OK : CommandExitStatus.GENERAL_ERRORS; + + } catch (err) { + if (isOpenAiErrorDTO(err)) { + console.error(`Error: [${err.error.type}]: ${err.error.message}`); + return CommandExitStatus.GENERAL_ERRORS; + } else { + throw err; + } + } + + } + + /** + * OpenAI completion + * + * Example 1: `completion(['Say this is a test'])` will print out `"\n\nThis is indeed a test"` + * + * @param args + */ + public async completion (args: readonly string[]): Promise { + if ( args.length === 0 ) { + return CommandExitStatus.USAGE; + } + LOG.debug(`args = `, args); + + const prompt: string = (await this._populateFiles(args)).join('\n\n'); + LOG.debug(`prompt = "${prompt}"`); + + try { + + const result: OpenAiCompletionResponseDTO = await this._client.getCompletion( + prompt, + this._model, + this._maxTokens, + this._temperature, + this._topP, + this._frequencyPenalty, + this._presencePenalty + ); + LOG.debug(`result = `, result); + + const { + errorChoices, + alternativeTexts, + textChoice, + hasText, + hasErrors, + hasAlternativeTexts + } = this._parseCompletionResponse(result); + + if ( hasText ) { + if (textChoice?.finish_reason === 'stop') { + console.log(textChoice?.text ?? ''); + } else { + console.warn(`Warning! Partial response: "${textChoice?.text ?? ''}"`); + console.error(`Error: Please increase "maxTokens" property to get complete response.`); + return CommandExitStatus.GENERAL_ERRORS; + } + } + + if ( hasAlternativeTexts ) { + console.warn(`Alternative choices: ${JSON.stringify(alternativeTexts, null, 2)}`); + } + + if ( hasErrors ) { + console.error(`Other items detected: ${JSON.stringify(errorChoices, null, 2)}`); + } + + return (!hasErrors && hasText) ? CommandExitStatus.OK : CommandExitStatus.GENERAL_ERRORS; + + } catch (err) { + if (isOpenAiErrorDTO(err)) { + console.error(`Error: [${err.error.type}]: ${err.error.message}`); + return CommandExitStatus.GENERAL_ERRORS; + } else { + throw err; + } + } + } + + + /** + * Loop through arguments and if the argument exists on the file system, + * read it and return the content as the argument instead. + * + * @param list + * @private + * @fixme Change to asynchronous + */ + private async _populateFiles (list: readonly string[]): Promise { + return map( + list, + (item: string): string => { + if ( existsSync(item) ) { + return readFileSync(item, {encoding: 'utf8'}).toString(); + } else { + return item; + } + } + ); + } + + /** + * + * @param result + * @private + */ + private _parseCompletionResponse ( + result: OpenAiCompletionResponseDTO + ) : { + errorChoices : OpenAiError[], + alternativeTexts : OpenAiCompletionResponseChoice[], + textChoice : OpenAiCompletionResponseChoice | undefined, + hasText : boolean, + hasErrors : boolean, + hasAlternativeTexts : boolean + } { + const errorChoices : OpenAiError[] = filter( + result.choices, + (result: OpenAiCompletionResponseChoice | OpenAiError): boolean => { + return !isOpenAiCompletionResponseChoice(result); + } + ) as OpenAiError[]; + + const alternativeTexts: OpenAiCompletionResponseChoice[] = filter( + result.choices, + (item: OpenAiCompletionResponseChoice | OpenAiError): boolean => { + return isOpenAiCompletionResponseChoice(item); + } + ) as OpenAiCompletionResponseChoice[]; + + const textChoice : OpenAiCompletionResponseChoice | undefined = alternativeTexts.shift(); + const hasText = textChoice !== undefined; + const hasErrors = !!errorChoices.length; + const hasAlternativeTexts = !!alternativeTexts.length; + + return { + errorChoices, + alternativeTexts, + textChoice, + hasText, + hasErrors, + hasAlternativeTexts + } + + } + +} + +// FIXME: Add it's own function and unit test +function splitString(str: string, chunkLength: number): string[] { + let result = []; + for (let i = 0; i < str.length; i += chunkLength) { + result.push(str.slice(i, i + chunkLength)); + } + return result; +} diff --git a/cmd/functions/createAutowiredDefaultCallback.test.ts b/cmd/functions/createAutowiredDefaultCallback.test.ts new file mode 100644 index 0000000..6440f04 --- /dev/null +++ b/cmd/functions/createAutowiredDefaultCallback.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from "@jest/globals"; +import { createAutowiredDefaultCallback } from './createAutowiredDefaultCallback'; +import { AutowireServiceImpl } from "../main/services/AutowireServiceImpl"; +import { AutowireUtils } from "../main/utils/AutowireUtils"; +import { LogLevel } from "../../types/LogLevel"; +import { autowired } from "../main/autowired"; +import { addAutowired } from "../main/addAutowired"; + +describe('createAutowiredDefaultCallback', () => { + + let setNameSpy: jest.SpiedFunction<(...args: any[]) => any>; + let hasNameSpy: jest.SpiedFunction<(...args: any[]) => any>; + let getNameSpy: jest.SpiedFunction<(...args: any[]) => any>; + + // AutowireServiceImpl instance + let autowireService: AutowireServiceImpl; + + beforeAll(() => { + + AutowireUtils.setLogLevel(LogLevel.NONE); + addAutowired.setLogLevel(LogLevel.NONE); + autowired.setLogLevel(LogLevel.NONE); + + // Create a new instance of AutowireServiceImpl + autowireService = AutowireServiceImpl.create(); + // Make getAutowireService return our instance + AutowireServiceImpl.getAutowireService = jest.fn(() => autowireService); + + // Now we can spy on setName, hasName, and getName + setNameSpy = jest.spyOn(autowireService, 'setName'); + hasNameSpy = jest.spyOn(autowireService, 'hasName'); + getNameSpy = jest.spyOn(autowireService, 'getName'); + }); + + beforeEach(() => { + // Clear all mocks before each test + setNameSpy.mockClear(); + hasNameSpy.mockClear(); + getNameSpy.mockClear(); + }); + + it('returns a DefaultValueCallback that returns autowired value', () => { + const autowiredTo = 'autowiredValue'; + const testValue = 'test'; + + // Set up our autowired value + setNameSpy.mockImplementation((name: any, value: any) => { + if (name === autowiredTo) { + hasNameSpy.mockReturnValue(true); + getNameSpy.mockReturnValue(value); + } + }); + + autowireService.setName(autowiredTo, testValue); + + // Create the DefaultValueCallback + const defaultValueCallback = createAutowiredDefaultCallback(autowiredTo); + + // Assert that the DefaultValueCallback returns the autowired value + expect(defaultValueCallback()).toBe(testValue); + }); + + it('returns a DefaultValueCallback that returns undefined when there is no autowired value', () => { + const autowiredTo = 'autowiredValue'; + + // Make sure there is no autowired value + setNameSpy.mockImplementation((name: any, value: any) => { + if (name === autowiredTo) { + hasNameSpy.mockReturnValue(false); + getNameSpy.mockReturnValue(value); + } + }); + + autowireService.setName(autowiredTo, undefined); + + // Create the DefaultValueCallback + const defaultValueCallback = createAutowiredDefaultCallback(autowiredTo); + + // Assert that the DefaultValueCallback returns undefined + expect(defaultValueCallback()).toBeUndefined(); + }); + +}); diff --git a/cmd/functions/createAutowiredDefaultCallback.ts b/cmd/functions/createAutowiredDefaultCallback.ts new file mode 100644 index 0000000..6e5b2d3 --- /dev/null +++ b/cmd/functions/createAutowiredDefaultCallback.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DefaultValueCallback } from "../types/DefaultValueCallback"; +import { addAutowired } from "../main/addAutowired"; +import { autowired } from "../main/autowired"; + +/** + * Create a callback that auto wires the named parameter to the return value. + * + * @param autowiredTo The name of the parameter to autowire + */ +export function createAutowiredDefaultCallback ( + autowiredTo: string +): DefaultValueCallback { + + class DefaultValueParserImpl { + @addAutowired() + public static parseValue ( + @autowired( autowiredTo ) + value ?: string + ): string | undefined { + return value; + } + } + + return (): string | undefined => DefaultValueParserImpl.parseValue(); +} \ No newline at end of file diff --git a/cmd/functions/parseArgumentWithParam.test.ts b/cmd/functions/parseArgumentWithParam.test.ts new file mode 100644 index 0000000..c6252cc --- /dev/null +++ b/cmd/functions/parseArgumentWithParam.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseArgumentWithParam } from "./parseArgumentWithParam"; +import { ArgumentType } from "../types/ArgumentType"; +import { UserDefinedParserMap } from "../types/UserDefinedParserMap"; + +describe('parseArgumentWithParam', () => { + + describe('with built-in types', () => { + it('parses boolean arguments correctly', () => { + const result = parseArgumentWithParam('--foo=true', ArgumentType.BOOLEAN, 'true', undefined); + expect(result).toBe(true); + }); + + it('parses string arguments correctly', () => { + const result = parseArgumentWithParam('--foo=bar', ArgumentType.STRING, 'bar', undefined); + expect(result).toBe('bar'); + }); + + it('parses non-empty string arguments correctly', () => { + const result = parseArgumentWithParam('--foo=bar', ArgumentType.NON_EMPTY_STRING, 'bar', undefined); + expect(result).toBe('bar'); + }); + + it('parses number arguments correctly', () => { + const result = parseArgumentWithParam('--foo=123', ArgumentType.NUMBER, '123', undefined); + expect(result).toBe(123); + }); + + it('parses integer arguments correctly', () => { + const result = parseArgumentWithParam('--foo=123', ArgumentType.INTEGER, '123', undefined); + expect(result).toBe(123); + }); + }); + + describe('with custom type and parser', () => { + it('uses the correct custom parser when provided', () => { + const parserMap : UserDefinedParserMap = { + 'CUSTOM': (value: unknown): string | undefined => `Custom parsed: ${value}` + }; + const result = parseArgumentWithParam('--foo=bar', 'CUSTOM', 'bar', parserMap); + expect(result).toBe('Custom parsed: bar'); + }); + }); + + describe('with invalid type', () => { + it('throws TypeError when an invalid type is provided', () => { + expect(() => parseArgumentWithParam('--foo=bar', 'INVALID_TYPE' as any, 'bar', undefined)).toThrow(TypeError); + }); + }); + +}); diff --git a/cmd/functions/parseArgumentWithParam.ts b/cmd/functions/parseArgumentWithParam.ts new file mode 100644 index 0000000..bf95bcc --- /dev/null +++ b/cmd/functions/parseArgumentWithParam.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ArgumentType } from "../types/ArgumentType"; +import { parseBooleanArgument } from "./parseBooleanArgument"; +import { parseStringArgument } from "./parseStringArgument"; +import { parseNonEmptyStringArgument } from "./parseNonEmptyStringArgument"; +import { parseNumberArgument } from "./parseNumberArgument"; +import { parseIntegerArgument } from "./parseIntegerArgument"; +import { has } from "../../functions/has"; +import { isFunction } from "../../types/Function"; +import { UserDefinedArgumentType } from "../types/UserDefinedArgumentType"; +import { UserDefinedParserMap } from "../types/UserDefinedParserMap"; + +/** + * Parses command line arguments like `--foo=bar`. + * + * @param argName The full argument string, e.g. `--foo=bar` + * @param type The type of argument, e.g. `ArgumentType.STRING` or a name of a + * custom type. + * @param value The value of the argument, e.g. `bar` + * @param parserMap User defined parsers for custom types + */ +export function parseArgumentWithParam ( + argName: string, + type: UserDefinedArgumentType, + value: string, + parserMap: UserDefinedParserMap | undefined, +): number | boolean | string { + switch ( type ) { + case ArgumentType.BOOLEAN: + return parseBooleanArgument( argName, value ); + case ArgumentType.STRING: + return parseStringArgument( argName, value ); + case ArgumentType.NON_EMPTY_STRING : + return parseNonEmptyStringArgument( argName, value ); + case ArgumentType.NUMBER: + return parseNumberArgument( argName, value ); + case ArgumentType.INTEGER: + return parseIntegerArgument( argName, value ); + } + if ( parserMap && has( parserMap, type ) && isFunction( parserMap[type] ) ) { + return parserMap[type]( value ); + } + throw new TypeError( `Unimplemented type: ${type.toString()}` ); +} diff --git a/cmd/functions/parseBooleanArgument.test.ts b/cmd/functions/parseBooleanArgument.test.ts new file mode 100644 index 0000000..476dab8 --- /dev/null +++ b/cmd/functions/parseBooleanArgument.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseBooleanArgument } from "./parseBooleanArgument"; + +describe('parseBooleanArgument', () => { + + describe('with valid input', () => { + + it('returns true when "true" is given', () => { + const argName = '--foo'; + const value = 'true'; + const result = parseBooleanArgument(argName, value); + expect(result).toBe(true); + }); + + it('returns false when "false" is given', () => { + const argName = '--foo'; + const value = 'false'; + const result = parseBooleanArgument(argName, value); + expect(result).toBe(false); + }); + + }); + + describe('with invalid input', () => { + + it('throws TypeError when a non-boolean string is given', () => { + const argName = '--foo'; + const value = 'abc'; + expect(() => parseBooleanArgument(argName, value)).toThrow(TypeError); + }); + + it('throws TypeError when undefined is given', () => { + const argName = '--foo'; + expect(() => parseBooleanArgument(argName, undefined as unknown as string)).toThrow(TypeError); + }); + + }); + +}); diff --git a/cmd/functions/parseBooleanArgument.ts b/cmd/functions/parseBooleanArgument.ts new file mode 100644 index 0000000..2a39d5c --- /dev/null +++ b/cmd/functions/parseBooleanArgument.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainBoolean, parseBoolean } from "../../types/Boolean"; + +/** + * Parses a command line argument with a type of boolean. + * + * @example + * parseBooleanArgument('--foo', 'true') === true + * + * @param argName The full argument name, e.g. `--foo=true`. + * @param value The argument value, e.g. `true` from `--foo=true`. + * @throw TypeError if could not parse the argument. + */ +export function parseBooleanArgument ( + argName: string, + value: string, +): boolean { + const output: boolean | undefined = parseBoolean( value ); + if ( output === undefined ) { + throw new TypeError( `Argument ${argName}: Not a boolean: ${explainBoolean( value )}` ); + } + return output; +} diff --git a/cmd/functions/parseIntegerArgument.test.ts b/cmd/functions/parseIntegerArgument.test.ts new file mode 100644 index 0000000..a76fcf1 --- /dev/null +++ b/cmd/functions/parseIntegerArgument.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseIntegerArgument } from "./parseIntegerArgument"; + +describe('CommandArgumentUtils', () => { + + describe('#parseIntegerArgument', () => { + + it('parses valid integer string to a number', () => { + const argName = '--foo=42'; + const value = '42'; + const result = parseIntegerArgument(argName, value); + expect(result).toBe(42); + }); + + it('throws TypeError on invalid integer string', () => { + const argName = '--foo=42.5'; + const value = '42.5'; // or any non-integer string + expect(() => parseIntegerArgument(argName, value)).toThrow(TypeError); + }); + + it('throws TypeError with correct error message on invalid integer string', () => { + const argName = '--foo=42.5'; + const value = '42.5'; // or any non-integer string + expect(() => parseIntegerArgument(argName, value)).toThrow(`Argument ${argName}: not integer`); + }); + + }); + +}); diff --git a/cmd/functions/parseIntegerArgument.ts b/cmd/functions/parseIntegerArgument.ts new file mode 100644 index 0000000..714ad8c --- /dev/null +++ b/cmd/functions/parseIntegerArgument.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainInteger, parseInteger } from "../../types/Number"; + +/** + * Parses a command line argument with a type of integer. + * + * @example + * parseIntegerArgument('--foo', '13') === 13 + * + * @param argName The full argument, e.g. `--foo=13` + * @param value The argument value, e.g. `13` from `--foo=13`. + * @throw TypeError if could not parse the argument. + */ +export function parseIntegerArgument ( + argName: string, + value: string, +): number { + const output: number | undefined = parseInteger( value ); + if ( output === undefined ) { + throw new TypeError( `Argument ${argName}: ${explainInteger( value )}` ); + } + return output; +} diff --git a/cmd/functions/parseNonEmptyStringArgument.test.ts b/cmd/functions/parseNonEmptyStringArgument.test.ts new file mode 100644 index 0000000..a3c48f6 --- /dev/null +++ b/cmd/functions/parseNonEmptyStringArgument.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseNonEmptyStringArgument } from "./parseNonEmptyStringArgument"; + +describe('parseNonEmptyStringArgument', () => { + + describe('with valid input', () => { + + it('returns the same string when a non-empty string is given', () => { + const argName = '--foo=bar'; + const value = 'bar'; + const result = parseNonEmptyStringArgument(argName, value); + expect(result).toBe(value); + }); + + }); + + describe('with invalid input', () => { + + it('throws TypeError when an empty string is given', () => { + const argName = '--foo'; + const value = ''; + expect(() => parseNonEmptyStringArgument(argName, value)).toThrow(TypeError); + }); + + it('throws TypeError when undefined is given', () => { + const argName = '--foo'; + expect(() => parseNonEmptyStringArgument(argName, undefined as unknown as string)).toThrow(TypeError); + }); + + }); +}); diff --git a/cmd/functions/parseNonEmptyStringArgument.ts b/cmd/functions/parseNonEmptyStringArgument.ts new file mode 100644 index 0000000..5b3a534 --- /dev/null +++ b/cmd/functions/parseNonEmptyStringArgument.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNonEmptyString, parseNonEmptyString } from "../../types/String"; + +/** + * Parses a command line argument with a type of string. + * + * @example + * parseNonEmptyStringArgument('--foo', 'bar') === 'bar' + * + * @param argName The full argument name, e.g. `--foo=bar`. + * @param value The argument value, e.g. `bar` from `--foo=bar`. + * @throw TypeError if could not parse the argument. + */ +export function parseNonEmptyStringArgument ( + argName: string, + value: string, +): string { + const output: string | undefined = parseNonEmptyString( value ); + if ( output === undefined ) { + throw new TypeError( `Argument ${argName}: Not a string: ${explainNonEmptyString( value )}` ); + } + return output; +} diff --git a/cmd/functions/parseNumberArgument.test.ts b/cmd/functions/parseNumberArgument.test.ts new file mode 100644 index 0000000..59eb7bb --- /dev/null +++ b/cmd/functions/parseNumberArgument.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseNumberArgument } from "./parseNumberArgument"; + +describe('parseNumberArgument', () => { + + describe('with valid input', () => { + it('returns a number when a valid string number is given', () => { + const argName = '--foo'; + const value = '123.45'; + const result = parseNumberArgument(argName, value); + expect(result).toBe(123.45); + }); + + it('returns a number when a valid numeric value is given', () => { + const argName = '--foo'; + const value = '123'; + const result = parseNumberArgument(argName, value); + expect(result).toBe(123); + }); + }); + + describe('with undefined input', () => { + it('throws TypeError', () => { + const argName = '--foo'; + expect(() => parseNumberArgument(argName, undefined as unknown as string)).toThrow(TypeError); + }); + }); + + describe('with non-numeric string input', () => { + it('throws TypeError', () => { + const argName = '--foo'; + const value = 'abc'; + expect(() => parseNumberArgument(argName, value)).toThrow(TypeError); + }); + }); + + describe('with NaN input', () => { + it('throws TypeError', () => { + const argName = '--foo'; + const value = NaN; + // @ts-ignore + expect(() => parseNumberArgument(argName, value)).toThrow(TypeError); + }); + }); + + describe('with string with whitespaces only', () => { + it('throws TypeError', () => { + const argName = '--foo'; + const value = ' '; + expect(() => parseNumberArgument(argName, value)).toThrow(TypeError); + }); + }); + +}); diff --git a/cmd/functions/parseNumberArgument.ts b/cmd/functions/parseNumberArgument.ts new file mode 100644 index 0000000..077c24b --- /dev/null +++ b/cmd/functions/parseNumberArgument.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { NumberUtils } from "../../NumberUtils"; + +/** + * Parses a command line argument with a type of number. + * + * @example + * parseNumberArgument('--foo', '13.45') === 13.45 + * + * @param argName The full argument, e.g. `--foo=13.45` + * @param value The argument value, e.g. `13.45` from `--foo=13.45`. + * @throw TypeError if could not parse the argument. + */ +export function parseNumberArgument ( + argName: string, + value: string, +): number { + const output: number | undefined = NumberUtils.parseNumber( value ); + if ( output === undefined ) { + throw new TypeError( `Argument ${argName}: Not a number: ${value}` ); + } + return output; +} diff --git a/cmd/functions/parseSingleArgument.test.ts b/cmd/functions/parseSingleArgument.test.ts new file mode 100644 index 0000000..ce578d1 --- /dev/null +++ b/cmd/functions/parseSingleArgument.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseSingleArgument } from "./parseSingleArgument"; +import { ArgumentType } from "../types/ArgumentType"; + +describe('parseSingleArgument', () => { + + describe('with built-in types', () => { + + it('parses boolean arguments correctly', () => { + const result = parseSingleArgument('--foo', ArgumentType.BOOLEAN, undefined); + expect(result).toBe(true); + }); + + it('parses string arguments correctly', () => { + const result = parseSingleArgument('--foo', ArgumentType.STRING, undefined); + expect(result).toBe(''); + }); + + it('parses non-empty string arguments correctly', () => { + expect( () => parseSingleArgument('--foo', ArgumentType.NON_EMPTY_STRING, undefined)).toThrow(TypeError); + }); + + it('parses number arguments correctly', () => { + expect( () => parseSingleArgument('--foo', ArgumentType.NUMBER, undefined) ).toThrow(TypeError); + }); + + it('parses integer arguments correctly', () => { + expect( () => parseSingleArgument('--foo', ArgumentType.INTEGER, undefined) ).toThrow(TypeError); + }); + + }); + + describe('with custom type and parser', () => { + it('uses the correct custom parser when provided', () => { + const parserMap = { + 'CUSTOM': ( + // @ts-ignore + value: unknown): string | undefined => `Custom parsed: no value` + }; + const result = parseSingleArgument('--foo', 'CUSTOM', parserMap); + expect(result).toBe('Custom parsed: no value'); + }); + }); + + describe('with invalid type', () => { + it('throws TypeError when an invalid type is provided', () => { + expect(() => parseSingleArgument('--foo', 'INVALID_TYPE' as any, undefined)).toThrow(TypeError); + }); + }); + +}); diff --git a/cmd/functions/parseSingleArgument.ts b/cmd/functions/parseSingleArgument.ts new file mode 100644 index 0000000..eeff7ef --- /dev/null +++ b/cmd/functions/parseSingleArgument.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ArgumentType } from "../types/ArgumentType"; +import { parseArgumentWithParam } from "./parseArgumentWithParam"; +import { UserDefinedArgumentType } from "../types/UserDefinedArgumentType"; +import { UserDefinedParserMap } from "../types/UserDefinedParserMap"; + +/** + * Parses command line arguments like `--foo` which do not have value. + * + * @param argName The full argument string, e.g. `--foo` + * @param type The type of argument, e.g. `ArgumentType.BOOLEAN` or a name of a + * custom type. + * @param parserMap User defined parsers for custom types + */ +export function parseSingleArgument ( + argName: string, + type: UserDefinedArgumentType, + parserMap: UserDefinedParserMap | undefined, +): number | boolean | string { + return parseArgumentWithParam( argName, type, type === ArgumentType.BOOLEAN ? 'true' : '', parserMap ); +} diff --git a/cmd/functions/parseStringArgument.test.ts b/cmd/functions/parseStringArgument.test.ts new file mode 100644 index 0000000..8477516 --- /dev/null +++ b/cmd/functions/parseStringArgument.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseStringArgument } from "./parseStringArgument"; + +describe('parseStringArgument', () => { + + describe('with valid input', () => { + it('should return the same string', () => { + const argName = '--foo=bar'; + const value = 'bar'; + const result = parseStringArgument(argName, value); + expect(result).toBe(value); + }); + it('should return the same integer string', () => { + const argName = '--foo=123'; + const value = '123'; + const result = parseStringArgument(argName, value); + expect(result).toBe(value); + }); + it('should return the same number as string', () => { + const argName = '--foo=123'; + const value = 123; + // @ts-ignore + const result = parseStringArgument(argName, value); + expect(result).toBe("123"); + }); + }); + + describe('with undefined input', () => { + it('should throw TypeError', () => { + const argName = '--foo'; + expect(() => parseStringArgument(argName, undefined as unknown as string)).toThrow(TypeError); + }); + }); + + describe('with null input', () => { + it('should throw TypeError', () => { + const argName = '--foo'; + expect(() => parseStringArgument(argName, null as unknown as string)).toThrow(TypeError); + }); + }); + +}); diff --git a/cmd/functions/parseStringArgument.ts b/cmd/functions/parseStringArgument.ts new file mode 100644 index 0000000..ea174bc --- /dev/null +++ b/cmd/functions/parseStringArgument.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainString, parseString } from "../../types/String"; + +/** + * Parses a command line argument with a type of string. + * + * @example + * parseStringArgument('--foo', 'bar') === 'bar' + * + * @param argName The full argument, e.g. `--foo=bar`. + * @param value The argument value, e.g. `bar` from `--foo=bar`. + * @throw TypeError if could not parse the argument. + */ +export function parseStringArgument ( + argName: string, + value: string, +): string { + const output: string | undefined = parseString( value ); + if ( output === undefined ) { + throw new TypeError( `Argument ${argName}: Not a string: ${explainString(value)}` ); + } + return output; +} diff --git a/cmd/hg/HgCommandService.ts b/cmd/hg/HgCommandService.ts new file mode 100644 index 0000000..a1b0ec3 --- /dev/null +++ b/cmd/hg/HgCommandService.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CommandExitStatus } from "../types/CommandExitStatus"; + +export interface HgCommandService { + + main (args: readonly string[]) : Promise; + + ai (args: readonly string[]) : Promise; + +} diff --git a/cmd/hg/HgCommandServiceImpl.ts b/cmd/hg/HgCommandServiceImpl.ts new file mode 100644 index 0000000..f57c31a --- /dev/null +++ b/cmd/hg/HgCommandServiceImpl.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { HgPackageCommandService } from "../pkg/HgPackageCommandService"; +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { HgCommandService } from "./HgCommandService"; +import { HgAiCommandService } from "../ai/HgAiCommandService"; + +export class HgCommandServiceImpl implements HgCommandService { + + private readonly _ai : HgAiCommandService; + private readonly _pkg : HgPackageCommandService; + + public static create ( + ai : HgAiCommandService, + pkg : HgPackageCommandService, + ) : HgCommandServiceImpl { + return new HgCommandServiceImpl( + ai, + pkg + ); + } + + protected constructor ( + ai : HgAiCommandService, + pkg : HgPackageCommandService, + ) { + this._ai = ai; + this._pkg = pkg; + } + + public async main (args: readonly string[]) : Promise { + + if (args.length === 0) return CommandExitStatus.USAGE; + + const [arg, ...freeArgs] = args; + switch (arg) { + case 'ai': return await this._ai.main(freeArgs); + case 'pkg': return await this._pkg.main(freeArgs); + } + console.error(`Unknown command: ${arg}`); + return CommandExitStatus.COMMAND_NOT_FOUND; + } + + public async ai (args: readonly string[]) : Promise { + return await this._ai.main(args); + } + +} diff --git a/cmd/main/addArgumentParser.test.ts b/cmd/main/addArgumentParser.test.ts new file mode 100644 index 0000000..96f8ea5 --- /dev/null +++ b/cmd/main/addArgumentParser.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from "@jest/globals"; +import { addArgumentParser } from './addArgumentParser'; +import { autowired } from './autowired'; +import { LogLevel } from "../../types/LogLevel"; +import { addAutowired } from "./addAutowired"; +import { CommandArgumentUtils } from "../utils/CommandArgumentUtils"; +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { AutowireServiceImpl } from "./services/AutowireServiceImpl"; +import { AutowireUtils } from "./utils/AutowireUtils"; +import { ParsedCommandArgumentStatus } from "../types/ParsedCommandArgumentStatus"; +import { AutowireService } from "./services/AutowireService"; +import { ArgumentType } from "../types/ArgumentType"; +import type { ArgumentValueMap } from "../types/ArgumentValueMap"; +import type { ParsedCommandArgumentObject } from "../types/ParsedCommandArgumentObject"; + +addArgumentParser.setLogLevel(LogLevel.NONE); +addAutowired.setLogLevel(LogLevel.NONE); +autowired.setLogLevel(LogLevel.NONE); + +describe('addArgumentParser', () => { + + beforeAll(() => { + CommandArgumentUtils.setLogLevel(LogLevel.NONE); + AutowireUtils.setLogLevel(LogLevel.NONE); + }); + + describe('integration testing', () => { + + let autowireService : AutowireService; + let retrievedArgs: string[] | undefined; + let retrievedFreeArgs : string[] | undefined; + let retrievedBackend: string | undefined; + let retrievedUserArgs: ArgumentValueMap | undefined; + let retrievedParsedArgs: ParsedCommandArgumentObject | undefined; + + let consoleLog: jest.SpiedFunction; + let consoleError: jest.SpiedFunction; + + let getVersion = jest.fn<() => string>().mockImplementation(() => '1.0.0'); + let getUsage = jest.fn<() => string>().mockImplementation(() => 'usage'); + + // Mock class with a method decorated with `addArgumentParser` and `addAutowired` + class MyApp { + @addArgumentParser( + 'testApp', + getVersion, + getUsage, + { + backend: [ ArgumentType.STRING, '--backend', '-b' ], + integer: [ ArgumentType.INTEGER, '--integer', '-i' ], + } + ) + @addAutowired() + public async run( + @autowired('args') + args: string[] = [], + @autowired('parsedArgs') + parsedArgs ?: ParsedCommandArgumentObject, + @autowired('userArgs') + userArgs ?: ArgumentValueMap, + @autowired('freeArgs') + freeArgs ?: string[], + @autowired('backend') + backend : string = '', + ): Promise { + retrievedArgs = args; + retrievedParsedArgs = parsedArgs; + retrievedBackend = backend; + retrievedUserArgs = userArgs; + retrievedFreeArgs = freeArgs; + return CommandExitStatus.OK; + } + } + + let app : MyApp; + + beforeEach( () => { + retrievedArgs = undefined; + retrievedParsedArgs = undefined; + retrievedBackend = undefined; + retrievedFreeArgs = undefined; + autowireService = AutowireServiceImpl.create(); + AutowireServiceImpl.setAutowireService(autowireService); + app = new MyApp(); + + consoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); + + consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + }); + + afterEach(() => { + jest.clearAllMocks(); + }) + + it('can parse long version argument', async () => { + + await app.run(['/usr/bin/node', 'dist.js', '--version']); + + // Check if autowired parameters match expected values + expect(consoleLog).toHaveBeenCalledWith('1.0.0'); + + expect(getUsage).not.toHaveBeenCalled(); + + expect(getVersion).toHaveBeenCalledTimes(1); + expect(getVersion).toHaveBeenCalledWith( + 'dist.js', + { + "exitStatus": 0, + "extraArgs": [], + "freeArgs": [], + "nodePath": "/usr/bin/node", + "parseStatus": 3, + "scriptName": "dist.js", + "userArgs": {} + } + ); + expect(retrievedArgs).toEqual(undefined); + expect(retrievedParsedArgs).not.toBeDefined(); + expect(retrievedBackend).toEqual(undefined); + expect(retrievedFreeArgs).toEqual(undefined); + + }); + + it('can parse short version argument', async () => { + + await app.run(['/usr/bin/node', 'dist.js', '-v']); + + // Check if autowired parameters match expected values + expect(consoleLog).toHaveBeenCalledWith('1.0.0'); + + expect(getUsage).not.toHaveBeenCalled(); + + expect(getVersion).toHaveBeenCalledTimes(1); + expect(getVersion).toHaveBeenCalledWith( + 'dist.js', + { + "exitStatus": 0, + "extraArgs": [], + "freeArgs": [], + "nodePath": "/usr/bin/node", + "parseStatus": 3, + "scriptName": "dist.js", + "userArgs": {} + } + ); + expect(retrievedArgs).toEqual(undefined); + expect(retrievedParsedArgs).not.toBeDefined(); + expect(retrievedBackend).toEqual(undefined); + expect(retrievedFreeArgs).toEqual(undefined); + + }); + + it('can parse long help argument', async () => { + + await app.run(['/usr/bin/node', 'dist.js', '--help']); + + // Check if autowired parameters match expected values + expect(consoleLog).toHaveBeenCalledWith('usage'); + + expect(getUsage).toHaveBeenCalledTimes(1); + expect(getUsage).toHaveBeenCalledWith( + 'dist.js', + { + "exitStatus": 0, + "extraArgs": [], + "freeArgs": [], + "nodePath": "/usr/bin/node", + "parseStatus": 2, + "scriptName": "dist.js", + "userArgs": {} + } + ); + + expect(getVersion).not.toHaveBeenCalled(); + + expect(retrievedArgs).toEqual(undefined); + expect(retrievedParsedArgs).not.toBeDefined(); + expect(retrievedBackend).toEqual(undefined); + expect(retrievedFreeArgs).toEqual(undefined); + + }); + + it('can parse short help argument', async () => { + + await app.run(['/usr/bin/node', 'dist.js', '-h']); + + // Check if autowired parameters match expected values + expect(consoleLog).toHaveBeenCalledWith('usage'); + + expect(getUsage).toHaveBeenCalledTimes(1); + expect(getUsage).toHaveBeenCalledWith( + 'dist.js', + { + "exitStatus": 0, + "extraArgs": [], + "freeArgs": [], + "nodePath": "/usr/bin/node", + "parseStatus": 2, + "scriptName": "dist.js", + "userArgs": {} + } + ); + expect(getVersion).not.toHaveBeenCalled(); + expect(retrievedArgs).toEqual(undefined); + expect(retrievedParsedArgs).not.toBeDefined(); + expect(retrievedBackend).toEqual(undefined); + expect(retrievedFreeArgs).toEqual(undefined); + + }); + + it('can parse long arguments', async () => { + + await app.run(['/usr/bin/node', 'dist.js', '--backend=myBackend']); + + // Check if autowired parameters match expected values + expect(retrievedArgs).toEqual(['/usr/bin/node', 'dist.js', '--backend=myBackend']); + expect(retrievedParsedArgs).toBeDefined(); + expect((retrievedParsedArgs as any)?.parseStatus).toEqual(ParsedCommandArgumentStatus.OK); + expect((retrievedParsedArgs as any)?.exitStatus).toEqual(CommandExitStatus.OK); + expect((retrievedParsedArgs as any)?.userArgs?.backend).toEqual('myBackend'); + expect((retrievedUserArgs as any)?.backend).toEqual('myBackend'); + expect(retrievedBackend).toEqual('myBackend'); + expect(retrievedFreeArgs).toEqual([]); + + }); + + it('can parse short arguments', async () => { + + await app.run(['/usr/bin/node', 'dist.js', '-b=myBackend']); + + // Check if autowired parameters match expected values + expect(retrievedArgs).toEqual(['/usr/bin/node', 'dist.js', '-b=myBackend']); + expect(retrievedParsedArgs).toBeDefined(); + expect((retrievedParsedArgs as any)?.parseStatus).toEqual(ParsedCommandArgumentStatus.OK); + expect((retrievedParsedArgs as any)?.exitStatus).toEqual(CommandExitStatus.OK); + expect((retrievedParsedArgs as any)?.userArgs?.backend).toEqual('myBackend'); + expect((retrievedUserArgs as any)?.backend).toEqual('myBackend'); + expect(retrievedBackend).toEqual('myBackend'); + expect(retrievedFreeArgs).toEqual([]); + + }); + + it('can parse free arguments', async () => { + + await app.run(['/usr/bin/node', 'dist.js', '-b=myBackend', 'command', 'one']); + + // Check if autowired parameters match expected values + expect(retrievedArgs).toEqual(['/usr/bin/node', 'dist.js', '-b=myBackend', 'command', 'one']); + expect(retrievedParsedArgs).toBeDefined(); + expect((retrievedParsedArgs as any)?.parseStatus).toEqual(ParsedCommandArgumentStatus.OK); + expect((retrievedParsedArgs as any)?.exitStatus).toEqual(CommandExitStatus.OK); + expect(retrievedFreeArgs).toEqual(['command', 'one']); + + }); + + it('can parse illegal integer argument', async () => { + + await app.run(['/usr/bin/node', 'dist.js', '--integer=myBackend']); + + expect(consoleError).toHaveBeenCalledWith('ERROR: Argument parse error: TypeError: Argument --integer=myBackend: not integer'); + + // Check if autowired parameters match expected values + expect(retrievedArgs).not.toBeDefined(); + expect(retrievedParsedArgs).not.toBeDefined(); + expect(retrievedBackend).not.toBeDefined(); + expect(retrievedFreeArgs).not.toBeDefined(); + + }); + + + }); + +}); diff --git a/cmd/main/addArgumentParser.ts b/cmd/main/addArgumentParser.ts new file mode 100644 index 0000000..a6dc373 --- /dev/null +++ b/cmd/main/addArgumentParser.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../LogService"; +import { MethodDecoratorFunction } from "../../decorators/types/MethodDecoratorFunction"; +import { CommandArgumentUtils } from "../utils/CommandArgumentUtils"; +import { ParsedCommandArgumentStatus } from "../types/ParsedCommandArgumentStatus"; +import { LogLevel } from "../../types/LogLevel"; +import { createMethodDecorator } from "../../decorators/createMethodDecorator"; +import { AutowireServiceImpl } from "./services/AutowireServiceImpl"; +import { forEach } from "../../functions/forEach"; +import { keys } from "../../functions/keys"; +import { ArgumentConfigurationMap } from "../types/ArgumentConfigurationMap"; +import { UserDefinedParserMap } from "../types/UserDefinedParserMap"; +import { ParsedCommandArgumentObject } from "../types/ParsedCommandArgumentObject"; + +const LOG = LogService.createLogger( 'addArgumentParser' ); + +export interface GetInfoCallback { + (scriptName: string, parsedOpts ?: ParsedCommandArgumentObject) : string; +} + +/** + * Wraps the method body with argument parser. + * + * Caught errors will be logged. + * + * Example usage: + * + * ```typescript + * class MyApp { + * + * @addArgumentParser( + * COMMAND_NAME, + * () => `${BUILD_COMMAND_NAME} v${BUILD_VERSION} (${BUILD_DATE})`, + * getMainUsage, + * { + * backend: [ ArgumentType.STRING, '--backend', '-b' ], + * } + * ) + * @addAutowired() + * public static async run ( + * @autowired('args') + * args: string[] = [], + * @autowired('parsedArgs') + * parsedArgs ?: ParsedCommandArgumentObject, + * ): Promise { + * console.log('Hello world'); + * } + * + * } + * @param defaultScriptName + * @param getVersion + * @param getUsage + * @param configurationMap + * @param parserMap + * + * @see CommandArgumentUtils + * } + */ +export function addArgumentParser ( + defaultScriptName : string, + getVersion : GetInfoCallback, + getUsage : GetInfoCallback, + configurationMap ?: ArgumentConfigurationMap, + parserMap ?: UserDefinedParserMap, +) : MethodDecoratorFunction { + LOG.debug(`calling createMethodDecorator`); + return createMethodDecorator( ( + method: Function, + context: ClassMethodDecoratorContext + ) => { + const propertyName = context.name; + LOG.debug(`overriding method ${context.name.toString()}`); + return async function ( + this: T, + args: readonly string[], + ) { + LOG.debug(`args = `, args); + const autowireService = AutowireServiceImpl.getAutowireService(); + let keysToDelete : string[] = []; + try { + autowireService.setName("args", args); + const parsedArgs : ParsedCommandArgumentObject = CommandArgumentUtils.parseArguments( + defaultScriptName, + args, + configurationMap, + parserMap + ); + autowireService.setName("parsedArgs", parsedArgs); + + const { + parseStatus, + exitStatus, + nodePath, + scriptName, + freeArgs, + extraArgs, + errorString, + userArgs, + } = parsedArgs; + + keysToDelete = keys(userArgs); + forEach( + keysToDelete, + (key: string) => { + autowireService.setName(`%${key}`, userArgs[key]); + autowireService.setName(key, userArgs[key]); + } + ); + + autowireService.setName("parseStatus", parseStatus); + autowireService.setName("exitStatus", exitStatus); + autowireService.setName("nodePath", nodePath); + autowireService.setName("scriptName", scriptName); + autowireService.setName("freeArgs", freeArgs); + autowireService.setName("extraArgs", extraArgs); + autowireService.setName("errorString", errorString); + autowireService.setName("userArgs", userArgs); + + if ( parseStatus === ParsedCommandArgumentStatus.VERSION ) { + console.log( getVersion( scriptName, parsedArgs ) ); + return exitStatus; + } + + if ( parseStatus === ParsedCommandArgumentStatus.HELP ) { + console.log( getUsage( scriptName, parsedArgs ) ); + return exitStatus; + } + + if ( errorString !== undefined ) { + console.error( `ERROR: ${errorString}` ); + return exitStatus; + } + + return await method.apply(this, args); + + } catch (err) { + LOG.warn(`Warning! The @addStateService decorator for "${propertyName.toString()}" method had an error: `, err); + throw err; + } finally { + + forEach( + keys(keysToDelete), + (key: string) => { + autowireService.deleteName(`%${key}`); + autowireService.deleteName(key); + } + ); + + autowireService.deleteName("parseStatus"); + autowireService.deleteName("exitStatus"); + autowireService.deleteName("nodePath"); + autowireService.deleteName("scriptName"); + autowireService.deleteName("freeArgs"); + autowireService.deleteName("extraArgs"); + autowireService.deleteName("errorString"); + autowireService.deleteName("userArgs"); + + } + }; + } ); +} + +addArgumentParser.setLogLevel = (level: LogLevel) => LOG.setLogLevel(level); diff --git a/cmd/main/addAutowired.test.ts b/cmd/main/addAutowired.test.ts new file mode 100644 index 0000000..5ea022c --- /dev/null +++ b/cmd/main/addAutowired.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { autowired } from "./autowired"; +import { addAutowired } from "./addAutowired"; +import { AutowireServiceImpl } from "./services/AutowireServiceImpl"; +import { AutowireUtils } from "./utils/AutowireUtils"; +import { LogLevel } from "../../types/LogLevel"; + +describe('addAutowired', () => { + + beforeAll(() => { + AutowireUtils.setLogLevel(LogLevel.NONE); + addAutowired.setLogLevel(LogLevel.NONE); + autowired.setLogLevel(LogLevel.NONE); + }); + + it('successfully updates metadata and invokes the method with autowired parameters', () => { + let retrievedArg: string = ''; + let retrievedArg2: string = 'xxx'; + let retrievedArg3: string = ''; + + // Define an example class with a method decorated with `addAutowired` + class MyApp { + @addAutowired() + public run( + @autowired('hello') + name: string = '', + @autowired('bar') + bar: string = '', + @autowired('foobar') + foobar: string = '', + ) { + retrievedArg = name; + retrievedArg2 = bar; + retrievedArg3 = foobar; + } + } + + const autowireService = AutowireServiceImpl.create(); + AutowireServiceImpl.setAutowireService( + autowireService + ); + autowireService.setName('hello', 'world'); + autowireService.setName('foobar', 'hello world'); + const app = new MyApp(); + app.run(); + // Check if autowired parameter matches the context + expect(retrievedArg).toEqual('world'); + expect(retrievedArg2).toEqual(''); + expect(retrievedArg3).toEqual('hello world'); + }); + +}); diff --git a/cmd/main/addAutowired.ts b/cmd/main/addAutowired.ts new file mode 100644 index 0000000..c5e47bf --- /dev/null +++ b/cmd/main/addAutowired.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../LogService"; +import { AutowireMetadataUtils } from "./utils/AutowireMetadataUtils"; +import { AutowireUtils } from "./utils/AutowireUtils"; +import { MethodDecoratorFunction } from "../../decorators/types/MethodDecoratorFunction"; +import { LogLevel } from "../../types/LogLevel"; + +const LOG = LogService.createLogger( 'addAutowired' ); + +/** + * Autowires method parameters based on the property name. + * + * Example usage: + * + * ```typescript + * class MyApp { + * + * @addAutowired() + * public static async run ( + * @autowired('args') + * args: string[] = [], + * ): Promise { + * console.log('Hello world'); + * } + * + * } + * + * } + */ +export function addAutowired () : MethodDecoratorFunction { + LOG.debug(`1 creating autowired decorator`); + return function autowiredMethod ( + target : any | Function, + propertyKey : string, + descriptor : TypedPropertyDescriptor, + ) : void { + const method = descriptor.value!; + const metadata = AutowireMetadataUtils.getMethodMetadata(target, propertyKey); + const paramNames = metadata?.paramNames ?? []; + LOG.debug(`3 autowiredMethod: paramNames = `, paramNames); + + const overrideCallback = function ( + this: T, + ...args: any + ) { + try { + return AutowireUtils.autowireApply( + target, + propertyKey, + method, + { + prevArgs: args + } + ); + } catch (err) { + LOG.warn(`Warning! The addAutowired decorator for "${propertyKey}" method had an error: `, err); + throw err; + } + }; + + descriptor.value = function (this: T, ...args: any) : any { + return overrideCallback.apply(this, args); + }; + + } +} + +addAutowired.setLogLevel = (level: LogLevel) => LOG.setLogLevel(level); diff --git a/cmd/main/addDestroyService.test.ts b/cmd/main/addDestroyService.test.ts new file mode 100644 index 0000000..090f2f2 --- /dev/null +++ b/cmd/main/addDestroyService.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../LogService"; + +jest.mock('../../LogService', () => ({ + LogService: { + createLogger: jest.fn<(name: string) => any>().mockImplementation( + (name: string) : any => ({ + name, + getLogLevel: jest.fn<() => LogLevel>(), + setLogLevel: jest.fn<(level: LogLevel | undefined) => ContextLogger>(), + debug: jest.fn<(...args: any) => void>(), + info: jest.fn<(...args: any) => void>(), + warn: jest.fn<(...args: any) => void>(), + error: jest.fn<(...args: any) => void>(), + }) + ) + } +})); + +// @ts-ignore +import "../../../testing/jest/matchers/index"; +import { debug } from "node:util"; +import { Simulate } from "react-dom/test-utils"; +import { ContextLogger } from "../../logger/context/ContextLogger"; +import { Logger } from "../../types/Logger"; +import { addDestroyService } from './addDestroyService'; +import { autowired } from './autowired'; +import { LogLevel } from "../../types/LogLevel"; +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { AutowireServiceImpl } from "./services/AutowireServiceImpl"; +import { addAutowired } from "./addAutowired"; +import { ProcessUtils } from "../../ProcessUtils"; +import { AutowireUtils } from "./utils/AutowireUtils"; +import { DestroyService } from "./services/DestroyService"; +import { LogService } from "../../LogService"; +import { find } from "../../functions/find"; +import error = Simulate.error; + +jest.mock('../../ProcessUtils', () => ({ + ProcessUtils: { + setupDestroyHandler: jest.fn(), + } +})); + +jest.mock('./services/DestroyServiceImpl', () => ({ + DestroyServiceImpl : { + create: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + destroy: jest.fn(), + isDestroyed: jest.fn(), + addDestroyListener: jest.fn(), + registerDisposable: jest.fn(), + })) + } +})); + +addDestroyService.setLogLevel(LogLevel.NONE); +addAutowired.setLogLevel(LogLevel.NONE); +autowired.setLogLevel(LogLevel.NONE); + +describe('addDestroyService', () => { + + beforeAll(() => { + AutowireUtils.setLogLevel(LogLevel.NONE); + }); + + it('sets up destroy handlers and correctly autowires destroyService', async () => { + + let retrievedDestroyService: DestroyService | undefined = undefined; + + // Mock class with a method decorated with `addDestroyService` and `addAutowired` + class MyApp { + @addDestroyService() + @addAutowired() + public async run( + @autowired('destroyService') + destroyService ?: any, + ): Promise { + retrievedDestroyService = destroyService; + return CommandExitStatus.OK; + } + } + + const autowireService = AutowireServiceImpl.create(); + AutowireServiceImpl.setAutowireService(autowireService); + const app = new MyApp(); + await app.run(); + + // Check if setupDestroyHandler was called + expect(ProcessUtils.setupDestroyHandler).toHaveBeenCalled(); + + // Check if autowired destroyService matches expected value + expect(retrievedDestroyService).toBeDefined(); + + const callback = (ProcessUtils.setupDestroyHandler as any).mock.calls[0][0]; + + // @ts-ignore + expect(callback).toBeFunction(); + expect((retrievedDestroyService as any)?.destroy).not.toHaveBeenCalled(); + callback(); + expect((retrievedDestroyService as any)?.destroy).toHaveBeenCalled(); + + }); + + it('sets up destroy handlers and correctly autowires destroyService and handles errors', async () => { + + let retrievedDestroyService: DestroyService | undefined = undefined; + + // Mock class with a method decorated with `addDestroyService` and `addAutowired` + class MyApp { + @addDestroyService() + @addAutowired() + public async run( + @autowired('destroyService') + destroyService ?: any, + ): Promise { + retrievedDestroyService = destroyService; + return CommandExitStatus.OK; + } + } + + const autowireService = AutowireServiceImpl.create(); + AutowireServiceImpl.setAutowireService(autowireService); + const app = new MyApp(); + await app.run(); + + // Check if setupDestroyHandler was called + expect(ProcessUtils.setupDestroyHandler).toHaveBeenCalled(); + + // Check if autowired destroyService matches expected value + expect(retrievedDestroyService).toBeDefined(); + + const errorCallback = (ProcessUtils.setupDestroyHandler as any).mock.calls[0][1]; + expect(errorCallback).toBeFunction(); + errorCallback('mock error'); + + const LOG = find( + (LogService.createLogger as any).mock.results, + (result) => { + return result.value.name === 'addDestroyService'; + } + ); + + expect(LOG.value.name).toBe('addDestroyService'); + expect(LOG.value.error).toHaveBeenCalledWith('Error while shutting down the service: ', 'mock error'); + + }); + +}); diff --git a/cmd/main/addDestroyService.ts b/cmd/main/addDestroyService.ts new file mode 100644 index 0000000..1f93e1e --- /dev/null +++ b/cmd/main/addDestroyService.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../LogService"; +import { MethodDecoratorFunction } from "../../decorators/types/MethodDecoratorFunction"; +import { createMethodDecorator } from "../../decorators/createMethodDecorator"; +import { ProcessUtils } from "../../ProcessUtils"; +import { DestroyServiceImpl } from "./services/DestroyServiceImpl"; +import { AutowireServiceImpl } from "./services/AutowireServiceImpl"; +import { LogLevel } from "../../types/LogLevel"; + +const LOG = LogService.createLogger( 'addDestroyService' ); + +/** + * Setup destroy handler for the process itself. + * + * This will add listeners for multiple signals like SIGTERM, SIGINT, SIGUSR1, + * SIGUSR2 and other uncaught error handlers. + * + * Example usage: + * + * ```typescript + * class MyApp { + * + * @addDestroyService() + * public static async run ( + * args: string[] = [] + * ): Promise { + * console.log('Hello world'); + * } + * + * } + * + * } + */ +export function addDestroyService ( +) : MethodDecoratorFunction { + LOG.debug(`creating MethodDecorator`); + + return createMethodDecorator( ( + method: Function, + context: ClassMethodDecoratorContext + ) => { + const propertyName = context.name; + LOG.debug(`overriding method ${propertyName.toString()}`); + return async function ( + this: T, + ...args: readonly string[] + ) { + + const autowireService = AutowireServiceImpl.getAutowireService(); + const destroyService = DestroyServiceImpl.create(); + autowireService.setName("destroyService", destroyService); + + ProcessUtils.setupDestroyHandler( () => { + LOG.debug( 'Stopping command from process utils event' ); + destroyService.destroy(); + }, (err: any) => { + LOG.error( 'Error while shutting down the service: ', err ); + } ); + + return await method.apply(this, args); + }; + } ); +} + +addDestroyService.setLogLevel = (level: LogLevel) => LOG.setLogLevel(level); diff --git a/cmd/main/addErrorHandler.test.ts b/cmd/main/addErrorHandler.test.ts new file mode 100644 index 0000000..a23ffed --- /dev/null +++ b/cmd/main/addErrorHandler.test.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { addErrorHandler } from './addErrorHandler'; +import { LogLevel } from "../../types/LogLevel"; +import { CommandExitStatus } from "../types/CommandExitStatus"; + +describe('addErrorHandler', () => { + + beforeAll(() => { + addErrorHandler.setLogLevel(LogLevel.NONE); + }); + + it('handles errors properly and returns the correct exit status', async () => { + // Define an example class with a method decorated with `addErrorHandler` + class MyApp { + @addErrorHandler(CommandExitStatus.FATAL_ERROR) + public async run( + args: string[] = [] + ) { + if (args.length > 0) { + throw new Error('Error!'); + } + return CommandExitStatus.OK; + } + } + + const app = new MyApp(); + + // Test when no error is thrown + let result = await app.run([]); + expect(result).toEqual(CommandExitStatus.OK); + + // Test when an error is thrown + result = await app.run(['hello']); + expect(result).toEqual(CommandExitStatus.FATAL_ERROR); + }); + +}); diff --git a/cmd/main/addErrorHandler.ts b/cmd/main/addErrorHandler.ts new file mode 100644 index 0000000..386b1ba --- /dev/null +++ b/cmd/main/addErrorHandler.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { LogService } from "../../LogService"; +import { MethodDecoratorFunction } from "../../decorators/types/MethodDecoratorFunction"; +import { createMethodDecorator } from "../../decorators/createMethodDecorator"; +import { LogLevel } from "../../types/LogLevel"; + +const LOG = LogService.createLogger( 'addErrorHandler' ); + +/** + * Wraps the method body with try-catch. + * + * Caught errors will be logged. + * + * Example usage: + * + * ```typescript + * class MyApp { + * + * @addErrorHandler(CommandExitStatus.FATAL_ERROR) + * public static async run ( + * args: string[] = [] + * ): Promise { + * console.log('Hello world'); + * } + * + * } + * @param exitStatus The exit status which to return on errors. + * + * } + */ +export function addErrorHandler ( + exitStatus: CommandExitStatus = CommandExitStatus.FATAL_ERROR +) : MethodDecoratorFunction { + LOG.debug(`creating MethodDecorator`); + return createMethodDecorator( ( + method: Function, + context: ClassMethodDecoratorContext + ) => { + const propertyName = context.name; + LOG.debug(`overriding method ${context.name.toString()}`); + return async function ( + this: T, + ...args: readonly string[] + ) { + try { + LOG.debug(`Calling `, propertyName, args); + return await method.apply(this, args); + } catch (err) { + LOG.error(`Error in method "${propertyName.toString()}()": `, err); + return exitStatus; + } + }; + } ); +} + +addErrorHandler.setLogLevel = (level: LogLevel) => LOG.setLogLevel(level); diff --git a/cmd/main/addStateService.test.ts b/cmd/main/addStateService.test.ts new file mode 100644 index 0000000..a45b65c --- /dev/null +++ b/cmd/main/addStateService.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from "@jest/globals"; +import { addStateService } from "./addStateService"; +import { AutowireServiceImpl } from "./services/AutowireServiceImpl"; +import { LogLevel } from "../../types/LogLevel"; + +// Mocked implementations + +jest.mock('./services/AutowireServiceImpl', () => ({ + ...jest.requireActual('./services/AutowireServiceImpl'), + AutowireServiceImpl: { + ...jest.requireActual('./services/AutowireServiceImpl').AutowireServiceImpl, + getAutowireService: jest.fn().mockImplementation(() => ({ + getName: jest.fn().mockImplementation((name: any) => name === 'parsedArgs' ? {} : undefined), + setName: jest.fn(), + deleteName: jest.fn(), + hasName: jest.fn() + })), + }, +})); + +// Unit tests +describe('addStateService', () => { + + beforeAll( () => { + addStateService.setLogLevel(LogLevel.NONE); + }); + + it('should properly create the service and perform state changes', async () => { + // Prepare + const createStateService = jest.fn(); + const createDTO = jest.fn(); + const isDTO = jest.fn(); + const explainDTO = jest.fn(); + const mockStateService = { on: jest.fn(), getDTO: jest.fn(), destroy: jest.fn() }; + const mockAutowireService = { + getName: jest.fn().mockImplementation((name: any) => name === 'parsedArgs' ? {} : undefined), + setName: jest.fn(), + deleteName: jest.fn(), + hasName: jest.fn() + }; + const readFile = jest.fn(); + const writeFile = jest.fn(); + + createStateService.mockReturnValue(mockStateService); + createDTO.mockReturnValue({}); + isDTO.mockReturnValue(true); + explainDTO.mockReturnValue('OK'); + (readFile as any).mockResolvedValue(JSON.stringify({})); + (AutowireServiceImpl as any).getAutowireService.mockReturnValue(mockAutowireService); + + // Decorate method + class MyClass { + @addStateService( + 'myState', + createStateService, + createDTO, + isDTO as any, + explainDTO, + readFile, + writeFile + ) + public static async run() {} + } + + // Execute + await MyClass.run(); + + // Assert + expect(createStateService).toHaveBeenCalledTimes(1); + expect(createDTO).not.toHaveBeenCalled(); + expect(isDTO).toHaveBeenCalledTimes(1); + expect(explainDTO).not.toHaveBeenCalled(); + expect(mockAutowireService.setName).toHaveBeenCalledTimes(2); + expect(mockAutowireService.deleteName).toHaveBeenCalledTimes(2); + expect(mockStateService.destroy).toHaveBeenCalledTimes(1); + + }); + + it('should throw an error if state file is not valid DTO', async () => { + // Prepare + const createStateService = jest.fn(); + const createDTO = jest.fn(); + const isDTO = jest.fn(); + const explainDTO = jest.fn(); + const readFile = jest.fn(); + const writeFile = jest.fn(); + + createDTO.mockReturnValue({}); + isDTO.mockReturnValue(false); + explainDTO.mockReturnValue('Not valid DTO'); + readFile.mockResolvedValue(JSON.stringify({})); + + // Decorate method + class MyClass { + @addStateService( + 'myState', + createStateService, + createDTO, + isDTO as any, + explainDTO, + readFile, + writeFile + ) + public static async run() {} + } + + // Execute and assert + await expect(MyClass.run()).rejects.toThrow('Not valid DTO'); + + expect(createDTO).not.toHaveBeenCalled(); + + }); + + it('should write new state to file when state changes', async () => { + + // Prepare + const createStateService = jest.fn(); + const createDTO = jest.fn(); + const isDTO = jest.fn(); + const explainDTO = jest.fn(); + const mockStateService = { + on: jest.fn(), + getDTO: jest.fn().mockReturnValue({ a: 1 }), + destroy: jest.fn(), + }; + const mockAutowireService = { + getName: jest.fn().mockImplementation((name: any) => name === 'parsedArgs' ? {} : undefined), + setName: jest.fn(), + deleteName: jest.fn(), + hasName: jest.fn() + }; + const readFile = jest.fn(); + const writeFile = jest.fn(); + + createStateService.mockReturnValue(mockStateService); + createDTO.mockReturnValue({}); + isDTO.mockReturnValue(true); + explainDTO.mockReturnValue('OK'); + readFile.mockResolvedValue(JSON.stringify({})); + (AutowireServiceImpl as any).getAutowireService.mockReturnValue(mockAutowireService); + + // Decorate method + class MyClass { + @addStateService( + 'myState', + createStateService, + createDTO, + isDTO as any, + explainDTO, + readFile, + writeFile + ) + public static async run() { + + // Simulate state change + const stateChangeCallback : any = mockStateService.on.mock.calls[0][1]; + await stateChangeCallback(); + + } + } + + // Execute + await MyClass.run(); + + // Assert + expect(writeFile).toHaveBeenCalledTimes(1); + expect(writeFile).toHaveBeenCalledWith(expect.any(String), JSON.stringify({ a: 1 }, null , 2), "utf8"); + + }); + +}); diff --git a/cmd/main/addStateService.ts b/cmd/main/addStateService.ts new file mode 100644 index 0000000..5d38f03 --- /dev/null +++ b/cmd/main/addStateService.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { resolve as pathResolve } from "path"; +import { LogService } from "../../LogService"; +import { ObserverDestructor } from "../../Observer"; +import { parseNonEmptyString } from "../../types/String"; +import { StateService, StateServiceEvent } from "./services/StateService"; +import { ExplainCallback } from "../../types/ExplainCallback"; +import { TestCallbackNonStandardOf } from "../../types/TestCallback"; +import { MethodDecoratorFunction } from "../../decorators/types/MethodDecoratorFunction"; +import { createMethodDecorator } from "../../decorators/createMethodDecorator"; +import { AutowireServiceImpl } from "./services/AutowireServiceImpl"; +import { DestroyService } from "./services/DestroyService"; +import { ParsedCommandArgumentObject } from "../types/ParsedCommandArgumentObject"; +import { LogLevel } from "../../types/LogLevel"; + +const LOG = LogService.createLogger( 'addStateService' ); + +/** + * Wraps the method body with proper state service handling. + * + * Example usage: + * + * ```typescript + * import { readFile, writeFile } from "fs/promises"; + * + * class MyApp { + * + * @addArgumentParser(...) + * @addStateService( + * AgentStateServiceImpl.create, + * () => createAgentStateDTO(undefined, undefined, undefined), + * isAgentStateDTO, + * explainAgentStateDTO, + * readFile, + * writeFile + * ) + * public static async run ( + * args : readonly string[], + * parsedArgs ?: ParsedCommandArgumentObject, + * stateService ?: MyStateService, + * ): Promise { + * console.log('Hello world'); + * } + * + * } + * + * } + */ +export function addStateService< + T = any, + DTO = any, + StateServiceImpl extends StateService = StateService, +> ( + name : string, + createStateService: ((initial: DTO) => StateServiceImpl), + createDTO: (() => DTO), + isDTO: TestCallbackNonStandardOf, + explainDTO: ExplainCallback, + readFile: (file : string, charset: "utf8") => Promise, + writeFile: (file : string, data: string, charset: "utf8") => Promise, +): MethodDecoratorFunction { + LOG.debug(`Creating method decorator`); + return createMethodDecorator( ( + method: Function, + context: ClassMethodDecoratorContext + ) => { + const propertyName = context.name; + LOG.debug(`Creating method decorator for "${propertyName.toString()}"`); + return async function ( + this: T, + ...args: any[] + ) { + const autowireService = AutowireServiceImpl.getAutowireService(); + let stateService: StateServiceImpl | undefined = undefined; + let savePromise: Promise | undefined = undefined; + let stateServiceChangedDestructor: ObserverDestructor | undefined; + try { + LOG.debug(`Running method decorator for "${propertyName.toString()}"`); + + const { + userArgs + } = autowireService.getName('parsedArgs'); + const stateFileArg: string | undefined = parseNonEmptyString( userArgs?.stateFile ) ?? undefined; + const stateConfigFile: string = pathResolve( stateFileArg ?? process?.env?.HOME ?? process.cwd(), '.nor-agent.json' ); + const initialStateConfig: DTO = await readStateDTOFromFile( + readFile, + stateConfigFile, + createDTO, + isDTO, + explainDTO + ); + stateService = createStateService( initialStateConfig ); + + if (autowireService.hasName('destroyService')) { + const destroyService = autowireService.getName('destroyService'); + destroyService.registerDisposable(stateService); + } else { + LOG.warn(`Warning! You should include @addDestroyService() for proper StateService destruction support`); + } + + stateServiceChangedDestructor = stateService.on( StateServiceEvent.CHANGED, () => { + if (!stateService) { + LOG.warn(`Warning! State service was already destroyed.`); + return; + } + savePromise = saveStateDTOFromFile( + writeFile, + stateConfigFile, + stateService.getDTO(), + savePromise, + isDTO, + explainDTO + ); + savePromise.catch( (err) => { + LOG.warn( `Warning! Failed to save state config: ${err}` ); + } ).finally( () => { + savePromise = undefined; + } ); + } ); + + autowireService.setName("stateService", stateService); + autowireService.setName(name, stateService); + + return await method.apply(this, args); + + } catch (err) { + LOG.warn(`Warning! The addStateService decorator for "${propertyName.toString()}" method had an error: `, err); + throw err; + } finally { + + if ( savePromise !== undefined ) { + LOG.debug( `Waiting for state saving...` ); + try { + await savePromise; + } catch (err) { + LOG.warn(`Warning! Saving state failed: `, err); + } + } else { + LOG.debug( `State service wasn't saving.` ); + } + + if ( stateServiceChangedDestructor !== undefined ) { + LOG.debug( `Removing state service listener` ); + stateServiceChangedDestructor(); + } + + autowireService.deleteName("stateService"); + autowireService.deleteName(name); + + if (stateService !== undefined) { + stateService.destroy(); + stateService = undefined; + } + + } + }; + } ); +} + +addStateService.setLogLevel = (level: LogLevel) => LOG.setLogLevel(level); + +async function readStateDTOFromFile ( + readFile: (file : string, charset: "utf8") => Promise, + file: string, + createDTO: (() => DTO), + isDTO: TestCallbackNonStandardOf, + explainDTO: ExplainCallback +): Promise { + try { + const dataString: string = await readFile( file, 'utf8' ); + const data = JSON.parse( dataString ); + if ( !isDTO( data ) ) { + throw new TypeError( `The file "${file}" is not valid DTO: ${explainDTO( data )}` ); + } + LOG.debug( `Config loaded from ${file}` ); + return data; + } catch ( err ) { + if ( (err as any)?.code === 'ENOENT' ) { + LOG.debug( `No configuration file found from ${file}. Creating from fresh.` ); + return createDTO(); + } + throw new TypeError( `Could not read config from "${file}": ${err}` ); + } +} + +async function saveStateDTOFromFile ( + writeFile: (file : string, data: string, charset: "utf8") => Promise, + file: string, + data: DTO, + previousPromise: Promise | undefined, + isDTO: TestCallbackNonStandardOf, + explainDTO: ExplainCallback, +): Promise { + if ( !isDTO( data ) ) { + throw new TypeError( `The file "${file}" is not valid DTO: ${explainDTO( data )}` ); + } + try { + const dataString = JSON.stringify( data, null, 2 ); + if ( previousPromise ) { + try { + await previousPromise; + } catch ( err ) { + LOG.warn( `Warning! Previous promise had error: `, err ); + } + } + await writeFile( file, dataString, 'utf8' ); + LOG.debug( `Config saved to ${file}` ); + } catch ( err ) { + throw new TypeError( `Could not write config to "${file}": ${err}` ); + } +} diff --git a/cmd/main/autowired.test.ts b/cmd/main/autowired.test.ts new file mode 100644 index 0000000..f148adb --- /dev/null +++ b/cmd/main/autowired.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { autowired } from "./autowired"; +import { addAutowired } from "./addAutowired"; +import { AutowireServiceImpl } from "./services/AutowireServiceImpl"; +import { AutowireUtils } from "./utils/AutowireUtils"; +import { LogLevel } from "../../types/LogLevel"; + +describe('autowired', () => { + + beforeAll(() => { + AutowireUtils.setLogLevel(LogLevel.NONE); + addAutowired.setLogLevel(LogLevel.NONE); + autowired.setLogLevel(LogLevel.NONE); + }); + + it('successfully updates metadata and invokes the method with autowired parameters', () => { + let retrievedArg: string = ''; + let retrievedArg2: string = 'xxx'; + let retrievedArg3: string = ''; + + // Define an example class with a method decorated with `addAutowired` + class MyApp { + @addAutowired() + public run( + @autowired('hello') + name: string = '', + @autowired('bar') + bar: string = '', + @autowired('foobar') + foobar: string = '', + ) { + retrievedArg = name; + retrievedArg2 = bar; + retrievedArg3 = foobar; + } + } + + const autowireService = AutowireServiceImpl.create(); + AutowireServiceImpl.setAutowireService( + autowireService + ); + autowireService.setName('hello', 'world'); + autowireService.setName('foobar', 'hello world'); + const app = new MyApp(); + app.run(); + // Check if autowired parameter matches the context + expect(retrievedArg).toEqual('world'); + expect(retrievedArg2).toEqual(''); + expect(retrievedArg3).toEqual('hello world'); + }); + + it('successfully updates metadata and invokes the method with missing autowired parameters', () => { + let retrievedArg: string = ''; + let retrievedArg2: string = 'xxx'; + let retrievedArg3: string = ''; + + // Define an example class with a method decorated with `addAutowired` + class MyApp { + @addAutowired() + public run( + @autowired('hello') + name: string = '', + @autowired('bar') + bar: string = '', + @autowired('foobar') + foobar: string = '', + ) { + retrievedArg = name; + retrievedArg2 = bar; + retrievedArg3 = foobar; + } + } + + const autowireService = AutowireServiceImpl.create(); + AutowireServiceImpl.setAutowireService( + autowireService + ); + autowireService.setName('foobar', 'hello world'); + const app = new MyApp(); + app.run(); + // Check if autowired parameter matches the context + expect(retrievedArg).toEqual(''); + expect(retrievedArg2).toEqual(''); + expect(retrievedArg3).toEqual('hello world'); + }); + +}); diff --git a/cmd/main/autowired.ts b/cmd/main/autowired.ts new file mode 100644 index 0000000..2cbfedd --- /dev/null +++ b/cmd/main/autowired.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../LogService"; +import { AutowireMetadataUtils } from "./utils/AutowireMetadataUtils"; +import { AutowireMetadata } from "./types/AutowireMetadata"; +import { map } from "../../functions/map"; +import { ParameterDecoratorFunction } from "../../decorators/types/ParameterDecoratorFunction"; +import { LogLevel } from "../../types/LogLevel"; + +const LOG = LogService.createLogger( 'autowired' ); + +/** + * Autowires method parameters based on the property name. + * + * Example usage: + * + * ```typescript + * class MyApp { + * + * @addAutowired() + * public static async run ( + * @autowired('args') + * args: string[] = [], + * ): Promise { + * console.log('Hello world'); + * } + * + * } + * + * } + */ +export function autowired ( + paramName : string +) : ParameterDecoratorFunction { + LOG.debug(`1 creating autowired decorator`); + return function autowiredParam ( + target : any | Function, + propertyKey ?: string | symbol, + paramIndex ?: number, + ): void { + LOG.debug(`3 autowiredParam: propertyKey = `, propertyKey, paramIndex); + if (propertyKey !== undefined && paramIndex !== undefined) { + AutowireMetadataUtils.updateMethodMetadata( + target, + propertyKey, + (orig: AutowireMetadata): AutowireMetadata => { + const paramNames : (string | undefined)[] = map(orig?.paramNames ?? [], item => item); + while (paramNames.length < paramIndex) { + paramNames.push(undefined); + } + paramNames[paramIndex] = paramName; + LOG.debug(`4 autowiredParam: "${propertyKey.toString()}": paramNames updated as: `, paramNames); + return { + ...orig, + paramNames + }; + } + ); + } + } +} + +autowired.setLogLevel = (level: LogLevel) => LOG.setLogLevel(level); diff --git a/cmd/main/services/AutowireService.ts b/cmd/main/services/AutowireService.ts new file mode 100644 index 0000000..0b863da --- /dev/null +++ b/cmd/main/services/AutowireService.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Public interface for autowire service + */ +export interface AutowireService { + hasName (name : string) : boolean; + getName (name : string) : T; + setName (name : string, value: T) : void; + deleteName (name : string) : void; +} diff --git a/cmd/main/services/AutowireServiceImpl.test.ts b/cmd/main/services/AutowireServiceImpl.test.ts new file mode 100644 index 0000000..7be72af --- /dev/null +++ b/cmd/main/services/AutowireServiceImpl.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { AutowireService } from './AutowireService'; +import { AutowireServiceImpl } from "./AutowireServiceImpl"; + +describe('AutowireServiceImpl', () => { + let autowireService: AutowireService; + + beforeEach(() => { + autowireService = AutowireServiceImpl.create(); + }); + + describe('with already setup service', () => { + + beforeEach(() => { + AutowireServiceImpl.setAutowireService(autowireService); + }); + + describe('getName', () => { + it('stores and retrieves a value by name', () => { + autowireService.setName('test', 'testValue'); + expect(autowireService.getName('test')).toEqual('testValue'); + }); + + it('throws error when getting a non-existant name', () => { + expect(() => autowireService.getName('nonExistant')).toThrowError('Autowire service did not have name: nonExistant'); + }); + }); + + describe('setName', () => { + it('stores and retrieves a value by name', () => { + autowireService.setName('test', 'testValue'); + expect(autowireService.getName('test')).toEqual('testValue'); + }); + + it('throws error when getting a non-existant name', () => { + expect(() => autowireService.getName('nonExistant')).toThrowError('Autowire service did not have name: nonExistant'); + }); + }); + + describe('hasName', () => { + it('returns true if the name exists', () => { + autowireService.setName('test', 'testValue'); + expect(autowireService.hasName('test')).toBe(true); + }); + + it('returns false if the name does not exist', () => { + expect(autowireService.hasName('nonExistant')).toBe(false); + }); + }); + + describe('deleteName', () => { + it('deletes a name', () => { + autowireService.setName('test', 'testValue'); + autowireService.deleteName('test'); + expect(autowireService.hasName('test')).toBe(false); + }); + }); + + describe('getAutowireService', () => { + it('returns the current autowire service', () => { + expect(AutowireServiceImpl.getAutowireService()).toEqual(autowireService); + }); + }); + + }); + + describe('with uninitialized service', () => { + + beforeEach(() => { + AutowireServiceImpl.setAutowireService(undefined); + }); + + describe('getAutowireService', () => { + it('throws error when no autowire service has been set', () => { + expect(() => AutowireServiceImpl.getAutowireService()).toThrowError('Autowire service has not been initialized'); + }); + }); + + }); + +}); diff --git a/cmd/main/services/AutowireServiceImpl.ts b/cmd/main/services/AutowireServiceImpl.ts new file mode 100644 index 0000000..ffbd9e6 --- /dev/null +++ b/cmd/main/services/AutowireServiceImpl.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { AutowireService } from "./AutowireService"; + +export class AutowireServiceImpl implements AutowireService { + + private static _autowireService : AutowireService | undefined; + + private _data : Map; + + protected constructor () { + this._data = new Map(); + } + + public hasName (name : string) : boolean { + return this._data.has(name); + } + + public getName (name : string) : T { + if (!this.hasName(name)) throw new TypeError(`Autowire service did not have name: ${name}`); + return this._data.get(name); + } + + public setName (name : string, value: T) : void { + this._data.set(name, value); + } + + public deleteName (name : string) : void { + this._data.delete(name); + } + + public static create () { + return new AutowireServiceImpl(); + } + + /** + * Set global autowire service + * @param service + */ + public static setAutowireService (service: AutowireService | undefined) : void { + this._autowireService = service; + } + + /** + * Get global autowire service + */ + public static getAutowireService () : AutowireService { + if (!this._autowireService) throw new TypeError('Autowire service has not been initialized'); + return this._autowireService; + } + +} diff --git a/cmd/main/services/DestroyService.ts b/cmd/main/services/DestroyService.ts new file mode 100644 index 0000000..6e86d0e --- /dev/null +++ b/cmd/main/services/DestroyService.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Disposable } from "../../../types/Disposable"; +import { DisposeAware } from "../../../types/DisposeAware"; +import { ObserverCallback, ObserverDestructor } from "../../../Observer"; +import { VoidCallback } from "../../../interfaces/callbacks"; + +export enum DestroyServiceEvent { + DESTROYED = "DESTROYED" +} + +export type DestroyServiceDestructor = ObserverDestructor; + +export interface DestroyService extends Disposable, DisposeAware { + + /** + * Listen event for disposing + * + * @param name + * @param callback + */ + on ( + name: DestroyServiceEvent.DESTROYED, + callback: ObserverCallback + ): DestroyServiceDestructor; + + /** + * @inheritDoc + */ + destroy (): void; + + /** + * @inheritDoc + */ + isDestroyed () : boolean; + + /** + * Add a listener which will be called once when the service is destroyed. + * + * @param callback + */ + addDestroyListener (callback: VoidCallback) : DestroyServiceDestructor; + + /** + * Register a listener which will be called once when the service is destroyed. + * + * @param obj Disposable object + */ + registerDisposable (obj: Disposable) : DestroyServiceDestructor; + +} diff --git a/cmd/main/services/DestroyServiceImpl.test.ts b/cmd/main/services/DestroyServiceImpl.test.ts new file mode 100644 index 0000000..b9ec5d2 --- /dev/null +++ b/cmd/main/services/DestroyServiceImpl.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { DestroyService } from './DestroyService'; +import { DestroyServiceImpl } from './DestroyServiceImpl'; +import { Disposable } from "../../../types/Disposable"; + +class MockDisposable implements Disposable { + destroyCalled = false; + destroy() { + this.destroyCalled = true; + } +} + +describe('DestroyServiceImpl', () => { + let destroyService: DestroyService; + + beforeEach(() => { + destroyService = DestroyServiceImpl.create(); + }); + + describe('destroy', () => { + it('sets isDestroyed to true', () => { + destroyService.destroy(); + expect(destroyService.isDestroyed()).toBe(true); + }); + }); + + describe('isDestroyed', () => { + it('returns false if not destroyed', () => { + expect(destroyService.isDestroyed()).toBe(false); + }); + + it('returns true if destroyed', () => { + destroyService.destroy(); + expect(destroyService.isDestroyed()).toBe(true); + }); + }); + + describe('addDestroyListener', () => { + + it('calls the callback when destroyed', () => { + const callback = jest.fn(); + destroyService.addDestroyListener(callback); + destroyService.destroy(); + expect(callback).toHaveBeenCalled(); + }); + + it('can be cancelled by calling the destructor', () => { + const callback = jest.fn(); + const destructor = destroyService.addDestroyListener(callback); + destructor(); + destroyService.destroy(); + expect(callback).not.toHaveBeenCalled(); + }); + + }); + + describe('registerDisposable', () => { + it('destroys the disposable when destroyed', () => { + const disposable = new MockDisposable(); + destroyService.registerDisposable(disposable); + destroyService.destroy(); + expect(disposable.destroyCalled).toBe(true); + }); + }); + +}); diff --git a/cmd/main/services/DestroyServiceImpl.ts b/cmd/main/services/DestroyServiceImpl.ts new file mode 100644 index 0000000..460b139 --- /dev/null +++ b/cmd/main/services/DestroyServiceImpl.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DestroyService, DestroyServiceDestructor, DestroyServiceEvent } from "./DestroyService"; +import { Observer, ObserverCallback, ObserverDestructor } from "../../../Observer"; +import { VoidCallback } from "../../../interfaces/callbacks"; +import { Disposable } from "../../../types/Disposable"; + +export class DestroyServiceImpl implements DestroyService { + + private _isDestroyed : boolean; + private readonly _observer: Observer; + + public static Event = DestroyServiceEvent; + + public static create () : DestroyService { + return new DestroyServiceImpl(); + } + + protected constructor () { + this._isDestroyed = false; + this._observer = new Observer( "DestroyServiceImpl" ); + } + + /** + * @inheritDoc + */ + public destroy (): void { + if (this._observer.hasCallbacks(DestroyServiceEvent.DESTROYED)) { + this._observer.triggerEvent(DestroyServiceEvent.DESTROYED); + } + this._isDestroyed = true; + this._observer.destroy(); + } + + /** + * @inheritDoc + */ + public on ( + name: DestroyServiceEvent, + callback: ObserverCallback + ): DestroyServiceDestructor { + return this._observer.listenEvent( name, callback ); + } + + /** + * @inheritDoc + */ + public isDestroyed (): boolean { + return this._isDestroyed; + } + + /** + * @inheritDoc + */ + public addDestroyListener ( + callback: VoidCallback + ) : DestroyServiceDestructor { + let destructor : ObserverDestructor | undefined = this.on( + DestroyServiceEvent.DESTROYED, + () => { + if (destructor) { + destructor(); + destructor = undefined; + callback(); + } + } + ); + return () => { + if (destructor) { + destructor(); + destructor = undefined; + } + } + } + + /** + * @inheritDoc + */ + public registerDisposable ( + obj: Disposable + ) : DestroyServiceDestructor { + return this.addDestroyListener( + () => obj.destroy() + ); + } + +} diff --git a/cmd/main/services/StateService.ts b/cmd/main/services/StateService.ts new file mode 100644 index 0000000..c7a1844 --- /dev/null +++ b/cmd/main/services/StateService.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ObserverCallback, ObserverDestructor } from "../../../Observer"; +import { ReadonlyJsonObject } from "../../../Json"; +import { Disposable } from "../../../types/Disposable"; + +export enum StateServiceEvent { + CHANGED = "changed" +} + +export type StateServiceDestructor = ObserverDestructor; + +export interface StateService extends Disposable { + + destroy (): void; + on ( + name: StateServiceEvent, + callback: ObserverCallback + ): StateServiceDestructor; + + setDTO (dto: DTO) : void; + getDTO () : DTO; + + setStateFileName (value: string | undefined) : void; + getStateFileName () : string | undefined; + +} diff --git a/cmd/main/services/StateServiceImpl.test.ts b/cmd/main/services/StateServiceImpl.test.ts new file mode 100644 index 0000000..20adce5 --- /dev/null +++ b/cmd/main/services/StateServiceImpl.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + jest +} from '@jest/globals'; +import { StateService, StateServiceEvent } from './StateService'; +import { StateServiceImpl } from './StateServiceImpl'; +import { isEqual } from "../../../functions/isEqual"; +import { Observer } from "../../../Observer"; + +// Mock DTO object +type MockDTO = { + key: string; +}; + +class MockStateServiceImpl extends StateServiceImpl { + private _data: MockDTO; + + protected constructor (data: MockDTO) { + super(); + this._data = data; + } + + public static create (data: MockDTO) : StateService { + return new MockStateServiceImpl(data); + } + + public getObserver () : Observer { + return this._observer; + } + + public setDTO (dto: MockDTO): void { + this._data = dto; + if (!isEqual(this._data, dto)) { + if (this._observer.hasCallbacks(StateServiceEvent.CHANGED)) { + this._observer.triggerEvent(StateServiceEvent.CHANGED); + } + } + } + + public getDTO (): MockDTO { + return this._data; + } + +} + +describe('StateServiceImpl', () => { + let stateService: MockStateServiceImpl; + + beforeEach(() => { + stateService = MockStateServiceImpl.create({key: ''}) as MockStateServiceImpl; + }); + + afterEach(() => { + stateService.destroy(); + }); + + describe('on', () => { + it('registers an event listener', () => { + const callback = jest.fn(); + stateService.on(StateServiceEvent.CHANGED, callback); + + // In reality, an event should trigger the callback, but for this test, we manually trigger it + stateService.getObserver().triggerEvent(StateServiceEvent.CHANGED); + + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('setStateFileName', () => { + it('sets the state file name', () => { + stateService.setStateFileName('test.txt'); + expect(stateService.getStateFileName()).toBe('test.txt'); + }); + }); + + describe('getStateFileName', () => { + it('gets the state file name', () => { + stateService.setStateFileName('test.txt'); + expect(stateService.getStateFileName()).toBe('test.txt'); + }); + }); + + describe('setDTO', () => { + it('sets the DTO', () => { + stateService.setDTO({ key: 'value' }); + expect(stateService.getDTO()).toEqual({ key: 'value' }); + }); + }); + + describe('getDTO', () => { + it('gets the DTO', () => { + stateService.setDTO({ key: 'value' }); + expect(stateService.getDTO()).toEqual({ key: 'value' }); + }); + }); + +}); diff --git a/cmd/main/services/StateServiceImpl.ts b/cmd/main/services/StateServiceImpl.ts new file mode 100644 index 0000000..6632dcb --- /dev/null +++ b/cmd/main/services/StateServiceImpl.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Observer, ObserverCallback } from "../../../Observer"; +import { StateService, StateServiceDestructor, StateServiceEvent } from "./StateService"; +import { ReadonlyJsonObject } from "../../../Json"; + +export abstract class StateServiceImpl implements StateService { + + private _fileName : string | undefined; + protected readonly _observer: Observer; + + public static Event = StateServiceEvent; + + protected constructor () { + this._observer = new Observer( "StateServiceImpl" ); + } + + public destroy (): void { + this._observer.destroy(); + } + + public on ( + name: StateServiceEvent, + callback: ObserverCallback + ): StateServiceDestructor { + return this._observer.listenEvent( name, callback ); + } + + public setStateFileName (value: string | undefined) : void { + this._fileName = value; + } + public getStateFileName () : string | undefined { + return this._fileName; + } + + abstract setDTO (dto: DTO) : void; + abstract getDTO () : DTO; + +} diff --git a/cmd/main/types/AutowireMetadata.test.ts b/cmd/main/types/AutowireMetadata.test.ts new file mode 100644 index 0000000..76ceacb --- /dev/null +++ b/cmd/main/types/AutowireMetadata.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + AutowireMetadata, + createAutowireMetadata, + isAutowireMetadata, + explainAutowireMetadata, + parseAutowireMetadata, + isAutowireMetadataOrUndefined, + explainAutowireMetadataOrUndefined, +} from './AutowireMetadata'; + +describe('AutowireMetadata', () => { + + describe('createAutowireMetadata', () => { + it('creates autowire metadata', () => { + const paramNames = ['param1', 'param2']; + const metadata: AutowireMetadata = createAutowireMetadata(paramNames); + + expect(metadata).toEqual({ + paramNames: ['param1', 'param2'], + }); + }); + }); + + describe('isAutowireMetadata', () => { + it('recognizes autowire metadata', () => { + const validMetadata: AutowireMetadata = { paramNames: ['param1', 'param2'] }; + const invalidMetadata = { paramNames: 'not an array' }; + + expect(isAutowireMetadata(validMetadata)).toBe(true); + expect(isAutowireMetadata(invalidMetadata)).toBe(false); + }); + }); + + describe('explainAutowireMetadata', () => { + it('explains autowire metadata', () => { + const validMetadata: AutowireMetadata = { paramNames: ['param1', 'param2'] }; + const invalidMetadata = { paramNames: 'not an array' }; + + expect(explainAutowireMetadata(validMetadata)).toMatch('OK'); + expect(explainAutowireMetadata(invalidMetadata)).toMatch('property "paramNames" not string[]'); + }); + }); + + describe('parseAutowireMetadata', () => { + it('parses autowire metadata', () => { + const validMetadata: AutowireMetadata = { paramNames: ['param1', 'param2'] }; + const invalidMetadata = { paramNames: 'not an array' }; + + expect(parseAutowireMetadata(validMetadata)).toEqual(validMetadata); + expect(parseAutowireMetadata(invalidMetadata)).toBeUndefined(); + }); + }); + + describe('isAutowireMetadataOrUndefined', () => { + it('checks for autowire metadata or undefined', () => { + const validMetadata: AutowireMetadata = { paramNames: ['param1', 'param2'] }; + const invalidMetadata = { paramNames: 'not an array' }; + + expect(isAutowireMetadataOrUndefined(validMetadata)).toBe(true); + expect(isAutowireMetadataOrUndefined(undefined)).toBe(true); + expect(isAutowireMetadataOrUndefined(invalidMetadata)).toBe(false); + }); + }); + + describe('explainAutowireMetadataOrUndefined', () => { + it('explains autowire metadata or undefined', () => { + const validMetadata: AutowireMetadata = { paramNames: ['param1', 'param2'] }; + const invalidMetadata = { paramNames: 'not an array' }; + + expect(explainAutowireMetadataOrUndefined(validMetadata)).toMatch('OK'); + expect(explainAutowireMetadataOrUndefined(undefined)).toMatch('OK'); + expect(explainAutowireMetadataOrUndefined(invalidMetadata)).toMatch('not AutowireMetadata or undefined'); + }); + }); + +}); diff --git a/cmd/main/types/AutowireMetadata.ts b/cmd/main/types/AutowireMetadata.ts new file mode 100644 index 0000000..f13259b --- /dev/null +++ b/cmd/main/types/AutowireMetadata.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainStringArray, isStringArray } from "../../../types/StringArray"; + +export interface AutowireMetadata { + readonly paramNames: readonly (string|undefined)[]; +} + +export function createAutowireMetadata ( + paramNames : readonly string[] +) : AutowireMetadata { + return { + paramNames + }; +} + +export function isAutowireMetadata (value: unknown) : value is AutowireMetadata { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'paramNames', + ]) + && isStringArray(value?.paramNames) + ); +} + +export function explainAutowireMetadata (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'paramNames', + ]) + , explainProperty("paramNames", explainStringArray(value?.paramNames)) + ] + ); +} + +export function parseAutowireMetadata (value: unknown) : AutowireMetadata | undefined { + if (isAutowireMetadata(value)) return value; + return undefined; +} + +export function isAutowireMetadataOrUndefined (value: unknown): value is AutowireMetadata | undefined { + return isUndefined(value) || isAutowireMetadata(value); +} + +export function explainAutowireMetadataOrUndefined (value: unknown): string { + return isAutowireMetadataOrUndefined(value) ? explainOk() : explainNot(explainOr(['AutowireMetadata', 'undefined'])); +} diff --git a/cmd/main/types/MainApp.ts b/cmd/main/types/MainApp.ts new file mode 100644 index 0000000..87a52fc --- /dev/null +++ b/cmd/main/types/MainApp.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CommandExitStatus } from "../../types/CommandExitStatus"; + +export interface MainApp { + run ( + args: readonly string[] + ) : Promise | CommandExitStatus; +} diff --git a/cmd/main/types/MainFunction.ts b/cmd/main/types/MainFunction.ts new file mode 100644 index 0000000..4f6cb61 --- /dev/null +++ b/cmd/main/types/MainFunction.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CommandExitStatus } from "../../types/CommandExitStatus"; + +export interface MainFunction { + ( + args: readonly string[] + ) : Promise | CommandExitStatus; +} diff --git a/cmd/main/utils/AutowireMetadataUtils.test.ts b/cmd/main/utils/AutowireMetadataUtils.test.ts new file mode 100644 index 0000000..850c670 --- /dev/null +++ b/cmd/main/utils/AutowireMetadataUtils.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + jest +} from '@jest/globals'; +import "reflect-metadata"; +import { AutowireMetadataUtils } from './AutowireMetadataUtils'; +import { createAutowireMetadata } from '../types/AutowireMetadata'; + +describe('AutowireMetadataUtils', () => { + + let targetMock : any; + let methodNameMock : any; + + beforeEach(() => { + + jest.spyOn(Reflect, 'getMetadata'); + jest.spyOn(Reflect, 'defineMetadata'); + + // Clear all mocks before each test + jest.clearAllMocks(); + + // Mock target object and methodName + targetMock = {}; + methodNameMock = 'methodName'; + + }); + + describe('getMethodMetadata', () => { + it('should return the metadata of the method', () => { + const expectedMetadata = createAutowireMetadata(['param1', 'param2']); + (Reflect.getMetadata as any).mockReturnValue(expectedMetadata); + + const metadata = AutowireMetadataUtils.getMethodMetadata(targetMock, methodNameMock); + + expect(metadata).toBe(expectedMetadata); + expect(Reflect.getMetadata).toHaveBeenCalledWith(expect.any(Symbol), targetMock, methodNameMock); + }); + }); + + describe('updateMethodMetadata', () => { + it('should update the metadata of the method', () => { + const initialMetadata = createAutowireMetadata(['param1', 'param2']); + const expectedMetadata = createAutowireMetadata(['param3', 'param4']); + + (Reflect.getMetadata as any).mockReturnValue(initialMetadata); + + AutowireMetadataUtils.updateMethodMetadata(targetMock, methodNameMock, () => expectedMetadata); + + expect(Reflect.defineMetadata).toHaveBeenCalledWith(expect.any(Symbol), expectedMetadata, targetMock, methodNameMock); + }); + }); + +}); diff --git a/cmd/main/utils/AutowireMetadataUtils.ts b/cmd/main/utils/AutowireMetadataUtils.ts new file mode 100644 index 0000000..fac7d87 --- /dev/null +++ b/cmd/main/utils/AutowireMetadataUtils.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "reflect-metadata"; +import { AutowireMetadata, createAutowireMetadata } from "../types/AutowireMetadata"; + +const METADATA_KEY = Symbol("autowiredMetadata"); + +export class AutowireMetadataUtils { + + public static getMethodMetadata ( + target : any, + methodName : string | symbol, + ) : AutowireMetadata { + return Reflect.getMetadata(METADATA_KEY, target, methodName); + } + + public static updateMethodMetadata ( + target : any, + methodName : string | symbol, + setValue : (metadata: AutowireMetadata) => AutowireMetadata + ) : void { + let metadata: AutowireMetadata = Reflect.getMetadata(METADATA_KEY, target, methodName) || createAutowireMetadata( + [], + ); + metadata = setValue(metadata); + Reflect.defineMetadata(METADATA_KEY, metadata, target, methodName); + } + +} diff --git a/cmd/main/utils/AutowireUtils.test.ts b/cmd/main/utils/AutowireUtils.test.ts new file mode 100644 index 0000000..94cbfc1 --- /dev/null +++ b/cmd/main/utils/AutowireUtils.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { AutowireServiceImpl } from '../services/AutowireServiceImpl'; +import { AutowireMetadataUtils } from './AutowireMetadataUtils'; +import { AutowireUtils } from './AutowireUtils'; +import { LogLevel } from "../../../types/LogLevel"; +import { AutowireService } from "../services/AutowireService"; + +jest.mock('../services/AutowireServiceImpl', () => ({ + AutowireServiceImpl: { + getAutowireService: jest.fn() + } +})); + +jest.mock('./AutowireMetadataUtils', () => ({ + AutowireMetadataUtils: { + getMethodMetadata: jest.fn() + } +})); + +describe('AutowireUtils', () => { + + let autowireServiceMock : Partial; + let mockTarget : {propertyName: jest.Mock}; + + beforeAll( () => { + AutowireUtils.setLogLevel(LogLevel.NONE); + }); + + beforeEach(() => { + + // Clear all mocks before each test + jest.clearAllMocks(); + + // Setup mock functions + autowireServiceMock = { + setName: jest.fn(), + hasName: jest.fn(), + getName: jest.fn(), + deleteName: jest.fn() + }; + + // Mock target object + mockTarget = { + propertyName: jest.fn() + }; + + (AutowireServiceImpl.getAutowireService as any).mockReturnValue(autowireServiceMock); + (AutowireMetadataUtils.getMethodMetadata as any).mockReturnValue({ paramNames: ['name', 'age'] }); + + (autowireServiceMock.hasName as any).mockImplementation((name: any) : boolean => name === 'name' || name === 'age'); + (autowireServiceMock.getName as any).mockImplementation((name: any) => name === 'name' ? 'John' : 30); + }); + + describe('autowireValues', () => { + it('should return autowired values', () => { + (autowireServiceMock.hasName as any).mockReturnValue(true); + + const autowiredValues = AutowireUtils.autowireValues(autowireServiceMock as AutowireService, ['name', 'age']); + + expect(autowireServiceMock.hasName).toHaveBeenCalledTimes(2); + expect(autowiredValues).toEqual(['John', 30]); + }); + }); + + describe('autowireApply', () => { + + it('should apply autowired values to the method', () => { + const values = { + name: 'John', + age: 30 + }; + + AutowireUtils.autowireApply(mockTarget, 'propertyName', mockTarget.propertyName, values); + + // Check if mock function was called with autowired values + expect(mockTarget.propertyName).toHaveBeenCalledWith('John', 30); + }); + + it('should clean up autowired values after method call', () => { + const values = { + name: 'John', + age: 30 + }; + + AutowireUtils.autowireApply(mockTarget, 'propertyName', mockTarget.propertyName, values); + + // Check if autowired values are deleted after method call + expect(autowireServiceMock.deleteName).toHaveBeenCalledTimes(3); // Once for each key in the values and once for 'autowireService' + }); + + }); + +}); diff --git a/cmd/main/utils/AutowireUtils.ts b/cmd/main/utils/AutowireUtils.ts new file mode 100644 index 0000000..efb1dd9 --- /dev/null +++ b/cmd/main/utils/AutowireUtils.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { keys } from "../../../functions/keys"; +import { forEach } from "../../../functions/forEach"; +import { LogLevel } from "../../../types/LogLevel"; +import { AutowireService } from "../services/AutowireService"; +import { AutowireMetadata } from "../types/AutowireMetadata"; +import { LogService } from "../../../LogService"; +import { AutowireServiceImpl } from "../services/AutowireServiceImpl"; +import { AutowireMetadataUtils } from "./AutowireMetadataUtils"; + +const LOG = LogService.createLogger( 'AutowireUtils' ); + +/** + * Can be used to autowire parameters to a function call automatically using + * an AutowireManager instance. + */ +export class AutowireUtils { + + public static setLogLevel (level: LogLevel) : void { + LOG.setLogLevel(level); + } + + /** + * Populates the arguments based on autowired parameter names. + * + * @param autowireService + * @param paramNames + */ + public static autowireValues ( + autowireService: AutowireService, + paramNames: readonly (string|undefined)[], + ): any[] { + return map( + paramNames, + (name: string | undefined) => { + return name && autowireService.hasName(name) ? autowireService.getName(name) : undefined; + } + ); + } + + public static autowireApply ( + target : any, + propertyName : string | symbol, + method : Function, + values ?: {readonly [name: string]: any} + ) : any { + const metadata : AutowireMetadata = AutowireMetadataUtils.getMethodMetadata(target, propertyName); + LOG.debug(`autowired metadata = `, metadata); + const autowireService : AutowireService = AutowireServiceImpl.getAutowireService(); + const initializedValues = values ?? {}; + LOG.debug(`autowired initializedValues = `, initializedValues); + const valueKeys = keys(initializedValues); + LOG.debug(`autowired valueKeys = `, valueKeys); + try { + forEach( + valueKeys, + (key: string) => { + autowireService.setName(key, initializedValues[key]); + } + ); + autowireService.setName("autowireService", autowireService); + LOG.debug(`autowired param names = `, metadata?.paramNames ?? []); + const autowiredArgs = AutowireUtils.autowireValues( + autowireService, + metadata?.paramNames ?? [] + ); + LOG.debug(`autowired args = `, autowiredArgs); + return method.apply( target, autowiredArgs ); + } finally { + forEach( + valueKeys, + (key: string) => { + autowireService.deleteName(key); + } + ); + autowireService.deleteName("autowireService"); + } + } + +} diff --git a/cmd/pkg/HgPackageCommandService.ts b/cmd/pkg/HgPackageCommandService.ts new file mode 100644 index 0000000..05eb26e --- /dev/null +++ b/cmd/pkg/HgPackageCommandService.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CommandExitStatus } from "../types/CommandExitStatus"; + +/** + */ +export interface HgPackageCommandService { + + /** + * The main command line handler + * + * @param args + */ + main (args: readonly string[]) : Promise; + +} diff --git a/cmd/pkg/HgPackageCommandServiceImpl.ts b/cmd/pkg/HgPackageCommandServiceImpl.ts new file mode 100644 index 0000000..6f239f3 --- /dev/null +++ b/cmd/pkg/HgPackageCommandServiceImpl.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { existsSync } from 'fs'; +import { opendir } from 'node:fs/promises'; +import { execSync } from 'child_process'; +import { join as pathJoin } from 'path'; +import { has } from "../../functions/has"; +import { keys } from "../../functions/keys"; +import { map } from "../../functions/map"; +import { trim } from "../../functions/trim"; +import { uniq } from "../../functions/uniq"; +import { ReadonlyJsonAny } from "../../Json"; +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { HgPackageCommandService } from "./HgPackageCommandService"; +import { readFile } from 'node:fs/promises'; + +export class HgPackageCommandServiceImpl implements HgPackageCommandService { + + public static create () : HgPackageCommandServiceImpl { + return new HgPackageCommandServiceImpl(); + } + + protected constructor ( + ) { + } + + public async main (args: readonly string[]): Promise { + + const withNpm : boolean = args.includes('with-npm'); + + const workingDir = process.cwd(); + let pkgDirectories : string[] = await this._getPkgDirectories(workingDir); + if (pkgDirectories.length === 0) { + console.error(`Could not find any directories with package.json`); + return CommandExitStatus.CONFLICT; + } + + let allDependencies : { + [subDir: string]: { + [pkgName: string]: string + } + } = {}; + + for (let i = 0; i pkgStatus[packageName][dir].version)); + if (pkgVersions.length >= 2) { + console.log(`Multiple versions available for ${packageName}: ${ + map( + subDirs, + (dir: string) : string => { + const version = pkgStatus[packageName][dir].version; + return `${dir} (${version})`; + } + ).join(' ') + }`); + } + + } + + return CommandExitStatus.OK; + } + + private async _readJson (file : string): Promise { + try { + const contents = await readFile(file, { encoding: 'utf8' }); + return JSON.parse(contents); + } catch (err) { + throw new Error(`Could not read file ${file}: ${err}`); + } + + } + + private async _getPkgDirectories (workingDir: string) : Promise { + const localPackageJson = pathJoin(workingDir, 'package.json'); + if (existsSync( localPackageJson )) { + return [ workingDir ]; + } + return await this._getSubPkgDirectories( workingDir ); + } + + private async _getSubPkgDirectories (workingDir: string) : Promise { + let pkgDirectories : string[] = []; + const dir = await opendir('./'); + for await (const dirent of dir) { + if (!dirent.isDirectory()) continue; + const subDir = pathJoin(workingDir, dirent.name); + const packageJsonFile = pathJoin(subDir, 'package.json'); + if (existsSync(packageJsonFile)) { + pkgDirectories.push(subDir); + } + } + return pkgDirectories; + } + +} diff --git a/cmd/testing/README.md b/cmd/testing/README.md new file mode 100644 index 0000000..4a5dd92 --- /dev/null +++ b/cmd/testing/README.md @@ -0,0 +1,10 @@ +## System testing library + +These functions are meant to be used from inside tests that test command line +tools. + +* `log(message)` - Print short log message +* `chdir(dir)` - Change current working directory +* `run(cmd)` - Run command (inside shell!) + +Note! Keep in mind, they are synchronous. diff --git a/cmd/testing/chdir.ts b/cmd/testing/chdir.ts new file mode 100644 index 0000000..96953d7 --- /dev/null +++ b/cmd/testing/chdir.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { log } from "./log"; + +/** + * Change the current working directory. + * + * Utility for testing framework. + * + * @param dir + */ +export function chdir (dir: string) : void { + try { + log(`# cd ${dir}`) + process.chdir(dir); + } catch (err) { + throw new Error(`Directory change to '${dir}' failed: ${err}`); + } +} diff --git a/cmd/testing/log.ts b/cmd/testing/log.ts new file mode 100644 index 0000000..a257daa --- /dev/null +++ b/cmd/testing/log.ts @@ -0,0 +1,13 @@ + +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Write log message. + * + * Utility for testing framework. + * + * @param message + */ +export function log (message: string) { + console.log(message); +} diff --git a/cmd/testing/run.ts b/cmd/testing/run.ts new file mode 100644 index 0000000..6ae7035 --- /dev/null +++ b/cmd/testing/run.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { execSync } from "child_process"; +import { log } from "./log"; + +/** + * Run command on system synchronously and return data as `Buffer`. + * + * Utility for testing framework. + * + * @param command + */ +export function run (command : string) { + try { + log(`# ${command}`) + return execSync(command); + } catch (err) { + throw new Error(`Command '${command}' failed: ${err}`); + } +} diff --git a/cmd/types/ArgumentConfiguration.ts b/cmd/types/ArgumentConfiguration.ts new file mode 100644 index 0000000..b50e71d --- /dev/null +++ b/cmd/types/ArgumentConfiguration.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ArgumentConfigurationWithEnv } from "./ArgumentConfigurationWithEnv"; +import { ArgumentConfigurationWithoutEnv } from "./ArgumentConfigurationWithoutEnv"; +import { ArgumentConfigurationWithEnvAndDefaultValue } from "./ArgumentConfigurationWithEnvAndDefaultValue"; + +/** + * User defined program arguments. + * + * Type | Long argument | Short argument | Environment variable name | default value + * + * @example + * [ ArgumentType.STRING, '--backend', undefined, undefined, 'localhost' ] + * @example + * [ ArgumentType.STRING, undefined, '-b', undefined, 'localhost' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b', undefined, 'localhost' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b', 'AGENT_BACKEND', 'localhost' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b', 'AGENT_BACKEND' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b' ] + */ +export type ArgumentConfiguration = ( + ArgumentConfigurationWithEnv + | ArgumentConfigurationWithoutEnv + | ArgumentConfigurationWithEnvAndDefaultValue + ); diff --git a/cmd/types/ArgumentConfigurationMap.ts b/cmd/types/ArgumentConfigurationMap.ts new file mode 100644 index 0000000..de7293b --- /dev/null +++ b/cmd/types/ArgumentConfigurationMap.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ArgumentConfiguration } from "./ArgumentConfiguration"; + +/** + * User defined program arguments. + * + * @example + * { + * backend : [ ArgumentType.STRING, '--backend', '-b', 'AGENT_BACKEND', 'localhost' ] + * } + */ +export interface ArgumentConfigurationMap { + [key: string]: ArgumentConfiguration; +} diff --git a/cmd/types/ArgumentConfigurationWithEnv.ts b/cmd/types/ArgumentConfigurationWithEnv.ts new file mode 100644 index 0000000..1a1d635 --- /dev/null +++ b/cmd/types/ArgumentConfigurationWithEnv.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { UserDefinedArgumentType } from "./UserDefinedArgumentType"; + +/** + * User defined program arguments. + * + * Type | Long argument | Short argument | Environment variable name + * + * @example + * [ ArgumentType.STRING, '--backend', undefined, undefined ] + * @example + * [ ArgumentType.STRING, undefined, '-b', undefined ] + * @example + * [ ArgumentType.STRING, '--backend', '-b', undefined ] + * @example + * [ ArgumentType.STRING, '--backend', '-b', 'AGENT_BACKEND' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b' ] + */ +export type ArgumentConfigurationWithEnv = readonly [ + UserDefinedArgumentType, + string | undefined, + string | undefined, + string | undefined +]; diff --git a/cmd/types/ArgumentConfigurationWithEnvAndDefaultValue.ts b/cmd/types/ArgumentConfigurationWithEnvAndDefaultValue.ts new file mode 100644 index 0000000..369d718 --- /dev/null +++ b/cmd/types/ArgumentConfigurationWithEnvAndDefaultValue.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { UserDefinedArgumentType } from "./UserDefinedArgumentType"; + +/** + * User defined program arguments. + * + * Type | Long argument | Short argument | Environment variable name | default value + * + * @example + * [ ArgumentType.STRING, '--backend', undefined, undefined, 'localhost' ] + * @example + * [ ArgumentType.STRING, undefined, '-b', undefined, 'localhost' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b', undefined, 'localhost' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b', 'AGENT_BACKEND', 'localhost' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b', 'AGENT_BACKEND' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b' ] + */ +export type ArgumentConfigurationWithEnvAndDefaultValue = readonly [ + UserDefinedArgumentType, + string | undefined, + string | undefined, + string | undefined, + string | undefined +]; diff --git a/cmd/types/ArgumentConfigurationWithoutEnv.ts b/cmd/types/ArgumentConfigurationWithoutEnv.ts new file mode 100644 index 0000000..302de69 --- /dev/null +++ b/cmd/types/ArgumentConfigurationWithoutEnv.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { UserDefinedArgumentType } from "./UserDefinedArgumentType"; + +/** + * User defined program arguments. + * + * Type | Long argument | Short argument + * + * @example + * [ ArgumentType.STRING, '--backend', undefined ] + * @example + * [ ArgumentType.STRING, undefined, '-b' ] + * @example + * [ ArgumentType.STRING, '--backend', '-b' ] + */ +export type ArgumentConfigurationWithoutEnv = readonly [ + UserDefinedArgumentType, + string | undefined, + string | undefined +]; diff --git a/cmd/types/ArgumentType.ts b/cmd/types/ArgumentType.ts new file mode 100644 index 0000000..0e17d29 --- /dev/null +++ b/cmd/types/ArgumentType.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export enum ArgumentType { + "BOOLEAN" = "b", + "STRING" = "s", + "NON_EMPTY_STRING" = "s+", + "NUMBER" = "n", + "INTEGER" = "i" +} diff --git a/cmd/types/ArgumentValueMap.ts b/cmd/types/ArgumentValueMap.ts new file mode 100644 index 0000000..e34258d --- /dev/null +++ b/cmd/types/ArgumentValueMap.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * User defined arguments and parsed values. + * + * @example + * { + * "bar": 123, + * "feature": true + * } + */ +export interface ArgumentValueMap { + [key: string]: string | boolean | number; +} diff --git a/cmd/types/CommandArgumentType.test.ts b/cmd/types/CommandArgumentType.test.ts new file mode 100644 index 0000000..b46d1e0 --- /dev/null +++ b/cmd/types/CommandArgumentType.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ShortCommandArgument, LongCommandArgument, CommandArgumentType, parseCommandArgumentType } from './CommandArgumentType'; + +describe('CommandArgumentType', () => { + + describe('parseCommandArgumentType', () => { + + it('should return correct CommandArgumentType for valid inputs', () => { + expect(parseCommandArgumentType(ShortCommandArgument.HELP)).toEqual(CommandArgumentType.HELP); + expect(parseCommandArgumentType(LongCommandArgument.HELP)).toEqual(CommandArgumentType.HELP); + expect(parseCommandArgumentType(CommandArgumentType.HELP)).toEqual(CommandArgumentType.HELP); + + expect(parseCommandArgumentType(ShortCommandArgument.VERSION)).toEqual(CommandArgumentType.VERSION); + expect(parseCommandArgumentType(LongCommandArgument.VERSION)).toEqual(CommandArgumentType.VERSION); + expect(parseCommandArgumentType(CommandArgumentType.VERSION)).toEqual(CommandArgumentType.VERSION); + + expect(parseCommandArgumentType(LongCommandArgument.DISABLE_ARGUMENT_PARSING)).toEqual(CommandArgumentType.DISABLE_ARGUMENT_PARSING); + expect(parseCommandArgumentType(CommandArgumentType.DISABLE_ARGUMENT_PARSING)).toEqual(CommandArgumentType.DISABLE_ARGUMENT_PARSING); + }); + + it('should return undefined for invalid inputs', () => { + expect(parseCommandArgumentType('-invalid')).toBeUndefined(); + expect(parseCommandArgumentType('--invalid')).toBeUndefined(); + expect(parseCommandArgumentType('INVALID')).toBeUndefined(); + // test other invalid inputs... + }); + + }); + +}); diff --git a/cmd/types/CommandArgumentType.ts b/cmd/types/CommandArgumentType.ts new file mode 100644 index 0000000..ad51191 --- /dev/null +++ b/cmd/types/CommandArgumentType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +export const enum ShortCommandArgument { + HELP = '-h', + VERSION = '-v' +} + +export const enum LongCommandArgument { + HELP = '--help', + VERSION = '--version', + DISABLE_ARGUMENT_PARSING = '--' +} + +export const enum CommandArgumentType { + HELP, + VERSION, + DISABLE_ARGUMENT_PARSING +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function parseCommandArgumentType (value : any) : CommandArgumentType | undefined { + + switch (value) { + + case ShortCommandArgument.HELP: + case LongCommandArgument.HELP: + case CommandArgumentType.HELP: + return CommandArgumentType.HELP; + + case ShortCommandArgument.VERSION: + case LongCommandArgument.VERSION: + case CommandArgumentType.VERSION: + return CommandArgumentType.VERSION; + + case LongCommandArgument.DISABLE_ARGUMENT_PARSING: + case CommandArgumentType.DISABLE_ARGUMENT_PARSING: + return CommandArgumentType.DISABLE_ARGUMENT_PARSING; + + } + + return undefined; +} diff --git a/cmd/types/CommandExitStatus.test.ts b/cmd/types/CommandExitStatus.test.ts new file mode 100644 index 0000000..c219a06 --- /dev/null +++ b/cmd/types/CommandExitStatus.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { CommandExitStatus, isCommandExitStatus, stringifyCommandExitStatus, parseCommandExitStatus } from './CommandExitStatus'; + +describe('CommandExitStatus', () => { + + describe('isCommandExitStatus', () => { + + it('should return true for valid CommandExitStatus values', () => { + expect(isCommandExitStatus(CommandExitStatus.OK)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.GENERAL_ERRORS)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.MISUSE_OF_SHELL_BUILTINS)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.ARGUMENT_PARSE_ERROR)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.UNKNOWN_ARGUMENT)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.UNIMPLEMENTED_FEATURE)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.FATAL_ERROR)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.USAGE)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.DATAERR)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.NOINPUT)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.NOUSER)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.NOHOST)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.UNAVAILABLE)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.SOFTWARE)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.OSERR)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.OSFILE)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.CANTCREAT)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.IOERR)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.TEMPFAIL)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.PROTOCOL)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.NOPERM)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.CONFIG)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.COMMAND_INVOKED_CANNOT_EXECUTE)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.COMMAND_NOT_FOUND)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.INVALID_ARGUMENT_TO_EXIT)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.FATAL_SIGNAL_RANGE_START)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.FATAL_SIGNAL_RANGE_END)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.EXIT_STATUS_OUT_OF_RANGE)).toBeTruthy(); + expect(isCommandExitStatus(CommandExitStatus.CONFLICT)).toBeTruthy(); + }); + + it('should return false for invalid CommandExitStatus values', () => { + expect(isCommandExitStatus(-1)).toBeFalsy(); + expect(isCommandExitStatus(256)).toBeFalsy(); + expect(isCommandExitStatus([])).toBeFalsy(); + expect(isCommandExitStatus([1])).toBeFalsy(); + expect(isCommandExitStatus({})).toBeFalsy(); + expect(isCommandExitStatus({'1':'hello'})).toBeFalsy(); + expect(isCommandExitStatus({'foo':'bar'})).toBeFalsy(); + expect(isCommandExitStatus(true)).toBeFalsy(); + expect(isCommandExitStatus(false)).toBeFalsy(); + expect(isCommandExitStatus(null)).toBeFalsy(); + expect(isCommandExitStatus(undefined)).toBeFalsy(); + expect(isCommandExitStatus('1')).toBeFalsy(); + expect(isCommandExitStatus('123')).toBeFalsy(); + expect(isCommandExitStatus('INVALID_VALUE')).toBeFalsy(); + // test other invalid values... + }); + + it('should return true for CommandExitStatus.FATAL_SIGNAL_1 until FATAL_SIGNAL_64', () => { + expect(isCommandExitStatus(129)).toBeTruthy(); + expect(isCommandExitStatus(130)).toBeTruthy(); + // ...test other enum values in this range... + expect(isCommandExitStatus(192)).toBeTruthy(); + expect(isCommandExitStatus(193)).toBeFalsy(); + }); + + }); + + describe('stringifyCommandExitStatus', () => { + + it('should return correct string representation for CommandExitStatus values', () => { + expect(stringifyCommandExitStatus(CommandExitStatus.OK)).toEqual('OK'); + expect(stringifyCommandExitStatus(CommandExitStatus.GENERAL_ERRORS)).toEqual('GENERAL_ERRORS'); + expect(stringifyCommandExitStatus(CommandExitStatus.MISUSE_OF_SHELL_BUILTINS)).toEqual('MISUSE_OF_SHELL_BUILTINS'); + expect(stringifyCommandExitStatus(CommandExitStatus.ARGUMENT_PARSE_ERROR)).toEqual('ARGUMENT_PARSE_ERROR'); + expect(stringifyCommandExitStatus(CommandExitStatus.UNKNOWN_ARGUMENT)).toEqual('UNKNOWN_ARGUMENT'); + expect(stringifyCommandExitStatus(CommandExitStatus.UNIMPLEMENTED_FEATURE)).toEqual('UNIMPLEMENTED_FEATURE'); + expect(stringifyCommandExitStatus(CommandExitStatus.FATAL_ERROR)).toEqual('FATAL_ERROR'); + expect(stringifyCommandExitStatus(CommandExitStatus.USAGE)).toEqual('USAGE'); + expect(stringifyCommandExitStatus(CommandExitStatus.DATAERR)).toEqual('DATAERR'); + expect(stringifyCommandExitStatus(CommandExitStatus.NOINPUT)).toEqual('NOINPUT'); + expect(stringifyCommandExitStatus(CommandExitStatus.NOUSER)).toEqual('NOUSER'); + expect(stringifyCommandExitStatus(CommandExitStatus.NOHOST)).toEqual('NOHOST'); + expect(stringifyCommandExitStatus(CommandExitStatus.UNAVAILABLE)).toEqual('UNAVAILABLE'); + expect(stringifyCommandExitStatus(CommandExitStatus.SOFTWARE)).toEqual('SOFTWARE'); + expect(stringifyCommandExitStatus(CommandExitStatus.OSERR)).toEqual('OSERR'); + expect(stringifyCommandExitStatus(CommandExitStatus.OSFILE)).toEqual('OSFILE'); + expect(stringifyCommandExitStatus(CommandExitStatus.CANTCREAT)).toEqual('CANTCREAT'); + expect(stringifyCommandExitStatus(CommandExitStatus.IOERR)).toEqual('IOERR'); + expect(stringifyCommandExitStatus(CommandExitStatus.TEMPFAIL)).toEqual('TEMPFAIL'); + expect(stringifyCommandExitStatus(CommandExitStatus.PROTOCOL)).toEqual('PROTOCOL'); + expect(stringifyCommandExitStatus(CommandExitStatus.NOPERM)).toEqual('NOPERM'); + expect(stringifyCommandExitStatus(CommandExitStatus.CONFIG)).toEqual('CONFIG'); + expect(stringifyCommandExitStatus(CommandExitStatus.COMMAND_INVOKED_CANNOT_EXECUTE)).toEqual('COMMAND_INVOKED_CANNOT_EXECUTE'); + expect(stringifyCommandExitStatus(CommandExitStatus.COMMAND_NOT_FOUND)).toEqual('COMMAND_NOT_FOUND'); + expect(stringifyCommandExitStatus(CommandExitStatus.INVALID_ARGUMENT_TO_EXIT)).toEqual('INVALID_ARGUMENT_TO_EXIT'); + expect(stringifyCommandExitStatus(CommandExitStatus.FATAL_SIGNAL_RANGE_START)).toEqual('FATAL_SIGNAL_1'); + expect(stringifyCommandExitStatus(CommandExitStatus.FATAL_SIGNAL_RANGE_END)).toEqual('FATAL_SIGNAL_64'); + expect(stringifyCommandExitStatus(CommandExitStatus.EXIT_STATUS_OUT_OF_RANGE)).toEqual('EXIT_STATUS_OUT_OF_RANGE'); + expect(stringifyCommandExitStatus(CommandExitStatus.CONFLICT)).toEqual('CONFLICT'); + }); + + it('should throw an error for invalid CommandExitStatus values', () => { + // @ts-ignore + expect(() => stringifyCommandExitStatus(-1)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus(256)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus([])).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus([1])).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus({})).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus({'1':'hello'})).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus({'foo':'bar'})).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus(true)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus(false)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus(null)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus(undefined)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus('1')).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus('123')).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifyCommandExitStatus('INVALID_VALUE')).toThrowError(TypeError); + }); + + it('should return correct string representation for CommandExitStatus.FATAL_SIGNAL_0 and beyond', () => { + expect(stringifyCommandExitStatus(129)).toEqual('FATAL_SIGNAL_1'); + // @ts-ignore + expect(stringifyCommandExitStatus(130)).toEqual('FATAL_SIGNAL_2'); + // ...test other enum values in this range... + // @ts-ignore + expect(stringifyCommandExitStatus(192)).toEqual('FATAL_SIGNAL_64'); + // @ts-ignore + expect(() => stringifyCommandExitStatus(193)).toThrowError(TypeError); + }); + + }); + + describe('parseCommandExitStatus', () => { + + it('should return correct CommandExitStatus values for valid inputs', () => { + + expect(parseCommandExitStatus(CommandExitStatus.OK)).toEqual(CommandExitStatus.OK); + expect(parseCommandExitStatus(CommandExitStatus.GENERAL_ERRORS)).toEqual(CommandExitStatus.GENERAL_ERRORS); + expect(parseCommandExitStatus(CommandExitStatus.MISUSE_OF_SHELL_BUILTINS)).toEqual(CommandExitStatus.MISUSE_OF_SHELL_BUILTINS); + expect(parseCommandExitStatus(CommandExitStatus.ARGUMENT_PARSE_ERROR)).toEqual(CommandExitStatus.ARGUMENT_PARSE_ERROR); + expect(parseCommandExitStatus(CommandExitStatus.UNKNOWN_ARGUMENT)).toEqual(CommandExitStatus.UNKNOWN_ARGUMENT); + expect(parseCommandExitStatus(CommandExitStatus.UNIMPLEMENTED_FEATURE)).toEqual(CommandExitStatus.UNIMPLEMENTED_FEATURE); + expect(parseCommandExitStatus(CommandExitStatus.FATAL_ERROR)).toEqual(CommandExitStatus.FATAL_ERROR); + expect(parseCommandExitStatus(CommandExitStatus.USAGE)).toEqual(CommandExitStatus.USAGE); + expect(parseCommandExitStatus(CommandExitStatus.DATAERR)).toEqual(CommandExitStatus.DATAERR); + expect(parseCommandExitStatus(CommandExitStatus.NOINPUT)).toEqual(CommandExitStatus.NOINPUT); + expect(parseCommandExitStatus(CommandExitStatus.NOUSER)).toEqual(CommandExitStatus.NOUSER); + expect(parseCommandExitStatus(CommandExitStatus.NOHOST)).toEqual(CommandExitStatus.NOHOST); + expect(parseCommandExitStatus(CommandExitStatus.UNAVAILABLE)).toEqual(CommandExitStatus.UNAVAILABLE); + expect(parseCommandExitStatus(CommandExitStatus.SOFTWARE)).toEqual(CommandExitStatus.SOFTWARE); + expect(parseCommandExitStatus(CommandExitStatus.OSERR)).toEqual(CommandExitStatus.OSERR); + expect(parseCommandExitStatus(CommandExitStatus.OSFILE)).toEqual(CommandExitStatus.OSFILE); + expect(parseCommandExitStatus(CommandExitStatus.CANTCREAT)).toEqual(CommandExitStatus.CANTCREAT); + expect(parseCommandExitStatus(CommandExitStatus.IOERR)).toEqual(CommandExitStatus.IOERR); + expect(parseCommandExitStatus(CommandExitStatus.TEMPFAIL)).toEqual(CommandExitStatus.TEMPFAIL); + expect(parseCommandExitStatus(CommandExitStatus.PROTOCOL)).toEqual(CommandExitStatus.PROTOCOL); + expect(parseCommandExitStatus(CommandExitStatus.NOPERM)).toEqual(CommandExitStatus.NOPERM); + expect(parseCommandExitStatus(CommandExitStatus.CONFIG)).toEqual(CommandExitStatus.CONFIG); + expect(parseCommandExitStatus(CommandExitStatus.COMMAND_INVOKED_CANNOT_EXECUTE)).toEqual(CommandExitStatus.COMMAND_INVOKED_CANNOT_EXECUTE); + expect(parseCommandExitStatus(CommandExitStatus.COMMAND_NOT_FOUND)).toEqual(CommandExitStatus.COMMAND_NOT_FOUND); + expect(parseCommandExitStatus(CommandExitStatus.INVALID_ARGUMENT_TO_EXIT)).toEqual(CommandExitStatus.INVALID_ARGUMENT_TO_EXIT); + expect(parseCommandExitStatus(CommandExitStatus.FATAL_SIGNAL_RANGE_START)).toEqual(CommandExitStatus.FATAL_SIGNAL_RANGE_START); + expect(parseCommandExitStatus(CommandExitStatus.FATAL_SIGNAL_RANGE_END)).toEqual(CommandExitStatus.FATAL_SIGNAL_RANGE_END); + expect(parseCommandExitStatus(CommandExitStatus.EXIT_STATUS_OUT_OF_RANGE)).toEqual(CommandExitStatus.EXIT_STATUS_OUT_OF_RANGE); + expect(parseCommandExitStatus(CommandExitStatus.CONFLICT)).toEqual(CommandExitStatus.CONFLICT); + + expect(parseCommandExitStatus('OK')).toEqual(CommandExitStatus.OK); + expect(parseCommandExitStatus('GENERAL_ERRORS')).toEqual(CommandExitStatus.GENERAL_ERRORS); + expect(parseCommandExitStatus('MISUSE_OF_SHELL_BUILTINS')).toEqual(CommandExitStatus.MISUSE_OF_SHELL_BUILTINS); + expect(parseCommandExitStatus('ARGUMENT_PARSE_ERROR')).toEqual(CommandExitStatus.ARGUMENT_PARSE_ERROR); + expect(parseCommandExitStatus('UNKNOWN_ARGUMENT')).toEqual(CommandExitStatus.UNKNOWN_ARGUMENT); + expect(parseCommandExitStatus('UNIMPLEMENTED_FEATURE')).toEqual(CommandExitStatus.UNIMPLEMENTED_FEATURE); + expect(parseCommandExitStatus('FATAL_ERROR')).toEqual(CommandExitStatus.FATAL_ERROR); + expect(parseCommandExitStatus('USAGE')).toEqual(CommandExitStatus.USAGE); + expect(parseCommandExitStatus('DATAERR')).toEqual(CommandExitStatus.DATAERR); + expect(parseCommandExitStatus('NOINPUT')).toEqual(CommandExitStatus.NOINPUT); + expect(parseCommandExitStatus('NOUSER')).toEqual(CommandExitStatus.NOUSER); + expect(parseCommandExitStatus('NOHOST')).toEqual(CommandExitStatus.NOHOST); + expect(parseCommandExitStatus('UNAVAILABLE')).toEqual(CommandExitStatus.UNAVAILABLE); + expect(parseCommandExitStatus('SOFTWARE')).toEqual(CommandExitStatus.SOFTWARE); + expect(parseCommandExitStatus('OSERR')).toEqual(CommandExitStatus.OSERR); + expect(parseCommandExitStatus('OSFILE')).toEqual(CommandExitStatus.OSFILE); + expect(parseCommandExitStatus('CANTCREAT')).toEqual(CommandExitStatus.CANTCREAT); + expect(parseCommandExitStatus('IOERR')).toEqual(CommandExitStatus.IOERR); + expect(parseCommandExitStatus('TEMPFAIL')).toEqual(CommandExitStatus.TEMPFAIL); + expect(parseCommandExitStatus('PROTOCOL')).toEqual(CommandExitStatus.PROTOCOL); + expect(parseCommandExitStatus('NOPERM')).toEqual(CommandExitStatus.NOPERM); + expect(parseCommandExitStatus('CONFIG')).toEqual(CommandExitStatus.CONFIG); + expect(parseCommandExitStatus('COMMAND_INVOKED_CANNOT_EXECUTE')).toEqual(CommandExitStatus.COMMAND_INVOKED_CANNOT_EXECUTE); + expect(parseCommandExitStatus('COMMAND_NOT_FOUND')).toEqual(CommandExitStatus.COMMAND_NOT_FOUND); + expect(parseCommandExitStatus('INVALID_ARGUMENT_TO_EXIT')).toEqual(CommandExitStatus.INVALID_ARGUMENT_TO_EXIT); + expect(parseCommandExitStatus('EXIT_STATUS_OUT_OF_RANGE')).toEqual(CommandExitStatus.EXIT_STATUS_OUT_OF_RANGE); + expect(parseCommandExitStatus('CONFLICT')).toEqual(CommandExitStatus.CONFLICT); + + }); + + it('should return undefined for invalid inputs', () => { + expect(parseCommandExitStatus(-1)).toBeUndefined(); + expect(parseCommandExitStatus(256)).toBeUndefined(); + expect(parseCommandExitStatus([])).toBeUndefined(); + expect(parseCommandExitStatus([1])).toBeUndefined(); + expect(parseCommandExitStatus({})).toBeUndefined(); + expect(parseCommandExitStatus({'1':'hello'})).toBeUndefined(); + expect(parseCommandExitStatus({'foo':'bar'})).toBeUndefined(); + expect(parseCommandExitStatus(true)).toBeUndefined(); + expect(parseCommandExitStatus(false)).toBeUndefined(); + expect(parseCommandExitStatus(null)).toBeUndefined(); + expect(parseCommandExitStatus(undefined)).toBeUndefined(); + expect(parseCommandExitStatus('1')).toBeUndefined(); + expect(parseCommandExitStatus('123')).toBeUndefined(); + expect(parseCommandExitStatus('INVALID_VALUE')).toBeUndefined(); + }); + + it('should return correct CommandExitStatus values for FATAL_SIGNAL_0 and beyond', () => { + expect(parseCommandExitStatus('FATAL_SIGNAL_0')).toEqual(undefined); + expect(parseCommandExitStatus('FATAL_SIGNAL_1')).toEqual(128+1); + expect(parseCommandExitStatus('FATAL_SIGNAL_2')).toEqual(128+2); + expect(parseCommandExitStatus('FATAL_SIGNAL_3')).toEqual(128+3); + // ...test other valid inputs in this range... + expect(parseCommandExitStatus('FATAL_SIGNAL_63')).toEqual(128+63); + expect(parseCommandExitStatus('FATAL_SIGNAL_64')).toEqual(128+64); + expect(parseCommandExitStatus('FATAL_SIGNAL_65')).toEqual(undefined); + }); + + }); + +}); diff --git a/cmd/types/CommandExitStatus.ts b/cmd/types/CommandExitStatus.ts new file mode 100644 index 0000000..77a98f3 --- /dev/null +++ b/cmd/types/CommandExitStatus.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { startsWith } from "../../functions/startsWith"; +import { isNumber, parseInteger } from "../../types/Number"; + +export enum CommandExitStatus { + + /** Standard successful termination */ + OK = 0, + + // From Advanced Bash scripting guide + GENERAL_ERRORS = 1, + MISUSE_OF_SHELL_BUILTINS = 2, + + // Our custom errors + ARGUMENT_PARSE_ERROR = 3, + UNKNOWN_ARGUMENT = 4, + UNIMPLEMENTED_FEATURE = 5, + FATAL_ERROR = 6, + CONFLICT = 7, + + // From Linux sysexits.h + USAGE = 64, /* command line usage error */ + DATAERR = 65, /* data format error */ + NOINPUT = 66, /* cannot open input */ + NOUSER = 67, /* addressee unknown */ + NOHOST = 68, /* host name unknown */ + UNAVAILABLE = 69, /* service unavailable */ + SOFTWARE = 70, /* internal software error */ + OSERR = 71, /* system error (e.g., can't fork) */ + OSFILE = 72, /* critical OS file missing */ + CANTCREAT = 73, /* can't create (user) output file */ + IOERR = 74, /* input/output error */ + TEMPFAIL = 75, /* temp failure; user is invited to retry */ + PROTOCOL = 76, /* remote error in protocol */ + NOPERM = 77, /* permission denied */ + CONFIG = 78, /* configuration error */ + + /** From Advanced Bash scripting guide + * @see https://tldp.org/LDP/abs/html/exitcodes.html + */ + COMMAND_INVOKED_CANNOT_EXECUTE = 126, + + /** From Advanced Bash scripting guide + * @see https://tldp.org/LDP/abs/html/exitcodes.html + */ + COMMAND_NOT_FOUND = 127, + + /** From Advanced Bash scripting guide + * @see https://tldp.org/LDP/abs/html/exitcodes.html + */ + INVALID_ARGUMENT_TO_EXIT = 128, + + /** The smallest dynamic fatal error signal. + * + * From Advanced Bash scripting guide: + * This should be 128 + smallest_signal + * Smallest signal is 1. + * + * @see https://tldp.org/LDP/abs/html/exitcodes.html + */ + FATAL_SIGNAL_RANGE_START = 129, + + /** The maximum dynamic fatal error signal. + * + * From Advanced Bash scripting guide: + * This should be 128 + SIGRTMAX + * SIGRTMAX is 64 + * + * @see https://tldp.org/LDP/abs/html/exitcodes.html + * @see https://chromium.googlesource.com/chromiumos/docs/+/master/constants/signals.md + * @see https://en.wikipedia.org/wiki/Signal_(IPC)#SIGRTMIN + */ + FATAL_SIGNAL_RANGE_END = 192, + + /** From Advanced Bash scripting guide + * @see https://tldp.org/LDP/abs/html/exitcodes.html + */ + EXIT_STATUS_OUT_OF_RANGE = 255 + +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isCommandExitStatus (value: any): value is CommandExitStatus { + if (!isNumber(value)) return false; + if (value < 0) return false; + if (value > 255) return false; + if ( value >= CommandExitStatus.FATAL_SIGNAL_RANGE_START + && value <= CommandExitStatus.FATAL_SIGNAL_RANGE_END + ) { + return true; + } + switch (value) { + case CommandExitStatus.OK: + case CommandExitStatus.GENERAL_ERRORS: + case CommandExitStatus.MISUSE_OF_SHELL_BUILTINS: + case CommandExitStatus.ARGUMENT_PARSE_ERROR: + case CommandExitStatus.UNKNOWN_ARGUMENT: + case CommandExitStatus.UNIMPLEMENTED_FEATURE: + case CommandExitStatus.FATAL_ERROR: + case CommandExitStatus.USAGE: + case CommandExitStatus.DATAERR: + case CommandExitStatus.NOINPUT: + case CommandExitStatus.NOUSER: + case CommandExitStatus.NOHOST: + case CommandExitStatus.UNAVAILABLE: + case CommandExitStatus.SOFTWARE: + case CommandExitStatus.OSERR: + case CommandExitStatus.OSFILE: + case CommandExitStatus.CANTCREAT: + case CommandExitStatus.IOERR: + case CommandExitStatus.TEMPFAIL: + case CommandExitStatus.PROTOCOL: + case CommandExitStatus.NOPERM: + case CommandExitStatus.CONFIG: + case CommandExitStatus.COMMAND_INVOKED_CANNOT_EXECUTE: + case CommandExitStatus.COMMAND_NOT_FOUND: + case CommandExitStatus.INVALID_ARGUMENT_TO_EXIT: + case CommandExitStatus.FATAL_SIGNAL_RANGE_START: + case CommandExitStatus.FATAL_SIGNAL_RANGE_END: + case CommandExitStatus.EXIT_STATUS_OUT_OF_RANGE: + case CommandExitStatus.CONFLICT: + return true; + default: + return false; + } +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function stringifyCommandExitStatus (value: CommandExitStatus): string { + if (value >= CommandExitStatus.FATAL_SIGNAL_RANGE_START && value <= CommandExitStatus.FATAL_SIGNAL_RANGE_END) { + return `FATAL_SIGNAL_${ value - CommandExitStatus.FATAL_SIGNAL_RANGE_START + 1 }`; + } + switch (value) { + case CommandExitStatus.OK : return 'OK'; + case CommandExitStatus.GENERAL_ERRORS : return 'GENERAL_ERRORS'; + case CommandExitStatus.MISUSE_OF_SHELL_BUILTINS : return 'MISUSE_OF_SHELL_BUILTINS'; + case CommandExitStatus.ARGUMENT_PARSE_ERROR : return 'ARGUMENT_PARSE_ERROR'; + case CommandExitStatus.UNKNOWN_ARGUMENT : return 'UNKNOWN_ARGUMENT'; + case CommandExitStatus.UNIMPLEMENTED_FEATURE : return 'UNIMPLEMENTED_FEATURE'; + case CommandExitStatus.FATAL_ERROR : return 'FATAL_ERROR'; + case CommandExitStatus.USAGE : return 'USAGE'; + case CommandExitStatus.DATAERR : return 'DATAERR'; + case CommandExitStatus.NOINPUT : return 'NOINPUT'; + case CommandExitStatus.NOUSER : return 'NOUSER'; + case CommandExitStatus.NOHOST : return 'NOHOST'; + case CommandExitStatus.UNAVAILABLE : return 'UNAVAILABLE'; + case CommandExitStatus.SOFTWARE : return 'SOFTWARE'; + case CommandExitStatus.OSERR : return 'OSERR'; + case CommandExitStatus.OSFILE : return 'OSFILE'; + case CommandExitStatus.CANTCREAT : return 'CANTCREAT'; + case CommandExitStatus.IOERR : return 'IOERR'; + case CommandExitStatus.TEMPFAIL : return 'TEMPFAIL'; + case CommandExitStatus.PROTOCOL : return 'PROTOCOL'; + case CommandExitStatus.NOPERM : return 'NOPERM'; + case CommandExitStatus.CONFIG : return 'CONFIG'; + case CommandExitStatus.COMMAND_INVOKED_CANNOT_EXECUTE : return 'COMMAND_INVOKED_CANNOT_EXECUTE'; + case CommandExitStatus.COMMAND_NOT_FOUND : return 'COMMAND_NOT_FOUND'; + case CommandExitStatus.INVALID_ARGUMENT_TO_EXIT : return 'INVALID_ARGUMENT_TO_EXIT'; + case CommandExitStatus.EXIT_STATUS_OUT_OF_RANGE : return 'EXIT_STATUS_OUT_OF_RANGE'; + case CommandExitStatus.CONFLICT : return 'CONFLICT'; + } + throw new TypeError(`Unsupported RunnerExitStatus value: ${value}`); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function parseCommandExitStatus (value: any): CommandExitStatus | undefined { + if (value === undefined) return undefined; + if (isCommandExitStatus(value)) return value; + + const valueString = `${value}`.toUpperCase(); + if (startsWith(valueString, 'FATAL_SIGNAL_')) { + const int : number | undefined = parseInteger( valueString.substring('FATAL_SIGNAL_'.length, valueString.length) ); + if (int === undefined) return undefined; + if ( int >= 1 && int <= (CommandExitStatus.FATAL_SIGNAL_RANGE_END+1 - CommandExitStatus.FATAL_SIGNAL_RANGE_START) ) { + return int + CommandExitStatus.FATAL_SIGNAL_RANGE_START-1; + } else { + return undefined; + } + } + + switch (valueString) { + case 'OK' : return CommandExitStatus.OK; + case 'GENERAL_ERRORS' : return CommandExitStatus.GENERAL_ERRORS; + case 'MISUSE_OF_SHELL_BUILTINS' : return CommandExitStatus.MISUSE_OF_SHELL_BUILTINS; + case 'ARGUMENT_PARSE_ERROR' : return CommandExitStatus.ARGUMENT_PARSE_ERROR; + case 'UNKNOWN_ARGUMENT' : return CommandExitStatus.UNKNOWN_ARGUMENT; + case 'UNIMPLEMENTED_FEATURE' : return CommandExitStatus.UNIMPLEMENTED_FEATURE; + case 'FATAL_ERROR' : return CommandExitStatus.FATAL_ERROR; + case 'USAGE' : return CommandExitStatus.USAGE; + case 'DATAERR' : return CommandExitStatus.DATAERR; + case 'NOINPUT' : return CommandExitStatus.NOINPUT; + case 'NOUSER' : return CommandExitStatus.NOUSER; + case 'NOHOST' : return CommandExitStatus.NOHOST; + case 'UNAVAILABLE' : return CommandExitStatus.UNAVAILABLE; + case 'SOFTWARE' : return CommandExitStatus.SOFTWARE; + case 'OSERR' : return CommandExitStatus.OSERR; + case 'OSFILE' : return CommandExitStatus.OSFILE; + case 'CANTCREAT' : return CommandExitStatus.CANTCREAT; + case 'IOERR' : return CommandExitStatus.IOERR; + case 'TEMPFAIL' : return CommandExitStatus.TEMPFAIL; + case 'PROTOCOL' : return CommandExitStatus.PROTOCOL; + case 'NOPERM' : return CommandExitStatus.NOPERM; + case 'CONFIG' : return CommandExitStatus.CONFIG; + case 'COMMAND_INVOKED_CANNOT_EXECUTE' : return CommandExitStatus.COMMAND_INVOKED_CANNOT_EXECUTE; + case 'COMMAND_NOT_FOUND' : return CommandExitStatus.COMMAND_NOT_FOUND; + case 'INVALID_ARGUMENT_TO_EXIT' : return CommandExitStatus.INVALID_ARGUMENT_TO_EXIT; + case 'EXIT_STATUS_OUT_OF_RANGE' : return CommandExitStatus.EXIT_STATUS_OUT_OF_RANGE; + case 'CONFLICT' : return CommandExitStatus.CONFLICT; + default: return undefined; + } +} diff --git a/cmd/types/DefaultValue.ts b/cmd/types/DefaultValue.ts new file mode 100644 index 0000000..693bb7b --- /dev/null +++ b/cmd/types/DefaultValue.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DefaultValueCallbackFactory } from "./DefaultValueCallbackFactory"; +import { DefaultValueCallback } from "./DefaultValueCallback"; + +/** + * Global access to implementation specific utilities for argument's default + * value parsing. + */ +export class DefaultValue { + + private static _impl : DefaultValueCallbackFactory | undefined = undefined; + + /** + * Initialize the internal platform specific implementation. + * + * @param impl + */ + public static initialize ( + impl : DefaultValueCallbackFactory + ) : void { + this._impl = impl; + } + + /** + * Read argument from autowired variable. + * + * @param name The name of the argument. If not specified, uses the previous + * value as the name. + */ + public static fromAutowired (name : string) : DefaultValueCallback { + if (!this._impl) throw new TypeError('DefaultValue.fromAutowired() not initialized'); + return this._impl.fromAutowired(name); + } + + /** + * Read argument from file system text file using UTF-8. + * + * Uses the previous value as the path. + */ + public static fromTextFile () : DefaultValueCallback { + if (!this._impl) throw new TypeError('DefaultValue.fromTextFile() not initialized'); + return this._impl.fromTextFile(); + } + + /** + * Build a callback function from a chain of callback functions, passing on + * the previous value to the next callback. + * + * @param callbacks Async or synchronous callback functions + */ + public static fromChain ( + ...callbacks: DefaultValueCallback[] + ) : DefaultValueCallback { + if (!this._impl) throw new TypeError('DefaultValue.fromChain() not initialized'); + return this._impl.fromChain(...callbacks); + } + +} diff --git a/cmd/types/DefaultValueCallback.ts b/cmd/types/DefaultValueCallback.ts new file mode 100644 index 0000000..f8722f8 --- /dev/null +++ b/cmd/types/DefaultValueCallback.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export interface DefaultValueCallback { + (value ?: T | undefined): R | undefined | Promise; +} diff --git a/cmd/types/DefaultValueCallbackFactory.ts b/cmd/types/DefaultValueCallbackFactory.ts new file mode 100644 index 0000000..332a371 --- /dev/null +++ b/cmd/types/DefaultValueCallbackFactory.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DefaultValueCallback } from "./DefaultValueCallback"; + +/** + * Interface for internal platform specific implementation. + */ +export interface DefaultValueCallbackFactory { + + /** + * Read argument from autowired variable. + * + * @param name The name of the argument + */ + fromAutowired ( + name: string + ) : DefaultValueCallback; + + /** + * Read argument from file system text file using UTF-8. + */ + fromTextFile () : DefaultValueCallback; + + /** + * Build a callback function from a chain of callback functions, passing on + * the previous value to the next callback. + * + * @param callbacks Async or synchronous callback functions + */ + fromChain ( + ...callbacks: DefaultValueCallback[] + ) : DefaultValueCallback; + +} diff --git a/cmd/types/ParsedCommandArgumentObject.ts b/cmd/types/ParsedCommandArgumentObject.ts new file mode 100644 index 0000000..7d085d9 --- /dev/null +++ b/cmd/types/ParsedCommandArgumentObject.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ParsedCommandArgumentStatus } from "./ParsedCommandArgumentStatus"; +import { CommandExitStatus } from "./CommandExitStatus"; +import { ArgumentValueMap } from "./ArgumentValueMap"; + +/** + * Result object from a command line argument parsing. + */ +export interface ParsedCommandArgumentObject { + + /** + * Status of parsing operation + */ + readonly parseStatus: ParsedCommandArgumentStatus; + + /** + * The status which to exit + */ + readonly exitStatus: CommandExitStatus; + + /** + * The path to the node process running the command + */ + readonly nodePath: string; + + /** + * The name of the running command + */ + readonly scriptName: string; + + /** + * These are command line arguments which are not detected as options + */ + readonly freeArgs: string[]; + + /** + * These are command line arguments which exist after the `--` argument + * which will turn off option parsing. This enables to pass on options to + * other commands. + */ + readonly extraArgs: string[]; + + /** + * Optional error string + */ + readonly errorString?: string; + + /** + * Values for user defined options + */ + readonly userArgs: ArgumentValueMap; + +} diff --git a/cmd/types/ParsedCommandArgumentStatus.ts b/cmd/types/ParsedCommandArgumentStatus.ts new file mode 100644 index 0000000..e473228 --- /dev/null +++ b/cmd/types/ParsedCommandArgumentStatus.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +/** + * The status of parsing command line arguments. + */ +export enum ParsedCommandArgumentStatus { + + /** + * There was no errors while parsing arguments. + */ + OK, + + /** + * Error happened. You should use `exitStatus` and `errorString` to inform + * the end user. + */ + ERROR, + + /** + * Built-in --help argument requested. + */ + HELP, + + /** + * Built-in --version argument reguested. + */ + VERSION +} diff --git a/cmd/types/UserDefinedArgumentType.ts b/cmd/types/UserDefinedArgumentType.ts new file mode 100644 index 0000000..62d98df --- /dev/null +++ b/cmd/types/UserDefinedArgumentType.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ArgumentType } from "./ArgumentType"; + +export type UserDefinedArgumentType = ArgumentType | string; diff --git a/cmd/types/UserDefinedParser.ts b/cmd/types/UserDefinedParser.ts new file mode 100644 index 0000000..62ea5aa --- /dev/null +++ b/cmd/types/UserDefinedParser.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * User defined custom type parser. + * + * @example + * (value: unknown) : string | undefined => BigInt.parse(value) + */ +export interface UserDefinedParser { + (value: unknown): T | undefined; +} diff --git a/cmd/types/UserDefinedParserMap.ts b/cmd/types/UserDefinedParserMap.ts new file mode 100644 index 0000000..ca2d325 --- /dev/null +++ b/cmd/types/UserDefinedParserMap.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { UserDefinedParser } from "./UserDefinedParser"; + +/** + * User defined custom types. + * + * @example + * { + * "bigint": (value: unknown) : string | undefined => BigInt.parse(value) + * } + */ +export interface UserDefinedParserMap { + [key: string]: UserDefinedParser; +} diff --git a/cmd/utils/CommandArgumentUtils.test.ts b/cmd/utils/CommandArgumentUtils.test.ts new file mode 100644 index 0000000..5603c7f --- /dev/null +++ b/cmd/utils/CommandArgumentUtils.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CommandArgumentUtils } from "./CommandArgumentUtils"; +import { ParsedCommandArgumentStatus } from "../types/ParsedCommandArgumentStatus"; +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { ArgumentType } from "../types/ArgumentType"; +import { ArgumentConfigurationMap } from "../types/ArgumentConfigurationMap"; +import { LogLevel } from "../../types/LogLevel"; + +describe('CommandArgumentUtils', () => { + + beforeAll( () => { + CommandArgumentUtils.setLogLevel(LogLevel.NONE); + }); + + describe('parseArguments', () => { + + it('should return error status when no script name is provided', () => { + const defaultScriptName = ''; + const args : string[] = []; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.ARGUMENT_PARSE_ERROR); + }); + + it('should return error status when script name is provided but no args', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.ARGUMENT_PARSE_ERROR); + }); + + it('should handle --help argument correctly', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--help']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.HELP); + expect(result.exitStatus).toBe(CommandExitStatus.OK); + }); + + it('should handle --version argument correctly', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--version']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.VERSION); + expect(result.exitStatus).toBe(CommandExitStatus.OK); + }); + + it('should handle long user defined arguments correctly', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--backend=1.2.3.4']; + const configMap : ArgumentConfigurationMap = { + backend: [ArgumentType.STRING, '--backend', undefined, undefined, 'localhost'] + }; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args, configMap); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.OK); + expect(result.exitStatus).toBe(CommandExitStatus.OK); + expect(result.userArgs?.backend).toBe('1.2.3.4'); + }); + + it('should handle long single user defined arguments correctly', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--backend']; + const configMap : ArgumentConfigurationMap = { + backend: [ArgumentType.STRING, '--backend', undefined, undefined, 'localhost'] + }; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args, configMap); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.OK); + expect(result.exitStatus).toBe(CommandExitStatus.OK); + expect(result.userArgs).toHaveProperty('backend'); + }); + + it('should handle short user defined arguments correctly', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '-b=1.2.3.4']; + const configMap : ArgumentConfigurationMap = { + backend: [ArgumentType.STRING, undefined, '-b', undefined, 'localhost'] + }; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args, configMap); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.OK); + expect(result.exitStatus).toBe(CommandExitStatus.OK); + expect(result.userArgs?.backend).toBe('1.2.3.4'); + }); + + it('should handle short single user defined arguments correctly', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '-b']; + const configMap : ArgumentConfigurationMap = { + backend: [ArgumentType.STRING, undefined, '-b', undefined, 'localhost'] + }; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args, configMap); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.OK); + expect(result.exitStatus).toBe(CommandExitStatus.OK); + expect(result.userArgs).toHaveProperty('backend'); + }); + + it('should return error status for long valueless unknown arguments', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--unknown']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.UNKNOWN_ARGUMENT); + expect(result.errorString).toBe('Unknown argument: --unknown'); + }); + + it('should return error status for short valueless unknown arguments', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '-u']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.UNKNOWN_ARGUMENT); + expect(result.errorString).toBe('Unknown argument: -u'); + }); + + it('should return error status for long with value unknown arguments', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--unknown=bar']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.UNKNOWN_ARGUMENT); + expect(result.errorString).toBe('Unknown argument: --unknown=bar'); + }); + + it('should return error status for short with value unknown arguments', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '-u=bar']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.UNKNOWN_ARGUMENT); + expect(result.errorString).toBe('Unknown argument: -u=bar'); + }); + + it('should return error status for long illegal integer arguments', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--integer=bar']; + const result = CommandArgumentUtils.parseArguments( + defaultScriptName, + args, + { + integer: [ ArgumentType.INTEGER, '--integer', '-I' ] + } + ); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.ARGUMENT_PARSE_ERROR); + expect(result.errorString).toBe('Argument parse error: TypeError: Argument --integer=bar: not integer'); + }); + + it('should return error status for short illegal integer arguments', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '-I=bar']; + const result = CommandArgumentUtils.parseArguments( + defaultScriptName, + args, + { + integer: [ ArgumentType.INTEGER, '--integer', '-I' ] + } + ); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.ARGUMENT_PARSE_ERROR); + expect(result.errorString).toBe('Argument parse error: TypeError: Argument -I=bar: not integer'); + }); + + it('should return error status for short arguments with undefined custom type', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '-I']; + const result = CommandArgumentUtils.parseArguments( + defaultScriptName, + args, + { + integer: [ 'CUSTOM', '--integer', '-I' ] + }, + { + + } + ); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.ARGUMENT_PARSE_ERROR); + expect(result.errorString).toBe('Argument parse error: TypeError: Unimplemented type: CUSTOM'); + }); + + it('should return error status for long arguments with undefined custom type', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--integer']; + const result = CommandArgumentUtils.parseArguments( + defaultScriptName, + args, + { + integer: [ 'CUSTOM', '--integer', '-I' ] + }, + { + + } + ); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.ERROR); + expect(result.exitStatus).toBe(CommandExitStatus.ARGUMENT_PARSE_ERROR); + expect(result.errorString).toBe('Argument parse error: TypeError: Unimplemented type: CUSTOM'); + }); + + it('should handle arguments after --', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', '--', '--backend']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.OK); + expect(result.exitStatus).toBe(CommandExitStatus.OK); + expect(result.extraArgs).toContain('--backend'); + }); + + it('should handle free args correctly', () => { + const defaultScriptName = 'scriptName'; + const args = ['nodePath', 'scriptName', 'freeArg']; + const result = CommandArgumentUtils.parseArguments(defaultScriptName, args); + expect(result.parseStatus).toBe(ParsedCommandArgumentStatus.OK); + expect(result.exitStatus).toBe(CommandExitStatus.OK); + expect(result.freeArgs).toContain('freeArg'); + }); + + }); + +}); diff --git a/cmd/utils/CommandArgumentUtils.ts b/cmd/utils/CommandArgumentUtils.ts new file mode 100644 index 0000000..5f5a453 --- /dev/null +++ b/cmd/utils/CommandArgumentUtils.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { startsWith } from "../../functions/startsWith"; +import { forEach } from "../../functions/forEach"; +import { keys } from "../../functions/keys"; +import { has } from "../../functions/has"; +import { indexOf } from "../../functions/indexOf"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { CommandExitStatus } from "../types/CommandExitStatus"; +import { CommandArgumentType, parseCommandArgumentType } from "../types/CommandArgumentType"; +import { ParsedCommandArgumentStatus } from "../types/ParsedCommandArgumentStatus"; +import { ArgumentConfigurationMap } from "../types/ArgumentConfigurationMap"; +import { UserDefinedParserMap } from "../types/UserDefinedParserMap"; +import { ArgumentValueMap } from "../types/ArgumentValueMap"; +import { ParsedCommandArgumentObject } from "../types/ParsedCommandArgumentObject"; +import { parseSingleArgument } from "../functions/parseSingleArgument"; +import { parseArgumentWithParam } from "../functions/parseArgumentWithParam"; + +const LOG = LogService.createLogger('CommandArgumentUtils'); + +export class CommandArgumentUtils { + + public static setLogLevel (level: LogLevel) : void { + LOG.setLogLevel(level); + } + + /** + * Parses command line arguments. + * + * @param defaultScriptName The name of the calling script + * @param args Array of command line arguments passed to the running script + * @param configurationMap The configuration map for user defined arguments + * @param parserMap The parser map for user defined types + * @returns Parsed values + */ + public static parseArguments ( + defaultScriptName : string, + args : readonly string[] = [], + configurationMap ?: ArgumentConfigurationMap, + parserMap ?: UserDefinedParserMap, + ) : ParsedCommandArgumentObject { + + const myArgs = [...args]; + LOG.debug(`myArgs = `, myArgs); + const nodePath : string = myArgs.shift() ?? ''; + LOG.debug(`nodePath = `, nodePath); + const scriptNameFromArgs : string = myArgs.shift() ?? ''; + LOG.debug(`scriptNameFromArgs = `, scriptNameFromArgs); + + const argConfigurationMap : ArgumentConfigurationMap = configurationMap ? configurationMap : {}; + + if (!scriptNameFromArgs) { + return { + parseStatus: ParsedCommandArgumentStatus.ERROR, + exitStatus: CommandExitStatus.ARGUMENT_PARSE_ERROR, + nodePath: nodePath, + scriptName: defaultScriptName, + freeArgs: [], + extraArgs: [], + userArgs: {} + }; + } + + if (myArgs.length === 0) { + return { + parseStatus: ParsedCommandArgumentStatus.ERROR, + exitStatus: CommandExitStatus.ARGUMENT_PARSE_ERROR, + nodePath: nodePath, + scriptName: scriptNameFromArgs, + freeArgs: [], + extraArgs: [], + userArgs: {} + }; + } + + let parsingArgs : boolean = true; + let freeArgs : string[] = [] + let extraArgs : string[] = [] + let userArgs : ArgumentValueMap = {}; + + let userLongArgs : {[key: string]: string} = {}; + let userShortArgs : {[key: string]: string} = {}; + let userEnvKeys : {[key: string]: string} = {}; + forEach( + keys(argConfigurationMap), + (key: string) => { + const [, long, short, envKey] = argConfigurationMap[key]; + if (long) { + userLongArgs[long] = key; + } + if (short) { + userShortArgs[short] = key; + } + if (envKey) { + userEnvKeys[envKey] = key; + } + } + ); + + do { + + const argName : string = myArgs.shift() ?? ''; + + if ( !parsingArgs ) { + extraArgs.push( argName ); + continue; + } + + const argType: CommandArgumentType | undefined = parseCommandArgumentType( argName ); + switch ( argType ) { + + case CommandArgumentType.HELP: + return { + parseStatus: ParsedCommandArgumentStatus.HELP, + exitStatus: CommandExitStatus.OK, + nodePath, + scriptName: scriptNameFromArgs, + freeArgs, + extraArgs, + userArgs + }; + + case CommandArgumentType.VERSION: + return { + parseStatus: ParsedCommandArgumentStatus.VERSION, + exitStatus: CommandExitStatus.OK, + nodePath, + scriptName: scriptNameFromArgs, + freeArgs, + extraArgs, + userArgs + }; + + case CommandArgumentType.DISABLE_ARGUMENT_PARSING: + parsingArgs = false; + break; + + default: + + if ( !startsWith( argName, '-' ) ) { + freeArgs.push( argName ); + break; + } + + if ( indexOf( argName, '=' ) >= 1 ) { + + const [ argKey, ...lastParts ] = argName.split( '=' ); + const argValue = lastParts.join( '=' ); + + if ( has( userLongArgs, argKey ) ) { + const key = userLongArgs[argKey]; + const [ type, , , , ] = argConfigurationMap[key]; + try { + userArgs[key] = parseArgumentWithParam( argName, type, argValue, parserMap ); + } catch (err) { + return CommandArgumentUtils._getArgumentParseError(err, nodePath, scriptNameFromArgs, freeArgs, extraArgs, userArgs ); + } + break; + } + + if ( has( userShortArgs, argKey ) ) { + const key = userShortArgs[argKey]; + const [ type, , , , ] = argConfigurationMap[key]; + try { + userArgs[key] = parseArgumentWithParam( argName, type, argValue, parserMap ); + } catch (err) { + return CommandArgumentUtils._getArgumentParseError(err, nodePath, scriptNameFromArgs, freeArgs, extraArgs, userArgs ); + } + break; + } + + } else { + + if ( has( userLongArgs, argName ) ) { + const key = userLongArgs[argName]; + const [ type, , , , ] = argConfigurationMap[key]; + try { + userArgs[key] = parseSingleArgument( argName, type, parserMap ); + } catch (err) { + return CommandArgumentUtils._getArgumentParseError(err, nodePath, scriptNameFromArgs, freeArgs, extraArgs, userArgs ); + } + break; + } + + if ( has( userShortArgs, argName ) ) { + const key = userShortArgs[argName]; + const [ type, , , , ] = argConfigurationMap[key]; + try { + userArgs[key] = parseSingleArgument( argName, type, parserMap ); + } catch (err) { + return CommandArgumentUtils._getArgumentParseError(err, nodePath, scriptNameFromArgs, freeArgs, extraArgs, userArgs ); + } + break; + } + + } + + return { + errorString: `Unknown argument: ${argName}`, + parseStatus: ParsedCommandArgumentStatus.ERROR, + exitStatus: CommandExitStatus.UNKNOWN_ARGUMENT, + nodePath, + scriptName: scriptNameFromArgs, + freeArgs, + extraArgs, + userArgs + }; + + } // switch (argType) + + } while( myArgs.length >= 1 ); + + // Set ENV keys + forEach( + keys(userEnvKeys), + (envKey: string) => { + const key = userEnvKeys[envKey]; + const [type, , , ] = argConfigurationMap[key]; + if ( has(process.env, envKey) && !has(userArgs, key) ) { + userArgs[key] = parseArgumentWithParam( + envKey, + type, + process.env[envKey] as unknown as string, + parserMap + ); + } + } + ); + + return { + parseStatus: ParsedCommandArgumentStatus.OK, + exitStatus:CommandExitStatus.OK, + nodePath, + scriptName: scriptNameFromArgs, + freeArgs, + extraArgs, + userArgs + }; + + } + + private static _getArgumentParseError ( + err: any, + nodePath : string, + scriptNameFromArgs: string, + freeArgs: string[], + extraArgs: string[], + userArgs: ArgumentValueMap, + ) { + return { + errorString: `Argument parse error: ${err}`, + parseStatus: ParsedCommandArgumentStatus.ERROR, + exitStatus: CommandExitStatus.ARGUMENT_PARSE_ERROR, + nodePath, + scriptName: scriptNameFromArgs, + freeArgs, + extraArgs, + userArgs + }; + } + +} diff --git a/cmd/utils/DefaultValueCallbackUtils.test.ts b/cmd/utils/DefaultValueCallbackUtils.test.ts new file mode 100644 index 0000000..508081a --- /dev/null +++ b/cmd/utils/DefaultValueCallbackUtils.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from "@jest/globals"; +import { DefaultValueCallbackUtils } from "./DefaultValueCallbackUtils"; +import { createAutowiredDefaultCallback } from "../functions/createAutowiredDefaultCallback"; +import { DefaultValueCallback } from "../types/DefaultValueCallback"; +import { LogLevel } from "../../types/LogLevel"; +import { addAutowired } from "../main/addAutowired"; +import { autowired } from "../main/autowired"; +import { AutowireServiceImpl } from "../main/services/AutowireServiceImpl"; +import { AutowireUtils } from "../main/utils/AutowireUtils"; + +describe('DefaultValueCallbackUtils', () => { + + // AutowireServiceImpl instance + let autowireService: AutowireServiceImpl; + let setNameSpy: jest.SpiedFunction<(...args: any) => any>; + let hasNameSpy: jest.SpiedFunction<(...args: any) => any>; + let getNameSpy: jest.SpiedFunction<(...args: any) => any>; + + beforeAll(() => { + AutowireUtils.setLogLevel(LogLevel.NONE); + addAutowired.setLogLevel(LogLevel.NONE); + autowired.setLogLevel(LogLevel.NONE); + }); + + beforeAll(() => { + // Create a new instance of AutowireServiceImpl + autowireService = AutowireServiceImpl.create(); + // Make getAutowireService return our instance + AutowireServiceImpl.getAutowireService = jest.fn(() => autowireService); + + // Now we can spy on setName, hasName, and getName + setNameSpy = jest.spyOn(autowireService, 'setName'); + hasNameSpy = jest.spyOn(autowireService, 'hasName'); + getNameSpy = jest.spyOn(autowireService, 'getName'); + }); + + beforeEach(() => { + // Clear all mocks before each test + setNameSpy.mockClear(); + hasNameSpy.mockClear(); + getNameSpy.mockClear(); + }); + + describe('#fromAutowired', () => { + + it('returns a DefaultValueCallback that returns autowired value', () => { + const autowiredTo = 'autowiredValue'; + const testValue = 'test'; + + // Set up our autowired value + setNameSpy.mockImplementation((name: any, value: any) => { + if (name === autowiredTo) { + hasNameSpy.mockReturnValue(true); + getNameSpy.mockReturnValue(value); + } + }); + + autowireService.setName(autowiredTo, testValue); + + // Create the DefaultValueCallback + const defaultValueCallback = DefaultValueCallbackUtils.fromAutowired(autowiredTo); + + // Assert that the DefaultValueCallback returns the autowired value + expect(defaultValueCallback()).toBe(testValue); + }); + + it('returns a DefaultValueCallback that returns undefined when there is no autowired value', () => { + const autowiredTo = 'autowiredValue'; + + // Make sure there is no autowired value + setNameSpy.mockImplementation((name, value) => { + if (name === autowiredTo) { + hasNameSpy.mockReturnValue(false); + getNameSpy.mockReturnValue(value); + } + }); + + autowireService.setName(autowiredTo, undefined); + + // Create the DefaultValueCallback + const defaultValueCallback = DefaultValueCallbackUtils.fromAutowired(autowiredTo); + + // Assert that the DefaultValueCallback returns undefined + expect(defaultValueCallback()).toBeUndefined(); + }); + + }); + + describe('#fromChain', () => { + + it('returns a DefaultValueCallback', () => { + const callbacks: DefaultValueCallback[] = [ + createAutowiredDefaultCallback('myArg1'), + createAutowiredDefaultCallback('myArg2') + ]; + const result = DefaultValueCallbackUtils.fromChain(...callbacks); + expect(typeof result).toBe('function'); + }); + + it('chains multiple DefaultValueCallback functions together', async () => { + // Create some test callbacks + const callback1: DefaultValueCallback = jest.fn((value) => `${value}_callback1`); + const callback2: DefaultValueCallback = jest.fn(async (value) => `${value}_callback2`); + const callback3: DefaultValueCallback = jest.fn((value) => `${value}_callback3`); + + // Chain the callbacks + const chainedCallback = DefaultValueCallbackUtils.fromChain(callback1, callback2, callback3); + + // Call the chained callback + const result = await chainedCallback('initial'); + + // Check the result + expect(result).toBe('initial_callback1_callback2_callback3'); + + // Check if the callbacks were called + expect(callback1).toHaveBeenCalledWith('initial'); + expect(callback2).toHaveBeenCalledWith('initial_callback1'); + expect(callback3).toHaveBeenCalledWith('initial_callback1_callback2'); + }); + + it('returns the input value when no callbacks are provided', async () => { + // Chain no callbacks + const chainedCallback = DefaultValueCallbackUtils.fromChain(); + + // Call the chained callback + const result = await chainedCallback('initial'); + + // Check the result + expect(result).toBe('initial'); + }); + + }); + +}); diff --git a/cmd/utils/DefaultValueCallbackUtils.ts b/cmd/utils/DefaultValueCallbackUtils.ts new file mode 100644 index 0000000..480b0aa --- /dev/null +++ b/cmd/utils/DefaultValueCallbackUtils.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DefaultValueCallback } from "../types/DefaultValueCallback"; +import { reduce } from "../../functions/reduce"; +import { reverse } from "../../functions/reverse"; +import { isPromise } from "../../types/Promise"; +import { createAutowiredDefaultCallback } from "../functions/createAutowiredDefaultCallback"; + +export class DefaultValueCallbackUtils { + + /** + * Read argument from autowired variable. + * + * @param name The name of the argument. If not specified, uses the previous + * value as the name. + */ + public static fromAutowired ( + name: string, + ) : DefaultValueCallback { + return createAutowiredDefaultCallback(name); + } + + /** + * Build a callback function from a chain of callback functions, passing on + * the previous value to the next callback. + * + * @param callbacks Async or synchronous callback functions + */ + public static fromChain ( + ...callbacks: DefaultValueCallback[] + ) : DefaultValueCallback { + return reduce( + reverse(callbacks), + (callback : DefaultValueCallback, previousCallback: DefaultValueCallback): DefaultValueCallback => { + return (value ?: string | undefined) : string | undefined | Promise => { + const previousValue = previousCallback(value); + return isPromise( previousValue ) ? previousValue.then( callback ) : callback( previousValue ); + }; + }, + (value ?: string | undefined) => value + ); + } + +} diff --git a/com/joker/dmapi/FiHgComJokerDomainManagementAPI.ts b/com/joker/dmapi/FiHgComJokerDomainManagementAPI.ts new file mode 100644 index 0000000..19713a7 --- /dev/null +++ b/com/joker/dmapi/FiHgComJokerDomainManagementAPI.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { JokerComApiLoginDTO } from "./types/JokerComApiLoginDTO"; +import { JokerComApiDomainListDTO } from "./types/JokerComApiDomainListDTO"; +import { JokerComApiWhoisDTO } from "./types/JokerComApiWhoisDTO"; +import { JokerComApiWhoisContactDTO } from "./types/JokerComApiWhoisContactDTO"; +import { JokerComApiPriceListDTO } from "./types/JokerComApiPriceListDTO"; +import { JokerPrivacyType } from "./types/JokerPrivacyType"; +import { JokerComApiDomainPriceType } from "./types/JokerComApiDomainPriceType"; +import { JokerComApiPeriodType } from "./types/JokerComApiPeriodType"; +import { JokerComApiDomainCheckDTO } from "./types/JokerComApiDomainCheckDTO"; +import { JokerComApiRegisterDTO } from "./types/JokerComApiRegisterDTO"; +import { JokerComApiProfileDTO } from "./types/JokerComApiProfileDTO"; + +/** + * Joker.com DMAPI client interface + * + * @see https://github.com/heusalagroup/fi.hg.node for NodeJS implementation + */ +export interface FiHgComJokerDomainManagementAPI { + + /** + * + */ + hasSession() : boolean; + + /** + * + */ + isReady() : Promise; + + /** Login using api key + * @see https://joker.com/faq/content/26/14/en/login.html + */ + loginWithApiKey (apiKey : string) : Promise; + + /** Login using username and password + * @see https://joker.com/faq/content/26/14/en/login.html + */ + loginWithUsername ( + username : string, + password : string + ) : Promise; + + /** Logout + * @see https://joker.com/faq/content/26/15/en/logout.html + */ + logout () : Promise; + + /** query-domain-list + * @params pattern Pattern to match (glob-like) + * @params from Pattern to match (glob-like) + * @params to End by this + * @params showStatus + * @params showGrants + * @params showJokerNS + * @see https://joker.com/faq/content/27/20/en/query_domain_list.html + */ + queryDomainList ( + pattern ?: string | undefined, + from ?: string | undefined, + to ?: string | undefined, + showStatus ?: boolean | undefined, + showGrants ?: boolean | undefined, + showJokerNS ?: boolean | undefined + ) : Promise; + + /** query-whois for domains + * At least one of the arguments must be specified + * @see https://joker.com/faq/content/79/455/en/query_whois.html + * @param domain + */ + queryWhoisByDomain ( + domain : string + ) : Promise; + + /** query-whois for contacts + * At least one of the arguments must be specified + * @see https://joker.com/faq/content/79/455/en/query_whois.html + * @param contact Contact handle + */ + queryWhoisByContact ( + contact : string + ) : Promise; + + /** query-whois for nameservers + * At least one of the arguments must be specified + * @see https://joker.com/faq/content/79/455/en/query_whois.html + * @param host + */ + queryWhoisByHost ( + host : string + ) : Promise; + + /** query-profile */ + queryProfile () : Promise; + + /** query-price-list + * @see https://joker.com/faq/content/79/509/en/query_price_list.html + */ + queryPriceList () : Promise; + + /** domain-renew + * @see https://joker.com/faq/content/27/22/en/domain_renew.html + */ + domainRenew ( + domain: string, + period: number | undefined, + expyear: string | undefined, + privacy: JokerPrivacyType | undefined, + maxPrice: number + ) : Promise; + + /** domain-check + * @param domain + * @param checkPrice + * @param periodType + * @param periods + * @param language + * @see https://joker.com/faq/content/27/497/en/domain_check.html + */ + domainCheck ( + domain : string, + checkPrice ?: JokerComApiDomainPriceType | undefined, + periods ?: number | undefined, + periodType ?: JokerComApiPeriodType, + language ?: string | undefined + ) : Promise; + + /** domain-register + * @see https://joker.com/faq/content/27/21/en/domain_register.html + * @param domain + * @param period Registration period in months, not years! + * @param status + * @param ownerContact + * @param billingContact + * @param adminContact + * @param techContact + * @param nsList + * @param autoRenew Optional + * @param language Optional + * @param registrarTag Only needed for .xxx domains + * @param privacy Optional + * @param maxPrice Optional + */ + domainRegister ( + domain : string, + period : number, + ownerContact : string, + billingContact : string, + adminContact : string, + techContact : string, + nsList : readonly string[], + autoRenew ?: boolean | undefined, + language ?: string | undefined, + registrarTag ?: string | undefined, + privacy ?: JokerPrivacyType | undefined, + maxPrice ?: number | undefined + ) : Promise; + + /** grants-list + * @see https://joker.com/faq/content/76/448/en/grants_list.html + */ + grantsList ( + domain: string, + showKey: string + ) : Promise; + + /** grants-invite + * @see https://joker.com/faq/content/76/449/en/grants_invite.html + */ + grantsInvite ( + domain: string, + email: string, + clientUid: string, + role: string, + nickname: string + ) : Promise; + + /** domain-modify + * @see https://joker.com/faq/content/27/24/en/domain_modify.html + */ + domainModify ( + domain : string, + billingContact ?: string | undefined, + adminContact ?: string | undefined, + techContact ?: string | undefined, + nsList ?: readonly string[] | undefined, + registerTag ?: string | undefined, + dnssec ?: boolean | undefined, + ds ?: readonly string[] | undefined, + ) : Promise; + +} diff --git a/com/joker/dmapi/types/JokerComApiCurrency.ts b/com/joker/dmapi/types/JokerComApiCurrency.ts new file mode 100644 index 0000000..51e171d --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiCurrency.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerComApiCurrency { + USD = "USD", + GBP = "GBP", + EUR = "EUR" +} + +export function isJokerComApiCurrency (value: any) : value is JokerComApiCurrency { + switch (value) { + case JokerComApiCurrency.USD: + case JokerComApiCurrency.GBP: + case JokerComApiCurrency.EUR: + return true; + default: + return false; + } +} + +export function explainJokerComApiCurrency (value : any) : string { + return explainEnum("JokerComApiCurrency", JokerComApiCurrency, isJokerComApiCurrency, value); +} + +export function stringifyJokerComApiCurrency (value : JokerComApiCurrency) : string { + switch (value) { + case JokerComApiCurrency.USD : return 'USD'; + case JokerComApiCurrency.GBP : return 'GBP'; + case JokerComApiCurrency.EUR : return 'EUR'; + } + throw new TypeError(`Unsupported JokerComApiCurrency value: ${value}`) +} + +export function parseJokerComApiCurrency (value: any) : JokerComApiCurrency | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'USD' : return JokerComApiCurrency.USD; + case 'GBP' : return JokerComApiCurrency.GBP; + case 'EUR' : return JokerComApiCurrency.EUR; + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerComApiDomainCheckDTO.ts b/com/joker/dmapi/types/JokerComApiDomainCheckDTO.ts new file mode 100644 index 0000000..ac9a75d --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiDomainCheckDTO.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainJokerStringObject, isJokerStringObject, JokerStringObject } from "./JokerStringObject"; +import { explainJokerComApiDomainStatus, isJokerComApiDomainStatus, JokerComApiDomainStatus } from "./JokerComApiDomainStatus"; +import { explainJokerComApiDomainPrice, isJokerComApiDomainPrice, JokerComApiDomainPrice } from "./JokerComApiDomainPrice"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; +import { explainArrayOfOrUndefined, isArrayOfOrUndefined } from "../../../../types/Array"; + +/** + * + * @see https://joker.com/faq/content/27/497/en/domain_check.html + */ +export interface JokerComApiDomainCheckDTO { + readonly domain : string; + readonly body : JokerStringObject; + readonly headers : JokerStringObject; + readonly status : JokerComApiDomainStatus; + readonly statusReason ?: string; + readonly domainClass ?: string; + readonly prices ?: readonly JokerComApiDomainPrice[]; +} + +export function createJokerComApiDomainCheckDTO ( + domain : string, + headers : JokerStringObject, + body : JokerStringObject, + status : JokerComApiDomainStatus, + statusReason ?: string, + domainClass ?: string, + prices ?: readonly JokerComApiDomainPrice[] +) : JokerComApiDomainCheckDTO { + return { + domain, + headers, + body, + status, + statusReason, + domainClass, + prices + }; +} + +export function isJokerComApiDomainCheckDTO (value: any) : value is JokerComApiDomainCheckDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'domain', + 'headers', + 'body', + 'status', + 'statusReason', + 'domainClass', + 'prices' + ]) + && isString(value?.domain) + && isJokerStringObject(value?.headers) + && isJokerStringObject(value?.body) + && isJokerComApiDomainStatus(value?.status) + && isStringOrUndefined(value?.statusReason) + && isStringOrUndefined(value?.domainClass) + && isArrayOfOrUndefined(value?.prices, isJokerComApiDomainPrice) + ); +} + +export function explainJokerComApiDomainCheckDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'domain', + 'headers', + 'body', + 'status', + 'statusReason', + 'domainClass', + 'prices' + ]), + explainProperty("domain", explainString(value?.domain)), + explainProperty("headers", explainJokerStringObject(value?.headers)), + explainProperty("body", explainJokerStringObject(value?.body)), + explainProperty("status", explainJokerComApiDomainStatus(value?.status)), + explainProperty("statusReason", explainStringOrUndefined(value?.statusReason)), + explainProperty("domainClass", explainStringOrUndefined(value?.domainClass)), + explainProperty("prices", explainArrayOfOrUndefined( + "JokerComApiDomainPrice", + explainJokerComApiDomainPrice, + value?.domainClass, + isJokerComApiDomainPrice + )) + ] + ); +} + +export function stringifyJokerComApiDomainCheckDTO (value : JokerComApiDomainCheckDTO) : string { + return `JokerComApiDomainCheckDTO(${value})`; +} + +export function parseJokerComApiDomainCheckDTO (value: any) : JokerComApiDomainCheckDTO | undefined { + if (isJokerComApiDomainCheckDTO(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerComApiDomainListDTO.ts b/com/joker/dmapi/types/JokerComApiDomainListDTO.ts new file mode 100644 index 0000000..694dbd3 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiDomainListDTO.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { + explainJokerDomainResult, + isJokerDomainResult, + JokerDomainResult +} from "./JokerDomainResult"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; +import { explainArrayOf, isArrayOf } from "../../../../types/Array"; + +export interface JokerComApiDomainListDTO { + readonly domainList : readonly JokerDomainResult[]; +} + +export function createJokerComApiDomainListDTO ( + domainList : readonly JokerDomainResult[] +) : JokerComApiDomainListDTO { + return { + domainList + }; +} + +export function isJokerComApiDomainListDTO (value: any) : value is JokerComApiDomainListDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'domainList' + ]) + && isArrayOf(value?.domainList, isJokerDomainResult) + ); +} + +export function explainJokerComApiDomainListDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'domainList' + ]), + explainProperty( + "domainList", + explainArrayOf( + "JokerDomainResult", + explainJokerDomainResult, + value?.domainList, + isJokerDomainResult + ) + ) + ] + ); +} + +export function stringifyJokerComApiDomainListDTO (value : JokerComApiDomainListDTO) : string { + return `JokerComApiDomainListDTO(${value})`; +} + +export function parseJokerComApiDomainListDTO (value: any) : JokerComApiDomainListDTO | undefined { + if (isJokerComApiDomainListDTO(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerComApiDomainPeriod.ts b/com/joker/dmapi/types/JokerComApiDomainPeriod.ts new file mode 100644 index 0000000..ea0b936 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiDomainPeriod.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerComApiDomainPeriod { + Y1 = "1y", + Y2 = "2y", + Y3 = "3y", + Y4 = "4y", + Y5 = "5y", + Y6 = "6y", + Y7 = "7y", + Y8 = "8y", + Y9 = "9y", + Y10 = "10y" +} + +export function isJokerComApiDomainPeriod (value: any) : value is JokerComApiDomainPeriod { + switch (value) { + case JokerComApiDomainPeriod.Y1: + case JokerComApiDomainPeriod.Y2: + case JokerComApiDomainPeriod.Y3: + case JokerComApiDomainPeriod.Y4: + case JokerComApiDomainPeriod.Y5: + case JokerComApiDomainPeriod.Y6: + case JokerComApiDomainPeriod.Y7: + case JokerComApiDomainPeriod.Y8: + case JokerComApiDomainPeriod.Y9: + case JokerComApiDomainPeriod.Y10: + return true; + default: + return false; + } +} + +export function explainJokerComApiDomainPeriod (value : any) : string { + return explainEnum("JokerComApiDomainPeriod", JokerComApiDomainPeriod, isJokerComApiDomainPeriod, value); +} + +export function stringifyJokerComApiDomainPeriod (value : JokerComApiDomainPeriod) : string { + switch (value) { + case JokerComApiDomainPeriod.Y1 : return '1y'; + case JokerComApiDomainPeriod.Y2 : return '2y'; + case JokerComApiDomainPeriod.Y3 : return '3y'; + case JokerComApiDomainPeriod.Y4 : return '4y'; + case JokerComApiDomainPeriod.Y5 : return '5y'; + case JokerComApiDomainPeriod.Y6 : return '6y'; + case JokerComApiDomainPeriod.Y7 : return '7y'; + case JokerComApiDomainPeriod.Y8 : return '8y'; + case JokerComApiDomainPeriod.Y9 : return '9y'; + case JokerComApiDomainPeriod.Y10 : return '10y'; + } + throw new TypeError(`Unsupported JokerComApiDomainPeriod value: ${value}`) +} + +export function parseJokerComApiDomainPeriod (value: any) : JokerComApiDomainPeriod | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toLowerCase()) { + + case 'y1': + case '1y' : return JokerComApiDomainPeriod.Y1; + + case 'y2': + case '2y' : return JokerComApiDomainPeriod.Y2; + + case 'y3': + case '3y' : return JokerComApiDomainPeriod.Y3; + + case 'y4': + case '4y' : return JokerComApiDomainPeriod.Y4; + + case 'y5': + case '5y' : return JokerComApiDomainPeriod.Y5; + + case 'y6': + case '6y' : return JokerComApiDomainPeriod.Y6; + + case 'y7': + case '7y' : return JokerComApiDomainPeriod.Y7; + + case 'y8': + case '8y' : return JokerComApiDomainPeriod.Y8; + + case 'y9': + case '9y' : return JokerComApiDomainPeriod.Y9; + + case 'y10': + case '10y' : return JokerComApiDomainPeriod.Y10; + + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerComApiDomainPrice.ts b/com/joker/dmapi/types/JokerComApiDomainPrice.ts new file mode 100644 index 0000000..469c328 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiDomainPrice.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainJokerComApiDomainPeriod, isJokerComApiDomainPeriod, JokerComApiDomainPeriod } from "./JokerComApiDomainPeriod"; +import { explainJokerComApiCurrency, isJokerComApiCurrency, JokerComApiCurrency } from "./JokerComApiCurrency"; +import { explainJokerComApiPriceAmount, isJokerComApiPriceAmount, JokerComApiPriceAmount } from "./JokerComApiPriceAmount"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../../../types/Boolean"; +import { explainStringOrUndefined, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface JokerComApiDomainPrice { + readonly price : JokerComApiPriceAmount; + readonly currency : JokerComApiCurrency; + readonly period : JokerComApiDomainPeriod; + readonly isPromo ?: boolean; + readonly promoStart ?: string; + readonly promoEnd ?: string; +} + +export function createJokerComApiDomainPrice ( + price : JokerComApiPriceAmount, + currency : JokerComApiCurrency, + period : JokerComApiDomainPeriod, + isPromo : boolean | undefined, + promoStart : string | undefined, + promoEnd : string | undefined +) : JokerComApiDomainPrice { + return { + price, + currency, + period, + isPromo, + promoStart, + promoEnd + }; +} + +export function isJokerComApiDomainPrice (value: any) : value is JokerComApiDomainPrice { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'price', + 'currency', + 'period', + 'isPromo', + 'promoStart', + 'promoEnd' + ]) + && isJokerComApiPriceAmount(value?.price) + && isJokerComApiCurrency(value?.currency) + && isJokerComApiDomainPeriod(value?.period) + && isBooleanOrUndefined(value?.isPromo) + && isStringOrUndefined(value?.promoStart) + && isStringOrUndefined(value?.promoEnd) + ); +} + +export function explainJokerComApiDomainPrice (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'price', + 'currency', + 'period', + 'isPromo', + 'promoStart', + 'promoEnd' + ]), + explainProperty("price", explainJokerComApiPriceAmount(value?.price)), + explainProperty("currency", explainJokerComApiCurrency(value?.currency)), + explainProperty("period", explainJokerComApiDomainPeriod(value?.period)), + explainProperty("isPromo", explainBooleanOrUndefined(value?.isPromo)), + explainProperty("promoStart", explainStringOrUndefined(value?.promoStart)), + explainProperty("promoEnd", explainStringOrUndefined(value?.promoEnd)) + ] + ); +} + +export function stringifyJokerComApiDomainPrice (value : JokerComApiDomainPrice) : string { + return `JokerComApiDomainPrice(${value})`; +} + +export function parseJokerComApiDomainPrice (value: any) : JokerComApiDomainPrice | undefined { + if (isJokerComApiDomainPrice(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerComApiDomainPriceType.ts b/com/joker/dmapi/types/JokerComApiDomainPriceType.ts new file mode 100644 index 0000000..cd85df4 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiDomainPriceType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerComApiDomainPriceType { + CREATE = "create", + RENEW = "renew", + TRANSFER = "transfer", + RESTORE = "restore" +} + +export function isJokerComApiDomainPriceType (value: any): value is JokerComApiDomainPriceType { + switch (value) { + case JokerComApiDomainPriceType.CREATE: + case JokerComApiDomainPriceType.RENEW: + case JokerComApiDomainPriceType.TRANSFER: + case JokerComApiDomainPriceType.RESTORE: + return true; + default: + return false; + } +} + +export function explainJokerComApiDomainPriceType (value: any): string { + return explainEnum("JokerComApiDomainCheckPriceType", JokerComApiDomainPriceType, isJokerComApiDomainPriceType, value); +} + +export function stringifyJokerComApiDomainPriceType (value: JokerComApiDomainPriceType): string { + switch (value) { + case JokerComApiDomainPriceType.CREATE : return 'create'; + case JokerComApiDomainPriceType.RENEW : return 'renew'; + case JokerComApiDomainPriceType.TRANSFER : return 'transfer'; + case JokerComApiDomainPriceType.RESTORE : return 'restore'; + } + throw new TypeError(`Unsupported JokerComApiDomainCheckPriceType value: ${value}`); +} + +export function parseJokerComApiDomainPriceType (value: any): JokerComApiDomainPriceType | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toLowerCase()) { + case 'create' : return JokerComApiDomainPriceType.CREATE; + case 'renew' : return JokerComApiDomainPriceType.RENEW; + case 'transfer' : return JokerComApiDomainPriceType.TRANSFER; + case 'restore' : return JokerComApiDomainPriceType.RESTORE; + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerComApiDomainStatus.ts b/com/joker/dmapi/types/JokerComApiDomainStatus.ts new file mode 100644 index 0000000..948dc18 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiDomainStatus.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerComApiDomainStatus { + UNAVAILABLE = "unavailable", + PREMIUM = "premium", + AVAILABLE = "available" +} + +export function isJokerComApiDomainStatus (value: any): value is JokerComApiDomainStatus { + switch (value) { + case JokerComApiDomainStatus.UNAVAILABLE: + case JokerComApiDomainStatus.PREMIUM: + case JokerComApiDomainStatus.AVAILABLE: + return true; + default: + return false; + } +} + +export function explainJokerComApiDomainStatus (value: any): string { + return explainEnum("JokerComApiDomainStatus", JokerComApiDomainStatus, isJokerComApiDomainStatus, value); +} + +export function stringifyJokerComApiDomainStatus (value: JokerComApiDomainStatus): string { + switch (value) { + case JokerComApiDomainStatus.UNAVAILABLE : return 'unavailable'; + case JokerComApiDomainStatus.PREMIUM : return 'premium'; + case JokerComApiDomainStatus.AVAILABLE : return 'available'; + } + throw new TypeError(`Unsupported JokerComApiDomainStatus value: ${value}`); +} + +export function parseJokerComApiDomainStatus (value: any): JokerComApiDomainStatus | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toLowerCase()) { + case 'unavailable' : return JokerComApiDomainStatus.UNAVAILABLE; + case 'premium' : return JokerComApiDomainStatus.PREMIUM; + case 'available' : return JokerComApiDomainStatus.AVAILABLE; + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerComApiLoginDTO.ts b/com/joker/dmapi/types/JokerComApiLoginDTO.ts new file mode 100644 index 0000000..5c3619a --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiLoginDTO.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainJokerStringObject, isJokerStringObject, JokerStringObject } from "./JokerStringObject"; +import { explainJokerComApiUserAccess, isJokerComApiUserAccess, JokerComApiUserAccess } from "./JokerComApiUserAccess"; +import { explainJokerComApiCurrency, isJokerComApiCurrency, JokerComApiCurrency } from "./JokerComApiCurrency"; +import { explainJokerComApiPriceAmount, isJokerComApiPriceAmount, JokerComApiPriceAmount } from "./JokerComApiPriceAmount"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, isString } from "../../../../types/String"; +import { explainNumber, isNumber } from "../../../../types/Number"; +import { explainStringArray, isStringArray } from "../../../../types/StringArray"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +/** + * @see https://joker.com/faq/content/26/14/en/login.html + */ +export interface JokerComApiLoginDTO { + + readonly headers : JokerStringObject; + readonly authSID : string; + readonly uid : string; + readonly userLogin : string; + readonly sessionTimeout : number; + readonly userAccess : JokerComApiUserAccess; + readonly accountCurrency : JokerComApiCurrency; + readonly accountBalance : JokerComApiPriceAmount; + readonly accountPendingAmount : JokerComApiPriceAmount; + readonly accountRebate : number; + readonly accountContractDate : string; + readonly statsNumberOfDomains : number; + readonly statsLastLogin : string; + readonly statsLastIp : string; + readonly statsLastError : string; + readonly statsLastErrorIp : string; + readonly statsNumberOfAutoRenew : number; + + /** + * List of domain TLDs which are available to the reseller. + */ + readonly tldList : readonly string[]; + +} + +export function createJokerComApiLoginDTO ( + headers : JokerStringObject, + authSID : string, + uid : string, + userLogin : string, + sessionTimeout : number, + userAccess : JokerComApiUserAccess, + accountCurrency : JokerComApiCurrency, + accountBalance : JokerComApiPriceAmount, + accountPendingAmount : JokerComApiPriceAmount, + accountRebate : number, + accountContractDate : string, + statsNumberOfDomains : number, + statsLastLogin : string, + statsLastIp : string, + statsLastError : string, + statsLastErrorIp : string, + statsNumberOfAutoRenew : number, + tldList : readonly string[] +) : JokerComApiLoginDTO { + return { + authSID, + uid, + userLogin, + sessionTimeout, + userAccess, + accountCurrency, + accountBalance, + accountPendingAmount, + accountRebate, + accountContractDate, + statsNumberOfDomains, + statsLastLogin, + statsLastIp, + statsLastError, + statsLastErrorIp, + statsNumberOfAutoRenew, + tldList, + headers + }; +} + +export function isJokerComApiLoginDTO (value: any) : value is JokerComApiLoginDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'headers', + 'authSID', + 'uid', + 'userLogin', + 'sessionTimeout', + 'userAccess', + 'accountCurrency', + 'accountBalance', + 'accountPendingAmount', + 'accountRebate', + 'accountContractDate', + 'statsNumberOfDomains', + 'statsLastLogin', + 'statsLastIp', + 'statsLastError', + 'statsLastErrorIp', + 'statsNumberOfAutoRenew', + 'tldList' + ]) + && isJokerStringObject(value?.headers) + && isString(value?.authSID) + && isString(value?.uid) + && isString(value?.userLogin) + && isNumber(value?.sessionTimeout) + && isJokerComApiUserAccess(value?.userAccess) + && isJokerComApiCurrency(value?.accountCurrency) + && isJokerComApiPriceAmount(value?.accountBalance) + && isJokerComApiPriceAmount(value?.accountPendingAmount) + && isNumber(value?.accountRebate) + && isString(value?.accountContractDate) + && isNumber(value?.statsNumberOfDomains) + && isString(value?.statsLastLogin) + && isString(value?.statsLastIp) + && isString(value?.statsLastError) + && isString(value?.statsLastErrorIp) + && isNumber(value?.statsNumberOfAutoRenew) + && isStringArray(value?.tldList) + ); +} + +export function explainJokerComApiLoginDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'headers', + 'authSID', + 'uid', + 'userLogin', + 'sessionTimeout', + 'userAccess', + 'accountCurrency', + 'accountBalance', + 'accountPendingAmount', + 'accountRebate', + 'accountContractDate', + 'statsNumberOfDomains', + 'statsLastLogin', + 'statsLastIp', + 'statsLastError', + 'statsLastErrorIp', + 'statsNumberOfAutoRenew', + 'tldList' + ]), + explainProperty("authSID", explainString(value?.authSID)), + explainProperty("uid", explainString(value?.uid)), + explainProperty("userLogin", explainString(value?.userLogin)), + explainProperty("sessionTimeout", explainNumber(value?.sessionTimeout)), + explainProperty("userAccess", explainJokerComApiUserAccess(value?.userAccess)), + explainProperty("accountCurrency", explainJokerComApiCurrency(value?.accountCurrency)), + explainProperty("accountBalance", explainJokerComApiPriceAmount(value?.accountBalance)), + explainProperty("accountPendingAmount", explainJokerComApiPriceAmount(value?.accountPendingAmount)), + explainProperty("accountRebate", explainNumber(value?.accountRebate)), + explainProperty("accountContractDate", explainString(value?.accountContractDate)), + explainProperty("statsNumberOfDomains", explainNumber(value?.statsNumberOfDomains)), + explainProperty("statsLastLogin", explainString(value?.statsLastLogin)), + explainProperty("statsLastIp", explainString(value?.statsLastIp)), + explainProperty("statsLastError", explainString(value?.statsLastError)), + explainProperty("statsLastErrorIp", explainString(value?.statsLastErrorIp)), + explainProperty("statsNumberOfAutoRenew", explainNumber(value?.statsNumberOfAutoRenew)), + explainProperty("tldList", explainStringArray(value?.tldList)), + explainProperty("headers", explainJokerStringObject(value?.headers)) + ] + ); +} + +export function stringifyJokerComApiLoginDTO (value : JokerComApiLoginDTO) : string { + return `JokerComApiLoginDTO(${value})`; +} + +export function parseJokerComApiLoginDTO (value: any) : JokerComApiLoginDTO | undefined { + if (isJokerComApiLoginDTO(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerComApiPeriodType.ts b/com/joker/dmapi/types/JokerComApiPeriodType.ts new file mode 100644 index 0000000..b44b3f5 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiPeriodType.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerComApiPeriodType { + YEARS = "YEARS", + MONTHS = "MONTHS" +} + +export function isJokerComApiPeriodType (value: any): value is JokerComApiPeriodType { + switch (value) { + case JokerComApiPeriodType.YEARS: + case JokerComApiPeriodType.MONTHS: + return true; + default: + return false; + } +} + +export function explainJokerComApiPeriodType (value: any): string { + return explainEnum("JokerComApiPeriodType", JokerComApiPeriodType, isJokerComApiPeriodType, value); +} + +export function stringifyJokerComApiPeriodType (value: JokerComApiPeriodType): string { + switch (value) { + case JokerComApiPeriodType.YEARS : return 'YEARS'; + case JokerComApiPeriodType.MONTHS : return 'MONTHS'; + } + throw new TypeError(`Unsupported JokerComApiPeriodType value: ${value}`); +} + +export function parseJokerComApiPeriodType (value: any): JokerComApiPeriodType | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toUpperCase()) { + case 'YEARS' : return JokerComApiPeriodType.YEARS; + case 'MONTHS' : return JokerComApiPeriodType.MONTHS; + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerComApiPriceAmount.ts b/com/joker/dmapi/types/JokerComApiPriceAmount.ts new file mode 100644 index 0000000..15c30a4 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiPriceAmount.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { trim } from "../../../../functions/trim"; +import { explainNumber, explainNumberOrUndefined, isNumber, isNumberOrUndefined } from "../../../../types/Number"; + +export type JokerComApiPriceAmount = number; + +export function isJokerComApiPriceAmount (value : any) : value is JokerComApiPriceAmount { + return isNumber(value); +} + +export function explainJokerComApiPriceAmount (value : any) : string { + return explainNumber(value); +} + +export function isJokerComApiPriceAmountOrUndefined (value : any) : value is JokerComApiPriceAmount { + return isNumberOrUndefined(value); +} + +export function explainJokerComApiPriceAmountOrUndefined (value : any) : string { + return explainNumberOrUndefined(value); +} + +export function parseJokerComApiPriceAmount (value: string): JokerComApiPriceAmount | undefined { + if ( trim(value) === '' ) return undefined; + return parseFloat(value); +} diff --git a/com/joker/dmapi/types/JokerComApiPriceListDTO.ts b/com/joker/dmapi/types/JokerComApiPriceListDTO.ts new file mode 100644 index 0000000..986600d --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiPriceListDTO.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainJokerStringObject, isJokerStringObject, JokerStringObject } from "./JokerStringObject"; +import { explainJokerComApiPriceListItem, isJokerComApiPriceListItem, JokerComApiPriceListItem } from "./JokerComApiPriceListItem"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; +import { explainArrayOf, isArrayOf } from "../../../../types/Array"; + +/** + * + * @see https://joker.com/faq/content/79/455/en/query_whois.html + */ +export interface JokerComApiPriceListDTO { + readonly headers : JokerStringObject; + readonly body : readonly JokerComApiPriceListItem[]; +} + +export function createJokerComApiPriceListDTO ( + headers: JokerStringObject, + body : readonly JokerComApiPriceListItem[] +) : JokerComApiPriceListDTO { + return { + headers, + body + }; +} + +export function isJokerComApiPriceListDTO (value: any) : value is JokerComApiPriceListDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'headers', + 'body' + ]) + && isJokerStringObject(value?.headers) + && isArrayOf(value?.body, isJokerComApiPriceListItem) + ); +} + +export function explainJokerComApiPriceListDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'headers', + 'body' + ]), + explainProperty("headers", explainJokerStringObject(value?.headers)), + explainProperty( + "body", + explainArrayOf( + "JokerComApiPriceListItem", + explainJokerComApiPriceListItem, + value?.body, + isJokerComApiPriceListItem + ) + ) + ] + ); +} + +export function stringifyJokerComApiPriceListDTO (value : JokerComApiPriceListDTO) : string { + return `JokerComApiPriceListDTO(${value})`; +} + +export function parseJokerComApiPriceListDTO (value: any) : JokerComApiPriceListDTO | undefined { + if (isJokerComApiPriceListDTO(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerComApiPriceListItem.ts b/com/joker/dmapi/types/JokerComApiPriceListItem.ts new file mode 100644 index 0000000..9ef8d20 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiPriceListItem.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { filter } from "../../../../functions/filter"; +import { map } from "../../../../functions/map"; +import { reduce } from "../../../../functions/reduce"; +import { trim } from "../../../../functions/trim"; +import { explainJokerComApiPriceListItemType, isJokerComApiPriceListItemType, JokerComApiPriceListItemType, parseJokerComApiPriceListItemType } from "./JokerComApiPriceListItemType"; +import { explainJokerComApiPriceListItemOperation, isJokerComApiPriceListItemOperation, JokerComApiPriceListItemOperation, parseJokerComApiPriceListItemOperation } from "./JokerComApiPriceListItemOperation"; +import { explainJokerStringObject, isJokerStringObject, JokerStringObject } from "./JokerStringObject"; +import { explainJokerComApiPriceAmountOrUndefined, isJokerComApiPriceAmountOrUndefined, JokerComApiPriceAmount, parseJokerComApiPriceAmount } from "./JokerComApiPriceAmount"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface JokerComApiPriceListItem { + readonly type : JokerComApiPriceListItemType; + readonly operation : JokerComApiPriceListItemOperation; + readonly tld : string; + readonly tldCurrency : string; + readonly currency : string; + readonly price1y : JokerComApiPriceAmount | undefined; + readonly price2y : JokerComApiPriceAmount | undefined; + readonly price3y : JokerComApiPriceAmount | undefined; + readonly price4y : JokerComApiPriceAmount | undefined; + readonly price5y : JokerComApiPriceAmount | undefined; + readonly price6y : JokerComApiPriceAmount | undefined; + readonly price7y : JokerComApiPriceAmount | undefined; + readonly price8y : JokerComApiPriceAmount | undefined; + readonly price9y : JokerComApiPriceAmount | undefined; + readonly price10y : JokerComApiPriceAmount | undefined; + readonly validFrom : string | undefined; + readonly validUntil : string | undefined; + readonly columns : JokerStringObject; +} + +export function createJokerComApiPriceListItem ( + type : JokerComApiPriceListItemType, + operation : JokerComApiPriceListItemOperation, + tld : string, + tldCurrency : string, + currency : string, + price1y : JokerComApiPriceAmount | undefined, + price2y : JokerComApiPriceAmount | undefined, + price3y : JokerComApiPriceAmount | undefined, + price4y : JokerComApiPriceAmount | undefined, + price5y : JokerComApiPriceAmount | undefined, + price6y : JokerComApiPriceAmount | undefined, + price7y : JokerComApiPriceAmount | undefined, + price8y : JokerComApiPriceAmount | undefined, + price9y : JokerComApiPriceAmount | undefined, + price10y : JokerComApiPriceAmount | undefined, + validFrom : string | undefined, + validUntil : string | undefined, + columns : JokerStringObject +) : JokerComApiPriceListItem { + return { + type, + operation, + tld, + tldCurrency, + currency, + price1y, + price2y, + price3y, + price4y, + price5y, + price6y, + price7y, + price8y, + price9y, + price10y, + validFrom, + validUntil, + columns + }; +} + +export function isJokerComApiPriceListItem (value: any) : value is JokerComApiPriceListItem { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'type', + 'operation', + 'tld', + 'tldCurrency', + 'currency', + 'price1y', + 'price2y', + 'price3y', + 'price4y', + 'price5y', + 'price6y', + 'price7y', + 'price8y', + 'price9y', + 'price10y', + 'validFrom', + 'validUntil', + 'columns' + ]) + && isJokerComApiPriceListItemType(value?.type) + && isJokerComApiPriceListItemOperation(value?.operation) + && isString(value?.tld) + && isString(value?.tldCurrency) + && isString(value?.currency) + && isJokerComApiPriceAmountOrUndefined(value?.price1y) + && isJokerComApiPriceAmountOrUndefined(value?.price2y) + && isJokerComApiPriceAmountOrUndefined(value?.price3y) + && isJokerComApiPriceAmountOrUndefined(value?.price4y) + && isJokerComApiPriceAmountOrUndefined(value?.price5y) + && isJokerComApiPriceAmountOrUndefined(value?.price6y) + && isJokerComApiPriceAmountOrUndefined(value?.price7y) + && isJokerComApiPriceAmountOrUndefined(value?.price8y) + && isJokerComApiPriceAmountOrUndefined(value?.price9y) + && isJokerComApiPriceAmountOrUndefined(value?.price10y) + && isStringOrUndefined(value?.validFrom) + && isStringOrUndefined(value?.validUntil) + && isJokerStringObject(value?.columns) + ); +} + +export function explainJokerComApiPriceListItem (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'type', + 'operation', + 'tld', + 'tldCurrency', + 'currency', + 'price1y', + 'price2y', + 'price3y', + 'price4y', + 'price5y', + 'price6y', + 'price7y', + 'price8y', + 'price9y', + 'price10y', + 'validFrom', + 'validUntil', + 'columns' + ]), + explainProperty("type", explainJokerComApiPriceListItemType(value?.type)), + explainProperty("operation", explainJokerComApiPriceListItemOperation(value?.operation)), + explainProperty("tld", explainString(value?.tld)), + explainProperty("tldCurrency", explainString(value?.tldCurrency)), + explainProperty("currency", explainString(value?.currency)), + explainProperty("price1y", explainJokerComApiPriceAmountOrUndefined(value?.price1y)), + explainProperty("price2y", explainJokerComApiPriceAmountOrUndefined(value?.price2y)), + explainProperty("price3y", explainJokerComApiPriceAmountOrUndefined(value?.price3y)), + explainProperty("price4y", explainJokerComApiPriceAmountOrUndefined(value?.price4y)), + explainProperty("price5y", explainJokerComApiPriceAmountOrUndefined(value?.price5y)), + explainProperty("price6y", explainJokerComApiPriceAmountOrUndefined(value?.price6y)), + explainProperty("price7y", explainJokerComApiPriceAmountOrUndefined(value?.price7y)), + explainProperty("price8y", explainJokerComApiPriceAmountOrUndefined(value?.price8y)), + explainProperty("price9y", explainJokerComApiPriceAmountOrUndefined(value?.price9y)), + explainProperty("price10y", explainJokerComApiPriceAmountOrUndefined(value?.price10y)), + explainProperty("validFrom", explainStringOrUndefined(value?.validFrom)), + explainProperty("validUntil", explainStringOrUndefined(value?.validUntil)), + explainProperty("columns", explainJokerStringObject(value?.columns)) + ] + ); +} + +export function stringifyJokerComApiPriceListItem (value : JokerComApiPriceListItem) : string { + return `JokerComApiPriceListItem(${value})`; +} + +export function parseJokerComApiPriceListItem (value: any) : JokerComApiPriceListItem | undefined { + if (isJokerComApiPriceListItem(value)) return value; + return undefined; +} + +export function parseJokerComApiPriceListItemFromString ( + value : string, + columnsKeys : readonly string[] +) : JokerComApiPriceListItem { + const columnValues = value.split('\t'); + const columns : JokerStringObject = reduce( + columnsKeys, + (obj: JokerStringObject, key: string, index: number) : JokerStringObject => { + const value = index < columnValues.length ? columnValues[index] : undefined; + return { + ...obj, + [key.toLowerCase()]: value ?? '' + }; + }, + {} + ); + const typeString = columns['type']; + const type = parseJokerComApiPriceListItemType(typeString); + if (!type) throw new TypeError(`parseJokerComApiPriceListItemFromString: Could not parse "type" from: "${value}": ${explainJokerComApiPriceListItemType(typeString)}`); + const operationString = columns['operation']; + const operation = parseJokerComApiPriceListItemOperation(operationString); + if (!operation) throw new TypeError(`parseJokerComApiPriceListItemFromString: Could not parse "operation" from: "${value}": ${explainJokerComApiPriceListItemOperation(operationString)}`); + const tld = columns['tld']; + if (!tld) throw new TypeError(`parseJokerComApiPriceListItemFromString: Could not parse "tld" from: "${value}"`); + const tldCurrency = parseCurrency(columns['tld-currency']); + if (!tldCurrency) throw new TypeError(`parseJokerComApiPriceListItemFromString: Could not parse "tld-currency" from: "${value}"`); + const currency = parseCurrency(columns['currency']); + if (!currency) throw new TypeError(`parseJokerComApiPriceListItemFromString: Could not parse "currency" from: "${value}"`); + const price1y = parseJokerComApiPriceAmount(columns['price-1y']); + const price2y = parseJokerComApiPriceAmount(columns['price-2y']); + const price3y = parseJokerComApiPriceAmount(columns['price-3y']); + const price4y = parseJokerComApiPriceAmount(columns['price-4y']); + const price5y = parseJokerComApiPriceAmount(columns['price-5y']); + const price6y = parseJokerComApiPriceAmount(columns['price-6y']); + const price7y = parseJokerComApiPriceAmount(columns['price-7y']); + const price8y = parseJokerComApiPriceAmount(columns['price-8y']); + const price9y = parseJokerComApiPriceAmount(columns['price-9y']); + const price10y = parseJokerComApiPriceAmount(columns['price-10y']); + const validFrom = parseDate(columns['valid_from']); + const validUntil = parseDate(columns['valid_until']); + return createJokerComApiPriceListItem( + type, + operation, + tld, + tldCurrency, + currency, + price1y, + price2y, + price3y, + price4y, + price5y, + price6y, + price7y, + price8y, + price9y, + price10y, + validFrom, + validUntil, + columns + ); +} + +export function parseDate (value : string) : string | undefined { + value = trim(value); + if (value === '') return undefined; + if (value === 'n/a') return undefined; + return value; +} + +export function parseCurrency (value : string) : string | undefined { + value = trim(value); + if (value === '') return undefined; + if (value === 'n/a') return undefined; + return value; +} + +export function parseJokerComApiPriceListItemListFromString ( + value : string, + columns : readonly string[] +) : readonly JokerComApiPriceListItem[] { + if (value === '') return []; + return map( + filter( + value.split('\n'), + (line : string ) : boolean => !!line + ), + (line: string) : JokerComApiPriceListItem => parseJokerComApiPriceListItemFromString( + line, + columns + ) + ); +} diff --git a/com/joker/dmapi/types/JokerComApiPriceListItemOperation.ts b/com/joker/dmapi/types/JokerComApiPriceListItemOperation.ts new file mode 100644 index 0000000..1415301 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiPriceListItemOperation.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerComApiPriceListItemOperation { + CREATE = "create", + TRANSFER = "transfer", + RENEW = "renew", + RGP = "rgp", + TRUSTEE = "trustee", + PRIVACY_BASIC = "privacy-basic", + PRIVACY_PRO = "privacy-pro", +} + +export function isJokerComApiPriceListItemOperation (value: any): value is JokerComApiPriceListItemOperation { + switch (value) { + case JokerComApiPriceListItemOperation.CREATE: + case JokerComApiPriceListItemOperation.TRANSFER: + case JokerComApiPriceListItemOperation.RENEW: + case JokerComApiPriceListItemOperation.RGP: + case JokerComApiPriceListItemOperation.TRUSTEE: + case JokerComApiPriceListItemOperation.PRIVACY_BASIC: + case JokerComApiPriceListItemOperation.PRIVACY_PRO: + return true; + default: + return false; + } +} + +export function explainJokerComApiPriceListItemOperation (value: any): string { + return explainEnum("JokerComApiPriceListItemOperation", JokerComApiPriceListItemOperation, isJokerComApiPriceListItemOperation, value); +} + +export function stringifyJokerComApiPriceListItemOperation (value: JokerComApiPriceListItemOperation): string { + switch (value) { + case JokerComApiPriceListItemOperation.CREATE : return 'create'; + case JokerComApiPriceListItemOperation.TRANSFER : return 'transfer'; + case JokerComApiPriceListItemOperation.RENEW : return 'renew'; + case JokerComApiPriceListItemOperation.RGP : return 'rgp'; + case JokerComApiPriceListItemOperation.TRUSTEE : return 'trustee'; + case JokerComApiPriceListItemOperation.PRIVACY_BASIC : return 'privacy-basic'; + case JokerComApiPriceListItemOperation.PRIVACY_PRO : return 'privacy-pro'; + } + throw new TypeError(`Unsupported JokerComApiPriceListItemOperation value: ${value}`); +} + +export function parseJokerComApiPriceListItemOperation (value: any): JokerComApiPriceListItemOperation | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toLowerCase()) { + case 'create' : return JokerComApiPriceListItemOperation.CREATE; + case 'transfer' : return JokerComApiPriceListItemOperation.TRANSFER; + case 'renew' : return JokerComApiPriceListItemOperation.RENEW; + case 'rgp' : return JokerComApiPriceListItemOperation.RGP; + case 'trustee' : return JokerComApiPriceListItemOperation.TRUSTEE; + case 'privacy-basic' : return JokerComApiPriceListItemOperation.PRIVACY_BASIC; + case 'privacy-pro' : return JokerComApiPriceListItemOperation.PRIVACY_PRO; + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerComApiPriceListItemType.ts b/com/joker/dmapi/types/JokerComApiPriceListItemType.ts new file mode 100644 index 0000000..7338802 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiPriceListItemType.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerComApiPriceListItemType { + DOMAIN = "domain", + DOMAIN_PROMO = "domain_promo" +} + +export function isJokerComApiPriceListItemType (value: any): value is JokerComApiPriceListItemType { + switch (value) { + case JokerComApiPriceListItemType.DOMAIN: + case JokerComApiPriceListItemType.DOMAIN_PROMO: + return true; + default: + return false; + } +} + +export function explainJokerComApiPriceListItemType (value: any): string { + return explainEnum("JokerComApiPriceListItemType", JokerComApiPriceListItemType, isJokerComApiPriceListItemType, value); +} + +export function stringifyJokerComApiPriceListItemType (value: JokerComApiPriceListItemType): string { + switch (value) { + case JokerComApiPriceListItemType.DOMAIN : return 'domain'; + case JokerComApiPriceListItemType.DOMAIN_PROMO : return 'domain_promo'; + } + throw new TypeError(`Unsupported JokerComApiPriceListItemType value: ${value}`); +} + +export function parseJokerComApiPriceListItemType (value: any): JokerComApiPriceListItemType | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toLowerCase()) { + case 'domain' : return JokerComApiPriceListItemType.DOMAIN; + case 'domain_promo' : return JokerComApiPriceListItemType.DOMAIN_PROMO; + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerComApiProfileDTO.ts b/com/joker/dmapi/types/JokerComApiProfileDTO.ts new file mode 100644 index 0000000..8ddf0ba --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiProfileDTO.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainJokerStringObject, isJokerStringObject, JokerStringObject } from "./JokerStringObject"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, isString } from "../../../../types/String"; +import { explainNumber, isNumber } from "../../../../types/Number"; +import { explainStringArray, isStringArray } from "../../../../types/StringArray"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface JokerComApiProfileDTO { + readonly body : JokerStringObject; + readonly headers : JokerStringObject; + readonly customerId : string; + readonly firstName : string; + readonly lastName : string; + readonly organization : string; + readonly city : string; + readonly address : readonly string[]; + readonly postalCode : string; + readonly state : string; + readonly phone : string; + readonly fax : string; + readonly balance : number; + readonly vatId : string; + readonly lastPayment : string; + readonly lastAccess : string; + readonly adminEmail : string; + readonly robotEmail : string; + readonly checkdIp : readonly string[]; + readonly http : string; + readonly url : string; + readonly whois : readonly string[]; +} + +export function createJokerComApiProfileDTO ( + headers : JokerStringObject, + body : JokerStringObject, + customerId : string, + firstName : string, + lastName : string, + organization : string, + city : string, + address : readonly string[], + postalCode : string, + state : string, + phone : string, + fax : string, + balance : number, + vatId : string, + lastPayment : string, + lastAccess : string, + adminEmail : string, + robotEmail : string, + checkdIp : readonly string[], + http : string, + url : string, + whois : readonly string[] +) : JokerComApiProfileDTO { + return { + headers, + body, + customerId, + firstName, + lastName, + organization, + city, + address, + postalCode, + state, + phone, + fax, + balance, + vatId, + lastPayment, + lastAccess, + adminEmail, + robotEmail, + checkdIp, + http, + url, + whois + }; +} + +export function isJokerComApiProfileDTO (value: any) : value is JokerComApiProfileDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'headers', + 'body', + 'customerId', + 'firstName', + 'lastName', + 'organization', + 'city', + 'address', + 'postalCode', + 'state', + 'phone', + 'fax', + 'balance', + 'vatId', + 'lastPayment', + 'lastAccess', + 'adminEmail', + 'robotEmail', + 'checkdIp', + 'http', + 'url', + 'whois' + ]) + && isJokerStringObject(value?.headers) + && isJokerStringObject(value?.body) + && isString(value?.customerId) + && isString(value?.firstName) + && isString(value?.lastName) + && isString(value?.organization) + && isString(value?.city) + && isStringArray(value?.address) + && isString(value?.postalCode) + && isString(value?.state) + && isString(value?.phone) + && isString(value?.fax) + && isNumber(value?.balance) + && isString(value?.vatId) + && isString(value?.lastPayment) + && isString(value?.lastAccess) + && isString(value?.adminEmail) + && isString(value?.robotEmail) + && isStringArray(value?.checkdIp) + && isString(value?.http) + && isString(value?.url) + && isStringArray(value?.whois) + ); +} + +export function explainJokerComApiProfileDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'headers', + 'body', + 'customerId', + 'firstName', + 'lastName', + 'organization', + 'city', + 'address', + 'postalCode', + 'state', + 'phone', + 'fax', + 'balance', + 'vatId', + 'lastPayment', + 'lastAccess', + 'adminEmail', + 'robotEmail', + 'checkdIp', + 'http', + 'url', + 'whois' + ]), + explainProperty("headers", explainJokerStringObject(value?.headers)), + explainProperty("body", explainJokerStringObject(value?.body)), + explainProperty("customerId", explainString(value?.customerId)), + explainProperty("firstName", explainString(value?.firstName)), + explainProperty("lastName", explainString(value?.lastName)), + explainProperty("organization", explainString(value?.organization)), + explainProperty("city", explainString(value?.city)), + explainProperty("address", explainStringArray(value?.address)), + explainProperty("postalCode", explainString(value?.postalCode)), + explainProperty("state", explainString(value?.state)), + explainProperty("phone", explainString(value?.phone)), + explainProperty("fax", explainString(value?.fax)), + explainProperty("balance", explainNumber(value?.balance)), + explainProperty("vatId", explainString(value?.vatId)), + explainProperty("lastPayment", explainString(value?.lastPayment)), + explainProperty("lastAccess", explainString(value?.lastAccess)), + explainProperty("adminEmail", explainString(value?.adminEmail)), + explainProperty("robotEmail", explainString(value?.robotEmail)), + explainProperty("checkdIp", explainStringArray(value?.checkdIp)), + explainProperty("http", explainString(value?.http)), + explainProperty("url", explainString(value?.url)), + explainProperty("whois", explainStringArray(value?.whois)) + ] + ); +} + +export function stringifyJokerComApiProfileDTO (value : JokerComApiProfileDTO) : string { + return `JokerComApiProfileDTO(${value})`; +} + +export function parseJokerComApiProfileDTO (value: any) : JokerComApiProfileDTO | undefined { + if (isJokerComApiProfileDTO(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerComApiRegisterDTO.ts b/com/joker/dmapi/types/JokerComApiRegisterDTO.ts new file mode 100644 index 0000000..c658b0e --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiRegisterDTO.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainJokerStringObject, isJokerStringObject, JokerStringObject } from "./JokerStringObject"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, isString } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface JokerComApiRegisterDTO { + readonly trackingId : string; + readonly headers : JokerStringObject; +} + +export function createJokerComApiRegisterDTO ( + trackingId : string, + headers: JokerStringObject +) : JokerComApiRegisterDTO { + return { + trackingId, + headers + }; +} + +export function isJokerComApiRegisterDTO (value: any) : value is JokerComApiRegisterDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'trackingId', + 'headers' + ]) + && isString(value?.trackingId) + && isJokerStringObject(value?.headers) + ); +} + +export function explainJokerComApiRegisterDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'trackingId', + 'headers' + ]), + explainProperty("trackingId", explainString(value?.trackingId)), + explainProperty("headers", explainJokerStringObject(value?.headers)), + ] + ); +} + +export function stringifyJokerComApiRegisterDTO (value : JokerComApiRegisterDTO) : string { + return `JokerComApiRegisterDTO(${value})`; +} + +export function parseJokerComApiRegisterDTO (value: any) : JokerComApiRegisterDTO | undefined { + if (isJokerComApiRegisterDTO(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerComApiUserAccess.ts b/com/joker/dmapi/types/JokerComApiUserAccess.ts new file mode 100644 index 0000000..05122b0 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiUserAccess.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerComApiUserAccess { + FULL = '@full', + READONLY = '@read-only' +} + +export function isJokerComApiUserAccess (value: any) : value is JokerComApiUserAccess { + switch (value) { + case JokerComApiUserAccess.FULL: + case JokerComApiUserAccess.READONLY: + return true; + default: + return false; + } +} + +export function explainJokerComApiUserAccess (value : any) : string { + return explainEnum("JokerComApiAccessLevel", JokerComApiUserAccess, isJokerComApiUserAccess, value); +} + +export function stringifyJokerComApiUserAccess (value : JokerComApiUserAccess) : string { + switch (value) { + case JokerComApiUserAccess.FULL : return '@full'; + case JokerComApiUserAccess.READONLY : return '@read-only'; + } + throw new TypeError(`Unsupported JokerComApiAccessLevel value: ${value}`) +} + +export function parseJokerComApiUserAccess (value: any) : JokerComApiUserAccess | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toLowerCase()) { + + case '@full': + case 'full' : return JokerComApiUserAccess.FULL; + + case '@readonly': + case '@read-only': + case 'read-only': + case 'readonly' : return JokerComApiUserAccess.READONLY; + + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerComApiWhoisContactDTO.ts b/com/joker/dmapi/types/JokerComApiWhoisContactDTO.ts new file mode 100644 index 0000000..57ebf25 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiWhoisContactDTO.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainJokerStringObject, isJokerStringObject, JokerStringObject } from "./JokerStringObject"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, isString } from "../../../../types/String"; +import { explainStringArray, isStringArray } from "../../../../types/StringArray"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +/** + * + * @see https://joker.com/faq/content/79/455/en/query_whois.html + */ +export interface JokerComApiWhoisContactDTO { + readonly headers : JokerStringObject; + readonly body : JokerStringObject; + readonly name : string; + readonly organization : string; + readonly address : readonly string[]; + readonly city : string; + readonly postalCode : string; + readonly country : string; + readonly email : string; + readonly phone : string; + readonly handle : string; + readonly created : string; + readonly modified : string; +} + +export function createJokerComApiWhoisContactDTO ( + name : string, + organization : string, + address : readonly string[], + city : string, + postalCode : string, + country : string, + email : string, + phone : string, + handle : string, + created : string, + modified : string, + headers : JokerStringObject, + body : JokerStringObject +) : JokerComApiWhoisContactDTO { + return { + name, + organization, + address, + city, + postalCode, + country, + email, + phone, + handle, + created, + modified, + headers, + body + }; +} + +export function isJokerComApiWhoisContactDTO (value: any) : value is JokerComApiWhoisContactDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name', + 'organization', + 'address', + 'city', + 'postalCode', + 'country', + 'email', + 'phone', + 'handle', + 'created', + 'modified', + 'headers', + 'body' + ]) + && isString(value?.name) + && isString(value?.organization) + && isStringArray(value?.address) + && isString(value?.city) + && isString(value?.postalCode) + && isString(value?.country) + && isString(value?.email) + && isString(value?.phone) + && isString(value?.handle) + && isString(value?.created) + && isString(value?.modified) + && isJokerStringObject(value?.headers) + && isJokerStringObject(value?.body) + ); +} + +export function explainJokerComApiWhoisContactDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'name', + 'organization', + 'address', + 'city', + 'postalCode', + 'country', + 'email', + 'phone', + 'handle', + 'created', + 'modified', + 'headers', + 'body' + ]), + explainProperty("organization", explainString(value?.organization)), + explainProperty("address", explainStringArray(value?.address)), + explainProperty("city", explainString(value?.city)), + explainProperty("postalCode", explainString(value?.postalCode)), + explainProperty("country", explainString(value?.country)), + explainProperty("email", explainString(value?.email)), + explainProperty("phone", explainString(value?.phone)), + explainProperty("handle", explainString(value?.handle)), + explainProperty("created", explainString(value?.created)), + explainProperty("modified", explainString(value?.modified)), + explainProperty("headers", explainJokerStringObject(value?.headers)), + explainProperty("body", explainJokerStringObject(value?.body)) + ] + ); +} + +export function stringifyJokerComApiWhoisContactDTO (value : JokerComApiWhoisContactDTO) : string { + return `JokerComApiWhoisContactDTO(${value})`; +} + +export function parseJokerComApiWhoisContactDTO (value: any) : JokerComApiWhoisContactDTO | undefined { + if (isJokerComApiWhoisContactDTO(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerComApiWhoisDTO.ts b/com/joker/dmapi/types/JokerComApiWhoisDTO.ts new file mode 100644 index 0000000..46c2c40 --- /dev/null +++ b/com/joker/dmapi/types/JokerComApiWhoisDTO.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainJokerStringObject, isJokerStringObject, JokerStringObject } from "./JokerStringObject"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +/** + * + * @see https://joker.com/faq/content/79/455/en/query_whois.html + */ +export interface JokerComApiWhoisDTO { + readonly body : JokerStringObject; + readonly headers : JokerStringObject; +} + +export function createJokerComApiWhoisDTO ( + headers: JokerStringObject, + body : JokerStringObject +) : JokerComApiWhoisDTO { + return { + headers, + body + }; +} + +export function isJokerComApiWhoisDTO (value: any) : value is JokerComApiWhoisDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'headers', + 'body' + ]) + && isJokerStringObject(value?.headers) + && isJokerStringObject(value?.body) + ); +} + +export function explainJokerComApiWhoisDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'headers', + 'body' + ]), + explainProperty("headers", explainJokerStringObject(value?.headers)), + explainProperty("body", explainJokerStringObject(value?.body)) + ] + ); +} + +export function stringifyJokerComApiWhoisDTO (value : JokerComApiWhoisDTO) : string { + return `JokerComApiWhoisDTO(${value})`; +} + +export function parseJokerComApiWhoisDTO (value: any) : JokerComApiWhoisDTO | undefined { + if (isJokerComApiWhoisDTO(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerDMAPIResponseObject.ts b/com/joker/dmapi/types/JokerDMAPIResponseObject.ts new file mode 100644 index 0000000..293b3ea --- /dev/null +++ b/com/joker/dmapi/types/JokerDMAPIResponseObject.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { JokerStringObject } from "./JokerStringObject"; + +export interface JokerDMAPIResponseObject { + readonly headers: JokerStringObject; + readonly body: string; +} diff --git a/com/joker/dmapi/types/JokerDomainResult.ts b/com/joker/dmapi/types/JokerDomainResult.ts new file mode 100644 index 0000000..aab4181 --- /dev/null +++ b/com/joker/dmapi/types/JokerDomainResult.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explain, explainProperty } from "../../../../types/explain"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../../../types/Boolean"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface JokerDomainResult { + readonly domain : string; + readonly expiration : string; + readonly status ?: string; + readonly jokerns ?: boolean; + readonly grants ?: string; +} + +export function createJokerDomainResult ( + domain : string, + expiration : string, + status ?: string, + jokerns ?: boolean, + grants ?: string, +) : JokerDomainResult { + return { + domain, + expiration, + status, + jokerns, + grants + }; +} + +export function isJokerDomainResult (value: any) : value is JokerDomainResult { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'domain', + 'expiration', + 'status', + 'jokerns', + 'grants' + ]) + && isString(value?.domain) + && isString(value?.expiration) + && isStringOrUndefined(value?.status) + && isBooleanOrUndefined(value?.jokerns) + && isStringOrUndefined(value?.grants) + ); +} + +export function explainJokerDomainResult (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'domain', + 'expiration', + 'status', + 'jokerns', + 'grants' + ]) + , explainProperty("domain", explainString(value?.domain)) + , explainProperty("expiration", explainString(value?.expiration)) + , explainProperty("status", explainStringOrUndefined(value?.status)) + , explainProperty("jokerns", explainBooleanOrUndefined(value?.jokerns)) + , explainProperty("grants", explainStringOrUndefined(value?.grants)) + ] + ); +} + +export function stringifyJokerDomainResult (value : JokerDomainResult) : string { + return `JokerDomainResult(${value})`; +} + +export function parseJokerDomainResult (value: any) : JokerDomainResult | undefined { + if (isJokerDomainResult(value)) return value; + return undefined; +} diff --git a/com/joker/dmapi/types/JokerPrivacyType.ts b/com/joker/dmapi/types/JokerPrivacyType.ts new file mode 100644 index 0000000..3d55383 --- /dev/null +++ b/com/joker/dmapi/types/JokerPrivacyType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum JokerPrivacyType { + BASIC = "basic", + PRO = "pro", + NONE = "none", + KEEP = "keep" +} + +export function isJokerPrivacyType (value: any): value is JokerPrivacyType { + switch (value) { + case JokerPrivacyType.BASIC: + case JokerPrivacyType.PRO: + case JokerPrivacyType.NONE: + case JokerPrivacyType.KEEP: + return true; + default: + return false; + } +} + +export function explainJokerPrivacyType (value: any): string { + return explainEnum("JokerPrivacyType", JokerPrivacyType, isJokerPrivacyType, value); +} + +export function stringifyJokerPrivacyType (value: JokerPrivacyType): string { + switch (value) { + case JokerPrivacyType.BASIC : return 'basic'; + case JokerPrivacyType.PRO : return 'pro'; + case JokerPrivacyType.NONE : return 'none'; + case JokerPrivacyType.KEEP : return 'keep'; + } + throw new TypeError(`Unsupported JokerPrivacyType value: ${value}`); +} + +export function parseJokerPrivacyType (value: any): JokerPrivacyType | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toLowerCase()) { + case 'basic' : return JokerPrivacyType.BASIC; + case 'pro' : return JokerPrivacyType.PRO; + case 'none' : return JokerPrivacyType.NONE; + case 'keep' : return JokerPrivacyType.KEEP; + default : return undefined; + } +} diff --git a/com/joker/dmapi/types/JokerRequestArgumentObject.ts b/com/joker/dmapi/types/JokerRequestArgumentObject.ts new file mode 100644 index 0000000..776565f --- /dev/null +++ b/com/joker/dmapi/types/JokerRequestArgumentObject.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export interface JokerRequestArgumentObject { + readonly [key: string]: string; +} diff --git a/com/joker/dmapi/types/JokerStringObject.ts b/com/joker/dmapi/types/JokerStringObject.ts new file mode 100644 index 0000000..7255ea3 --- /dev/null +++ b/com/joker/dmapi/types/JokerStringObject.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../../types/String"; +import { explainObjectOf, isObjectOf } from "../../../../types/Object"; + +export interface JokerStringObject { + readonly [key: string]: string; +} + +export function isJokerStringObject (value: any) : value is JokerStringObject { + return ( + isObjectOf(value, isString, isString) + ); +} + +export function explainJokerStringObject (value: any) : string { + return explainObjectOf( + value, + isString, + isString, + "string", + ); +} + +export function stringifyJokerStringObject (value : JokerStringObject) : string { + return `JokerStringObject(${value})`; +} + +export function parseJokerStringObject (value: any) : JokerStringObject | undefined { + if (isJokerStringObject(value)) return value; + return undefined; +} + diff --git a/com/joker/whois/JokerComWhoisService.ts b/com/joker/whois/JokerComWhoisService.ts new file mode 100644 index 0000000..bf1e002 --- /dev/null +++ b/com/joker/whois/JokerComWhoisService.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../../LogService"; +import { first } from "../../../functions/first"; +import { WhoisService } from "../../../whois/WhoisService"; +import { WhoisLookupResult } from "../../../whois/types/WhoisLookupResult"; +import { createWhoisServerOptions, WhoisServerOptions } from "../../../whois/types/WhoisServerOptions"; +import { JokerComWhoisDTO, parseJokerComWhoisDTOFromString } from "./types/JokerComWhoisDTO"; + +const LOG = LogService.createLogger('JokerComWhoisService'); + +export const JOKER_WHOIS_HOSTNAME = "whois.joker.com"; +export const JOKER_WHOIS_PORT = 4343; +export const JOKER_WHOIS_QUERY = "domain:$addr\r\n"; + +/** + * @see https://joker.com/faq/content/85/437/en/check-domain-availability.html + * @see example at https://github.com/heusalagroup/whois.hg.fi/blob/main/src/controllers/FiHgWhoisBackendController.ts#L62 + */ +export class JokerComWhoisService { + + private readonly _whois : WhoisService; + private readonly _jokerServer : WhoisServerOptions; + + /** + * Returns default joker.com whois server settings + */ + public static getJokerServer () : WhoisServerOptions { + return createWhoisServerOptions( + JOKER_WHOIS_HOSTNAME, + JOKER_WHOIS_PORT, + JOKER_WHOIS_QUERY + ); + } + + /** + * Create a service for accessing whois.joker.com + * + * @param service + * @param server + */ + constructor ( + service: WhoisService, + server : WhoisServerOptions = JokerComWhoisService.getJokerServer() + ) { + this._whois = service; + this._jokerServer = server; + } + + /** + * Lookup for a domain name using whois.joker.com + * + * @param address + */ + public async jokerLookup ( + address: string + ) : Promise { + const server = this._jokerServer; + LOG.debug(`lookupJoker: "${address}": server: `, server); + const payload : readonly WhoisLookupResult[] = await this._whois.whoisLookup( + address, + { + server, + follow: 0 + } + ); + LOG.debug(`lookupJoker: "${address}": payload: `, payload); + const response = first(payload); + const dto = response?.data ? parseJokerComWhoisDTOFromString(response?.data) : undefined; + if (!dto) { + LOG.error(`jokerLookup: Could not parse for "${address}": payload: `, payload); + throw new Error(`lookupJoker: Could not parse whois server response for "${address}": ${payload}`); + } + return dto; + } + +} diff --git a/com/joker/whois/types/JokerComDomainStatus.ts b/com/joker/whois/types/JokerComDomainStatus.ts new file mode 100644 index 0000000..b4d2b2e --- /dev/null +++ b/com/joker/whois/types/JokerComDomainStatus.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { trim } from "../../../../functions/trim"; +import { explainEnum } from "../../../../types/Enum"; + +/** + * Joker.com whois domain status + * + * @see https://joker.com/faq/content/85/437/en/check-domain-availability.html + */ +export enum JokerComDomainStatus { + + /** + * Domain is available + */ + FREE = "FREE", + + /** + * Domain is already registered or not available + */ + REGISTERED = "REGISTERED", + + /** + * Domain status is unknown (registry down, etc) + */ + UNKNOWN = "UNKNOWN" + +} + +export function isJokerComDomainStatus (value: any): value is JokerComDomainStatus { + switch (value) { + case JokerComDomainStatus.FREE: + case JokerComDomainStatus.REGISTERED: + case JokerComDomainStatus.UNKNOWN: + return true; + default: + return false; + } +} + +export function explainJokerComDomainStatus (value: any): string { + return explainEnum("JokerDomainStatus", JokerComDomainStatus, isJokerComDomainStatus, value); +} + +export function stringifyJokerDomainStatus (value: JokerComDomainStatus): string { + switch (value) { + case JokerComDomainStatus.FREE : return 'FREE'; + case JokerComDomainStatus.REGISTERED : return 'REGISTERED'; + case JokerComDomainStatus.UNKNOWN : return 'UNKNOWN'; + } + throw new TypeError(`Unsupported JokerDomainStatus value: ${value}`); +} + +export function parseJokerComDomainStatus (value: any): JokerComDomainStatus | undefined { + if ( value === undefined ) return undefined; + switch (trim(`${value}`).toLowerCase()) { + case 'free' : return JokerComDomainStatus.FREE; + case 'registered' : return JokerComDomainStatus.REGISTERED; + case 'unknown' : return JokerComDomainStatus.UNKNOWN; + default : return undefined; + } +} diff --git a/com/joker/whois/types/JokerComWhoisDTO.ts b/com/joker/whois/types/JokerComWhoisDTO.ts new file mode 100644 index 0000000..f9e0fa6 --- /dev/null +++ b/com/joker/whois/types/JokerComWhoisDTO.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { + explainJokerComDomainStatus, + isJokerComDomainStatus, + JokerComDomainStatus, + parseJokerComDomainStatus +} from "./JokerComDomainStatus"; +import { startsWith } from "../../../../functions/startsWith"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, isString } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface JokerComWhoisDTO { + readonly name : string; + readonly state : JokerComDomainStatus; +} + +export function createJokerComWhoisDTO ( + name : string, + state : JokerComDomainStatus +) : JokerComWhoisDTO { + return { + name, + state + }; +} + +export function isJokerComWhoisDTO (value: any) : value is JokerComWhoisDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name', + 'state' + ]) + && isString(value?.name) + && isJokerComDomainStatus(value?.state) + ); +} + +export function explainJokerComWhoisDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'name', + 'state' + ]) + , explainProperty("name", explainString(value?.name)) + , explainProperty("state", explainJokerComDomainStatus(value?.state)) + ] + ); +} + +export function stringifyJokerComWhoisDTO (value : JokerComWhoisDTO) : string { + return `JokerWhoisDTO(${value})`; +} + +/** + * + * @param value + * @see https://joker.com/faq/content/85/437/en/check-domain-availability.html + */ +export function parseJokerComWhoisDTOFromString (value: string) : JokerComWhoisDTO | undefined { + if (!startsWith(value, 'domain:')) { + return undefined; + } + value = value.substring('domain:'.length); + const parts = value.split(/\s+/); + const name = parts.shift(); + if (!name) throw new TypeError(`parseJokerComWhoisDTOFromString: Could not parse domain name from value: "${value}"`); + return createJokerComWhoisDTO( + name, + parseJokerComDomainStatus(parts.shift()) ?? JokerComDomainStatus.UNKNOWN + ); +} + +/** + * + * @param value + * @see https://joker.com/faq/content/85/437/en/check-domain-availability.html + */ +export function parseJokerComWhoisDTO (value: any) : JokerComWhoisDTO | undefined { + if (isString(value)) return parseJokerComWhoisDTOFromString(value); + if (isJokerComWhoisDTO(value)) return value; + return undefined; +} diff --git a/components/HyperComponentCollection.ts b/components/HyperComponentCollection.ts new file mode 100644 index 0000000..c625946 --- /dev/null +++ b/components/HyperComponentCollection.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { reduce } from "../functions/reduce"; +import { uniq } from "../functions/uniq"; +import { LogService } from "../LogService"; +import { ComponentDTO } from "../entities/component/ComponentDTO"; +import { ComponentFactory } from "../services/ComponentFactory"; +import { ComponentFactoryService } from "../services/ComponentFactoryService"; +import { ComponentEntity } from "../entities/component/ComponentEntity"; +import { ComponentType } from "../entities/component/ComponentType"; +import { registerActionButtonComponent } from "./actionButton/ActionButtonComponent"; +import { registerArticleComponent } from "./article/ArticleComponent"; +import { registerButtonComponent } from "./button/ButtonComponent"; +import { registerDivComponent } from "./div/DivComponent"; +import { registerFormComponent } from "./form/FormComponent"; +import { registerHeadingComponent } from "./heading/HeadingComponent"; +import { registerImageComponent } from "./image/ImageComponent"; +import { registerLinkButtonComponent } from "./linkButton/LinkButtonComponent"; +import { registerParagraphComponent } from "./paragraph/ParagraphComponent"; +import { registerSpanComponent } from "./span/SpanComponent"; +import { registerSubTitleComponent } from "./subTitle/SubTitleComponent"; +import { registerTableColumnComponent } from "./table/column/TableColumnComponent"; +import { registerTableRowComponent } from "./table/row/TableRowComponent"; +import { registerTableComponent } from "./table/TableComponent"; +import { registerTitleComponent } from "./title/TitleComponent"; + +const LOG = LogService.createLogger( 'HyperComponentCollection' ); + +/** + * Base collection of hyper components. + */ +export class HyperComponentCollection { + + /** + * Register base collection of components. + * + * @param factory The factory where to register components. + */ + public static registerToFactory ( + factory: ComponentFactory, + ) : void { + registerActionButtonComponent(factory); + registerArticleComponent(factory); + registerButtonComponent(factory); + registerDivComponent(factory); + registerFormComponent(factory); + registerHeadingComponent(factory); + registerImageComponent(factory); + registerLinkButtonComponent(factory); + registerParagraphComponent(factory); + registerSpanComponent(factory); + registerSubTitleComponent(factory); + registerTableColumnComponent(factory); + registerTableRowComponent(factory); + registerTableComponent(factory); + registerTitleComponent(factory); + } + + /** + * Create a new component factory with the base collection of components. + */ + public static createFactory () : ComponentFactory { + const factory : ComponentFactory = ComponentFactoryService.create(); + this.registerToFactory(factory); + return factory; + } + + /** + * Create a collection of HyperComponentDTOs using standard components for + * specified component types. + * + * @param types Component entities to search for parents (extends) to create base DTOs. + */ + public static createBaseCollection ( + types: readonly ComponentType[], + ) : readonly ComponentDTO[] { + + const factory : ComponentFactory = this.createFactory(); + + const extendList : readonly string[] = uniq( + reduce( + types, + (list : readonly string[], type: ComponentType) : readonly string[] => { + const entity : ComponentEntity = type.create('x'); + const extend : string | undefined = entity.getExtend(); + return extend === undefined ? list : [ + ...list, + extend + ]; + }, + [] + ) + ); + LOG.debug(`createCollection: extendList = `, extendList); + + return reduce( + extendList, + ( list : readonly ComponentDTO[], extend: string) : readonly ComponentDTO[] => { + + if ( !factory.hasComponent( extend ) ) { + return list; + } + + const dto: ComponentDTO | undefined = factory.createComponentDTO( extend ); + if ( dto === undefined ) { + LOG.warn( `Warning! Could not create DTO even though the component was registered. This should not happen.` ); + return list; + } + + return [ + ...list, + dto + ]; + }, + [] + ); + } + +} diff --git a/components/actionButton/ActionButtonComponent.ts b/components/actionButton/ActionButtonComponent.ts new file mode 100644 index 0000000..359094f --- /dev/null +++ b/components/actionButton/ActionButtonComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const ACTION_BUTTON_COMPONENT_NAME: string = 'ActionButtonComponent'; + +export type ActionButtonComponent = ComponentDTO; + +export function createActionButtonComponent ( +) : ActionButtonComponent { + return ( + ComponentEntity.create(ACTION_BUTTON_COMPONENT_NAME) + .extend(HyperComponent.ActionButton) + .getDTO() + ); +} + +export function registerActionButtonComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(ACTION_BUTTON_COMPONENT_NAME, createActionButtonComponent); +} diff --git a/components/actionButton/ActionButtonEntity.ts b/components/actionButton/ActionButtonEntity.ts new file mode 100644 index 0000000..84a30b3 --- /dev/null +++ b/components/actionButton/ActionButtonEntity.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ActionEntity } from "../../entities/action/ActionEntity"; +import { ReadonlyJsonAny } from "../../Json"; +import { isString } from "../../types/String"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { ActionDTO } from "../../entities/action/ActionDTO"; +import { ACTION_BUTTON_COMPONENT_NAME } from "./ActionButtonComponent"; + +export class ActionButtonEntity extends ComponentEntity { + + protected constructor (name : string) { + super(name); + this.extend(ACTION_BUTTON_COMPONENT_NAME); + } + + public static create (name : string) : ActionButtonEntity { + return new ActionButtonEntity(name); + } + + public static createButton ( + name: string, + dto: ActionDTO | string, + ) : ActionButtonEntity { + + if (isString(dto)) { + return this.createButton( + name, + ActionEntity.create() + .label('') + .target(dto) + .method('link') + .getDTO() + ); + } + + const text = dto.label; + const href = dto.target; + const method = dto.method ?? 'POST'; + const body = dto.body; + const successRedirect = dto.successRedirect; + const failureRedirect = dto.errorRedirect; + + return this.create(name).addText(text).setMeta( + { + href, + successRedirect: successRedirect as unknown as ReadonlyJsonAny, + failureRedirect: failureRedirect as unknown as ReadonlyJsonAny, + method: method.toUpperCase(), + body, + } + ); + + } + +} diff --git a/components/article/ArticleComponent.test.ts b/components/article/ArticleComponent.test.ts new file mode 100644 index 0000000..d0b2ef8 --- /dev/null +++ b/components/article/ArticleComponent.test.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { HyperComponent } from "../../entities/types/HyperComponent"; +import {ARTICLE_COMPONENT_NAME, ArticleComponent, createArticleComponent} from "./ArticleComponent"; + +describe('createArticleComponent', () => { + it('should create ArticleComponent with default values', () => { + + const articleComponent: ArticleComponent = createArticleComponent(); + + expect(articleComponent).toEqual( + expect.objectContaining({ + name: ARTICLE_COMPONENT_NAME, + extend: HyperComponent.Article, + }) + ); + }); +}); diff --git a/components/article/ArticleComponent.ts b/components/article/ArticleComponent.ts new file mode 100644 index 0000000..71d47de --- /dev/null +++ b/components/article/ArticleComponent.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const ARTICLE_COMPONENT_NAME: string = 'ArticleComponent'; + +export type ArticleComponent = ComponentDTO; + +export function createArticleComponent ( +) : ArticleComponent { + return ComponentEntity.create().name(ARTICLE_COMPONENT_NAME).extend(HyperComponent.Article).getDTO(); +} + +export function registerArticleComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(ARTICLE_COMPONENT_NAME, createArticleComponent); +} diff --git a/components/article/ArticleEntity.ts b/components/article/ArticleEntity.ts new file mode 100644 index 0000000..54eadfb --- /dev/null +++ b/components/article/ArticleEntity.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { ARTICLE_COMPONENT_NAME } from "./ArticleComponent"; + +export class ArticleEntity extends ComponentEntity { + + protected constructor (name : string) { + super(name); + this.extend( ARTICLE_COMPONENT_NAME ); + } + + public addText (value: string) : this { + return this.addContent([value]); + } + + public static create (name : string) : ArticleEntity { + return new this(name); + } + + public static createText ( + name : string, + text : string, + ) : ArticleEntity { + return this.create(name).addText(text); + } + +} diff --git a/components/articleText/ArticleTextEntity.test.ts b/components/articleText/ArticleTextEntity.test.ts new file mode 100644 index 0000000..800fc53 --- /dev/null +++ b/components/articleText/ArticleTextEntity.test.ts @@ -0,0 +1,21 @@ +import {ArticleTextEntity} from "./ArticleTextEntity"; +import {ARTICLE_COMPONENT_NAME} from "../article/ArticleComponent"; + +describe('ArticleTextEntity', () => { + describe('#createText', () => { + it('should create ArticleText with provided name and text', () => { + const name = 'ArticleTextName'; + const text = 'Lorem ipsum dolor sit amet.'; + + const expectedArticleText: any = { + name: name, + extend: ARTICLE_COMPONENT_NAME, + content: [text], + }; + + const articleText: ArticleTextEntity = ArticleTextEntity.createText(name, text); + + expect(articleText.getDTO()).toEqual(expectedArticleText); + }); + }); +}); diff --git a/components/articleText/ArticleTextEntity.ts b/components/articleText/ArticleTextEntity.ts new file mode 100644 index 0000000..daeb123 --- /dev/null +++ b/components/articleText/ArticleTextEntity.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ArticleEntity } from "../article/ArticleEntity"; + +export class ArticleTextEntity extends ArticleEntity { + + public static create (name : string) : ArticleTextEntity { + return new this(name); + } + + public static createText ( + name : string, + text : string, + ) : ArticleTextEntity { + return this.create(name).addText(text); + } + +} diff --git a/components/button/ButtonComponent.test.ts b/components/button/ButtonComponent.test.ts new file mode 100644 index 0000000..76c5a1f --- /dev/null +++ b/components/button/ButtonComponent.test.ts @@ -0,0 +1,15 @@ +import { HyperComponent } from "../../entities/types/HyperComponent"; +import {BUTTON_COMPONENT_NAME, ButtonComponent, createButtonComponent} from "./ButtonComponent"; + +describe('createButtonComponent', () => { + it('should create ButtonComponent with default values', () => { + const expectedButtonComponent: ButtonComponent = { + name: BUTTON_COMPONENT_NAME, + extend: HyperComponent.Button, + }; + + const buttonComponent: ButtonComponent = createButtonComponent(); + + expect(buttonComponent).toEqual(expectedButtonComponent); + }); +}); diff --git a/components/button/ButtonComponent.ts b/components/button/ButtonComponent.ts new file mode 100644 index 0000000..080a033 --- /dev/null +++ b/components/button/ButtonComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const BUTTON_COMPONENT_NAME: string = 'ButtonComponent'; + +export type ButtonComponent = ComponentDTO; + +export function createButtonComponent ( +) : ButtonComponent { + return ( + ComponentEntity.create(BUTTON_COMPONENT_NAME) + .extend(HyperComponent.Button) + .getDTO() + ); +} + +export function registerButtonComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(BUTTON_COMPONENT_NAME, createButtonComponent); +} diff --git a/components/button/ButtonEntity.test.ts b/components/button/ButtonEntity.test.ts new file mode 100644 index 0000000..233ff0f --- /dev/null +++ b/components/button/ButtonEntity.test.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import {ButtonEntity} from "./ButtonEntity"; +import {BUTTON_COMPONENT_NAME} from "./ButtonComponent"; + +describe('ButtonEntity', () => { + describe('createButton', () => { + it('should create Button with provided name, text, and eventName', () => { + const name = 'ButtonName'; + const text = 'Click me'; + const eventName = 'buttonClick'; + + const expectedButton: any = { + name: name, + content: [text], + extend: BUTTON_COMPONENT_NAME, + meta: { + eventName: eventName, + } + }; + + const button: ButtonEntity = ButtonEntity.createButton(name, text, eventName); + + expect(button.getDTO()).toEqual(expectedButton); + + }); + }); +}); diff --git a/components/button/ButtonEntity.ts b/components/button/ButtonEntity.ts new file mode 100644 index 0000000..d52a46a --- /dev/null +++ b/components/button/ButtonEntity.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { BUTTON_COMPONENT_NAME } from "./ButtonComponent"; + +export class ButtonEntity extends ComponentEntity { + + protected constructor (name : string) { + super(name); + this.extend( BUTTON_COMPONENT_NAME ); + } + + public static create (name : string) : ButtonEntity { + return new this(name); + } + + public static createButton ( + name : string, + text : string, + eventName : string, + ) : ButtonEntity { + return this.create(name).addText(text).setEventName(eventName); + } + + public setEventName (eventName : string) : this { + return this.setMeta({eventName}); + } + +} diff --git a/components/div/DivComponent.ts b/components/div/DivComponent.ts new file mode 100644 index 0000000..95bba05 --- /dev/null +++ b/components/div/DivComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const DIV_COMPONENT_NAME: string = 'DivComponent'; + +export type DivComponent = ComponentDTO; + +export function createDivComponent ( +) : DivComponent { + return ( + ComponentEntity.create(DIV_COMPONENT_NAME) + .extend(HyperComponent.Div) + .getDTO() + ); +} + +export function registerDivComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(DIV_COMPONENT_NAME, createDivComponent); +} diff --git a/components/div/DivEntity.ts b/components/div/DivEntity.ts new file mode 100644 index 0000000..512391c --- /dev/null +++ b/components/div/DivEntity.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { DIV_COMPONENT_NAME } from "./DivComponent"; + +export class DivEntity extends ComponentEntity { + + protected constructor ( + name : string, + ) { + super(name); + this.extend(DIV_COMPONENT_NAME); + } + + public static create ( + name : string, + ) : DivEntity { + return new this( name ); + } + + public static createText ( + name : string, + value : string, + ) : DivEntity { + return this.create(name).addText(value); + } + + public addText (value: string) : this { + return this.addContent([value]); + } + +} diff --git a/components/form/FormComponent.ts b/components/form/FormComponent.ts new file mode 100644 index 0000000..4dac11a --- /dev/null +++ b/components/form/FormComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const FORM_COMPONENT_NAME: string = 'FormComponent'; + +export type FormComponent = ComponentDTO; + +export function createFormComponent ( +) : FormComponent { + return ( + ComponentEntity.create(FORM_COMPONENT_NAME) + .extend(HyperComponent.Form) + .getDTO() + ); +} + +export function registerFormComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(FORM_COMPONENT_NAME, createFormComponent); +} diff --git a/components/form/FormEntity.ts b/components/form/FormEntity.ts new file mode 100644 index 0000000..e210acc --- /dev/null +++ b/components/form/FormEntity.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentContent } from "../../entities/component/ComponentContent"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { FORM_COMPONENT_NAME } from "./FormComponent"; + +export class FormEntity extends ComponentEntity { + + protected constructor (name : string) { + super(name); + this.extend( FORM_COMPONENT_NAME ); + } + + public static create (name : string) : FormEntity { + return new this(name); + } + + public static createForm ( + name: string, + content: ComponentContent, + ) : FormEntity { + return this.create(name).addContent(content).setMeta({}); + } + +} diff --git a/components/heading/HeadingComponent.ts b/components/heading/HeadingComponent.ts new file mode 100644 index 0000000..3c84a48 --- /dev/null +++ b/components/heading/HeadingComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const HEADING_COMPONENT_NAME: string = 'HeadingComponent'; + +export type HeadingComponent = ComponentDTO; + +export function createHeadingComponent ( +) : HeadingComponent { + return ( + ComponentEntity.create(HEADING_COMPONENT_NAME) + .extend(HyperComponent.H3) + .getDTO() + ); +} + +export function registerHeadingComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(HEADING_COMPONENT_NAME, createHeadingComponent); +} diff --git a/components/heading/HeadingEntity.ts b/components/heading/HeadingEntity.ts new file mode 100644 index 0000000..d3c4e75 --- /dev/null +++ b/components/heading/HeadingEntity.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HEADING_COMPONENT_NAME } from "./HeadingComponent"; + +export class HeadingEntity extends ComponentEntity { + + protected constructor ( + name : string, + ) { + super(name); + this.extend(HEADING_COMPONENT_NAME); + } + + public static create ( + name : string, + ) : HeadingEntity { + return new this( name ); + } + + public static createText ( + name : string, + value : string, + ) : HeadingEntity { + return this.create(name).addText(value); + } + + public addText (value: string) : this { + return this.addContent(value); + } + +} diff --git a/components/image/ImageComponent.ts b/components/image/ImageComponent.ts new file mode 100644 index 0000000..11617fb --- /dev/null +++ b/components/image/ImageComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const IMAGE_COMPONENT_NAME: string = 'ImageComponent'; + +export type ImageComponent = ComponentDTO; + +export function createImageComponent ( +) : ImageComponent { + return ( + ComponentEntity.create(IMAGE_COMPONENT_NAME) + .extend(HyperComponent.Image) + .getDTO() + ); +} + +export function registerImageComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(IMAGE_COMPONENT_NAME, createImageComponent); +} diff --git a/components/image/ImageEntity.ts b/components/image/ImageEntity.ts new file mode 100644 index 0000000..2c6b8f5 --- /dev/null +++ b/components/image/ImageEntity.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { IMAGE_COMPONENT_NAME } from "./ImageComponent"; + +const IMAGE_SOURCE_META_KEY : string = "source"; +const IMAGE_ALT_META_KEY : string = "alt"; + +export class ImageEntity extends ComponentEntity { + + protected constructor ( + name : string, + ) { + super(name); + this.extend(IMAGE_COMPONENT_NAME); + } + + public getSource () : string | undefined { + return this.getMetaString(IMAGE_SOURCE_META_KEY); + } + + public setSource (source : string) : this { + return this.setMetaString(IMAGE_SOURCE_META_KEY, source); + } + + public getAltText () : string | undefined { + return this.getMetaString(IMAGE_ALT_META_KEY); + } + + public setAltText (alt : string) : this { + return this.setMetaString(IMAGE_ALT_META_KEY, alt); + } + + public static create ( + name : string, + ) : ImageEntity { + return new this( name ); + } + + public static createImage ( + name : string, + source : string, + alt : string = '', + ) : ImageEntity { + return this.create(name).setSource(source).setAltText(alt); + } + +} diff --git a/components/linkButton/LinkButtonComponent.test.ts b/components/linkButton/LinkButtonComponent.test.ts new file mode 100644 index 0000000..690e5fb --- /dev/null +++ b/components/linkButton/LinkButtonComponent.test.ts @@ -0,0 +1,21 @@ +import { HyperComponent } from "../../entities/types/HyperComponent"; +import {createLinkButtonComponent, LinkButtonComponent, LINK_BUTTON_COMPONENT_NAME} from "./LinkButtonComponent"; + +describe('createLinkButtonComponent', () => { + it('should create LinkButtonComponent with default values', () => { + + const expectedLinkButtonComponent: LinkButtonComponent = { + name: LINK_BUTTON_COMPONENT_NAME, + extend: HyperComponent.LinkButton, + }; + + const linkButtonComponent: LinkButtonComponent = createLinkButtonComponent(); + + expect(linkButtonComponent).toEqual( + expect.objectContaining( + expectedLinkButtonComponent + ) + ); + + }); +}); \ No newline at end of file diff --git a/components/linkButton/LinkButtonComponent.ts b/components/linkButton/LinkButtonComponent.ts new file mode 100644 index 0000000..dd21345 --- /dev/null +++ b/components/linkButton/LinkButtonComponent.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { BorderStyle } from "../../entities/types/BorderStyle"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { BorderEntity } from "../../entities/border/BorderEntity"; +import { StyleEntity } from "../../entities/style/StyleEntity"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const LINK_BUTTON_COMPONENT_NAME: string = 'LinkButtonComponent'; + +export type LinkButtonComponent = ComponentDTO; + +export function createLinkButtonComponent ( +) : LinkButtonComponent { + return ( + ComponentEntity.create(LINK_BUTTON_COMPONENT_NAME) + .extend(HyperComponent.LinkButton) + .style( + StyleEntity.create() + .setBorder( + BorderEntity.create() + .setWidth(1) + .setStyle(BorderStyle.SOLID) + .getDTO() + ) + ) + .getDTO() + ); +} + +export function registerLinkButtonComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(LINK_BUTTON_COMPONENT_NAME, createLinkButtonComponent); +} diff --git a/components/linkButton/LinkButtonEntity.test.ts b/components/linkButton/LinkButtonEntity.test.ts new file mode 100644 index 0000000..e0e84dc --- /dev/null +++ b/components/linkButton/LinkButtonEntity.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import {LinkButtonEntity} from "./LinkButtonEntity"; +import { LINK_BUTTON_COMPONENT_NAME, LinkButtonComponent } from "./LinkButtonComponent"; + +describe('LinkButtonEntity', () => { + describe('createLinkButton', () => { + it('should create LinkButton with provided name, text, and href', () => { + const name = 'LinkButtonName'; + const text = 'Visit our website'; + const href = 'https://example.com'; + + const expectedLinkButton: LinkButtonComponent = { + name: name, + extend: LINK_BUTTON_COMPONENT_NAME, + content: [text], + meta: { + href: href, + }, + }; + + const linkButton: LinkButtonEntity = LinkButtonEntity.createButton(name, text, href); + + expect(linkButton.getDTO()).toEqual(expectedLinkButton); + }); + }); +}); \ No newline at end of file diff --git a/components/linkButton/LinkButtonEntity.ts b/components/linkButton/LinkButtonEntity.ts new file mode 100644 index 0000000..33c4678 --- /dev/null +++ b/components/linkButton/LinkButtonEntity.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { LINK_BUTTON_COMPONENT_NAME } from "./LinkButtonComponent"; + +export class LinkButtonEntity extends ComponentEntity { + + protected constructor (name : string) { + super(name); + this.extend(LINK_BUTTON_COMPONENT_NAME); + } + + public setText (value : string) : this { + return this.addContent([value]); + } + + public setHref (href : string) : this { + return this.setMeta({href}); + } + + public static create (name : string) : LinkButtonEntity { + return new this(name); + } + + public static createButton ( + name: string, + text: string, + href: string, + ) : LinkButtonEntity { + return ( + this.create(name) + .setText(text) + .setHref(href) + ); + } + +} diff --git a/components/paragraph/ParagraphComponent.ts b/components/paragraph/ParagraphComponent.ts new file mode 100644 index 0000000..b47278a --- /dev/null +++ b/components/paragraph/ParagraphComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const PARAGRAPH_COMPONENT_NAME: string = 'ParagraphComponent'; + +export type ParagraphComponent = ComponentDTO; + +export function createParagraphComponent ( +) : ParagraphComponent { + return ( + ComponentEntity.create(PARAGRAPH_COMPONENT_NAME) + .extend(HyperComponent.Paragraph) + .getDTO() + ); +} + +export function registerParagraphComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(PARAGRAPH_COMPONENT_NAME, createParagraphComponent); +} diff --git a/components/paragraph/ParagraphEntity.ts b/components/paragraph/ParagraphEntity.ts new file mode 100644 index 0000000..cf9363b --- /dev/null +++ b/components/paragraph/ParagraphEntity.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { PARAGRAPH_COMPONENT_NAME } from "./ParagraphComponent"; + +export class ParagraphEntity extends ComponentEntity { + + protected constructor ( + name : string, + ) { + super(name); + this.extend(PARAGRAPH_COMPONENT_NAME); + } + + public static create ( + name : string, + ) : ParagraphEntity { + return new this( name ); + } + + public static createText ( + name : string, + value : string, + ) : ParagraphEntity { + return this.create(name).addText(value); + } + + public addText (value: string) : this { + return this.addContent([value]); + } + +} diff --git a/components/span/SpanComponent.ts b/components/span/SpanComponent.ts new file mode 100644 index 0000000..00ffb19 --- /dev/null +++ b/components/span/SpanComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const SPAN_COMPONENT_NAME: string = 'SpanComponent'; + +export type SpanComponent = ComponentDTO; + +export function createSpanComponent ( +) : SpanComponent { + return ( + ComponentEntity.create(SPAN_COMPONENT_NAME) + .extend(HyperComponent.Span) + .getDTO() + ); +} + +export function registerSpanComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(SPAN_COMPONENT_NAME, createSpanComponent); +} diff --git a/components/span/SpanEntity.ts b/components/span/SpanEntity.ts new file mode 100644 index 0000000..8ab23ec --- /dev/null +++ b/components/span/SpanEntity.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { SPAN_COMPONENT_NAME } from "./SpanComponent"; + +export class SpanEntity extends ComponentEntity { + + protected constructor ( + name : string, + ) { + super(name); + this.extend(SPAN_COMPONENT_NAME); + } + + public static create ( + name : string, + ) : SpanEntity { + return new this( name ); + } + + public static createText ( + name : string, + value : string, + ) : SpanEntity { + return this.create(name).addText(value); + } + + public addText (value: string) : this { + return this.addContent([value]); + } + +} diff --git a/components/subTitle/SubTitleComponent.ts b/components/subTitle/SubTitleComponent.ts new file mode 100644 index 0000000..46164c6 --- /dev/null +++ b/components/subTitle/SubTitleComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const SUB_TITLE_COMPONENT_NAME: string = 'SubTitleComponent'; + +export type SubTitleComponent = ComponentDTO; + +export function createSubTitleComponent ( +) : SubTitleComponent { + return ( + ComponentEntity.create(SUB_TITLE_COMPONENT_NAME) + .extend(HyperComponent.H2) + .getDTO() + ); +} + +export function registerSubTitleComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(SUB_TITLE_COMPONENT_NAME, createSubTitleComponent); +} diff --git a/components/subTitle/SubTitleEntity.ts b/components/subTitle/SubTitleEntity.ts new file mode 100644 index 0000000..4a579c3 --- /dev/null +++ b/components/subTitle/SubTitleEntity.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { SUB_TITLE_COMPONENT_NAME } from "./SubTitleComponent"; + +export class SubTitleEntity extends ComponentEntity { + + protected constructor ( + name : string, + ) { + super(name); + this.extend(SUB_TITLE_COMPONENT_NAME); + } + + public static create ( + name : string, + ) : SubTitleEntity { + return new this( name ); + } + + public static createText ( + name : string, + value : string, + ) : SubTitleEntity { + return this.create(name).addText(value); + } + + public addText (value: string) : this { + return this.addContent([value]); + } + +} diff --git a/components/table/TableComponent.ts b/components/table/TableComponent.ts new file mode 100644 index 0000000..81a8ea9 --- /dev/null +++ b/components/table/TableComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const TABLE_COMPONENT_NAME: string = 'TableComponent'; + +export type TableComponent = ComponentDTO; + +export function createTableComponent ( +) : TableComponent { + return ( + ComponentEntity.create(TABLE_COMPONENT_NAME) + .extend(HyperComponent.Table) + .getDTO() + ); +} + +export function registerTableComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(TABLE_COMPONENT_NAME, createTableComponent); +} diff --git a/components/table/TableEntity.ts b/components/table/TableEntity.ts new file mode 100644 index 0000000..4e430dc --- /dev/null +++ b/components/table/TableEntity.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentContent } from "../../entities/component/ComponentContent"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { TABLE_COMPONENT_NAME } from "./TableComponent"; +import { TableRowEntity } from "./row/TableRowEntity"; + +export class TableEntity extends ComponentEntity { + + protected constructor (name : string) { + super(name); + this.extend(TABLE_COMPONENT_NAME); + } + + public addRow (row : TableRowEntity) : this { + return this.addContent([row.getDTO()]); + } + + public static create (name: string) : TableEntity { + return new this(name); + } + + public static createTable ( + name: string, + data: ComponentContent, + ) : TableEntity { + return this.create(name).addContent(data); + } + +} diff --git a/components/table/column/TableColumnComponent.ts b/components/table/column/TableColumnComponent.ts new file mode 100644 index 0000000..1bba427 --- /dev/null +++ b/components/table/column/TableColumnComponent.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../../services/ComponentFactory"; + +export const TABLE_COLUMN_COMPONENT_NAME: string = 'TableColumnComponent'; + +export type TableColumnComponent = ComponentDTO; + +export function createTableColumnComponent () : TableColumnComponent { + return ( + ComponentEntity.create(TABLE_COLUMN_COMPONENT_NAME) + .extend(HyperComponent.TableColumn) + .getDTO() + ); +} + +export function registerTableColumnComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(TABLE_COLUMN_COMPONENT_NAME, createTableColumnComponent); +} diff --git a/components/table/column/TableColumnEntity.ts b/components/table/column/TableColumnEntity.ts new file mode 100644 index 0000000..c8680f2 --- /dev/null +++ b/components/table/column/TableColumnEntity.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { + UnreparedComponentContent, +} from "../../../entities/component/ComponentContent"; +import { ComponentEntity } from "../../../entities/component/ComponentEntity"; +import { TABLE_COLUMN_COMPONENT_NAME } from "./TableColumnComponent"; + +export class TableColumnEntity extends ComponentEntity { + + protected constructor (name : string) { + super(name); + this.extend(TABLE_COLUMN_COMPONENT_NAME); + } + + public static create (name : string) : TableColumnEntity { + return new this(name); + } + + public static createColumn ( + name: string, + data: UnreparedComponentContent, + ) : TableColumnEntity { + return this.create(name).addContent(data); + } + +} diff --git a/components/table/row/TableRowComponent.ts b/components/table/row/TableRowComponent.ts new file mode 100644 index 0000000..0a99ec7 --- /dev/null +++ b/components/table/row/TableRowComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../../services/ComponentFactory"; + +export const TABLE_ROW_COMPONENT_NAME: string = 'TableRowComponent'; + +export type TableRowComponent = ComponentDTO; + +export function createTableRowComponent ( +) : TableRowComponent { + return ( + ComponentEntity.create(TABLE_ROW_COMPONENT_NAME) + .extend(HyperComponent.TableRow) + .getDTO() + ); +} + +export function registerTableRowComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(TABLE_ROW_COMPONENT_NAME, createTableRowComponent); +} diff --git a/components/table/row/TableRowEntity.ts b/components/table/row/TableRowEntity.ts new file mode 100644 index 0000000..c46faed --- /dev/null +++ b/components/table/row/TableRowEntity.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentContent } from "../../../entities/component/ComponentContent"; +import { ComponentEntity } from "../../../entities/component/ComponentEntity"; +import { TableColumnEntity } from "../column/TableColumnEntity"; +import { TABLE_ROW_COMPONENT_NAME } from "./TableRowComponent"; + +export class TableRowEntity extends ComponentEntity { + + protected constructor (name : string) { + super(name); + this.extend(TABLE_ROW_COMPONENT_NAME); + } + + public addColumn (item : TableColumnEntity) : this { + return this.addContent([item.getDTO()]); + } + + public static create (name : string) : TableRowEntity { + return new this(name); + } + + public static createRow ( + name: string, + data: ComponentContent, + ) : TableRowEntity { + return this.create(name).addContent(data); + } + +} diff --git a/components/title/TitleComponent.test.ts b/components/title/TitleComponent.test.ts new file mode 100644 index 0000000..598b1b1 --- /dev/null +++ b/components/title/TitleComponent.test.ts @@ -0,0 +1,16 @@ +import { HyperComponent } from "../../entities/types/HyperComponent"; +import {createTitleComponent, TITLE_COMPONENT_NAME, TitleComponent} from "./TitleComponent"; + +describe('createTitleTextComponent', () => { + it('should create TitleTextComponent with default values', () => { + + const expectedTitleTextComponent: TitleComponent = { + name: TITLE_COMPONENT_NAME, + extend: HyperComponent.H1, + }; + + const titleTextComponent: TitleComponent = createTitleComponent(); + + expect(titleTextComponent).toEqual(expectedTitleTextComponent); + }); +}); \ No newline at end of file diff --git a/components/title/TitleComponent.ts b/components/title/TitleComponent.ts new file mode 100644 index 0000000..cdfd4f0 --- /dev/null +++ b/components/title/TitleComponent.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../entities/types/HyperComponent"; +import { ComponentFactory } from "../../services/ComponentFactory"; + +export const TITLE_COMPONENT_NAME: string = 'TitleComponent'; + +export type TitleComponent = ComponentDTO; + +export function createTitleComponent ( +) : TitleComponent { + return ( + ComponentEntity.create(TITLE_COMPONENT_NAME) + .extend(HyperComponent.H1) + .getDTO() + ); +} + +export function registerTitleComponent (factory: ComponentFactory) : void { + factory.registerComponentConstructor(TITLE_COMPONENT_NAME, createTitleComponent); +} diff --git a/components/title/TitleEntity.test.ts b/components/title/TitleEntity.test.ts new file mode 100644 index 0000000..fa58b94 --- /dev/null +++ b/components/title/TitleEntity.test.ts @@ -0,0 +1,21 @@ +import {TitleEntity} from "./TitleEntity"; +import {TITLE_COMPONENT_NAME} from "./TitleComponent"; + +describe('TitleEntity', () => { + describe('#createText', () => { + it('should create TitleText with provided name and text', () => { + const name = 'TitleTextName'; + const text = 'The Title'; + + const expectedTitleText: any = { + name: name, + extend: TITLE_COMPONENT_NAME, + content: [text], + }; + + const titleText: TitleEntity = TitleEntity.createText(name, text); + + expect(titleText.getDTO()).toEqual(expectedTitleText); + }); + }); +}); diff --git a/components/title/TitleEntity.ts b/components/title/TitleEntity.ts new file mode 100644 index 0000000..4f296f1 --- /dev/null +++ b/components/title/TitleEntity.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { TITLE_COMPONENT_NAME } from "./TitleComponent"; + +export class TitleEntity extends ComponentEntity { + + protected constructor ( + name : string, + ) { + super(name); + this.extend(TITLE_COMPONENT_NAME); + } + + public static create ( + name : string, + ) : TitleEntity { + return new this( name ); + } + + public static createText ( + name : string, + value : string, + ) : TitleEntity { + return this.create(name).addText(value); + } + + public addText (value: string) : this { + return this.addContent([value]); + } + +} diff --git a/constants/classNames.ts b/constants/classNames.ts new file mode 100644 index 0000000..ce7726a --- /dev/null +++ b/constants/classNames.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export const HYPER_APP_CLASS_NAME = 'hyper-app'; +export const HYPER_VIEW_CLASS_NAME = 'hyper-view'; +export const HYPER_LAYOUT_CLASS_NAME = 'hyper-layout'; +export const HYPER_ARTICLE_CLASS_NAME = 'hyper-article'; diff --git a/constants/environment.ts b/constants/environment.ts new file mode 100644 index 0000000..5449e17 --- /dev/null +++ b/constants/environment.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +/** + * @__PURE__ + */ +export const BUILD_NODE_ENV : string = /* @__PURE__ */_parseNonEmptyString('%{BUILD_NODE_ENV}') ?? 'development'; + +/** + * @__PURE__ + */ +export const IS_PRODUCTION : boolean = BUILD_NODE_ENV === 'production'; + +/** + * @__PURE__ + */ +export const IS_TEST : boolean = BUILD_NODE_ENV === 'test'; + +/** + * @__PURE__ + */ +export const IS_DEVELOPMENT : boolean = !IS_PRODUCTION && !IS_TEST; + +/** + * This file must not have dependencies, otherwise there might be a circular dependencies. + * + * @param value + * @__PURE__ + * @nosideeffects + */ +function _parseNonEmptyString (value : any) : string | undefined { + /** + * We have this as a separate constant so that the line will not be accidentally replaced in the build process + */ + const startLiteral = '%'; + if (value === undefined) return undefined; + value = `${value}`; + if (value === '') return undefined; + if (value.startsWith(startLiteral+'{') && value.endsWith('}')) return undefined; + return value; +} diff --git a/constants/wellKnown.ts b/constants/wellKnown.ts new file mode 100644 index 0000000..f126020 --- /dev/null +++ b/constants/wellKnown.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export const WELL_KNOWN_HG_METADATA_SERVICE_END_POINT = '/.well-known/fi.hg.m.json' +export const HG_METADATA_SERVICE_GITHUB_ORGANIZATION_NAME = 'fi.hg.github.org'; +export const WELL_KNOWN_HG_HEALTH_CHECK_END_POINT = '/.well-known/fi.hg.up.json'; diff --git a/crypto/CryptoService.ts b/crypto/CryptoService.ts new file mode 100644 index 0000000..d2e6d59 --- /dev/null +++ b/crypto/CryptoService.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +export interface CryptoService { + + /** + * Creates random string containing numbers between 0 and 9. + * + * Eg. `size=2` gives values between 0 and 99. + * Eg. `size=4` gives values between 0 and 9999. + * + * @param size + */ + createRandomInteger ( + size: number + ) : number; + + /** + * Creates random string containing numbers between 0 and 9. + * + * Eg. `size=2` gives values between "00" and "99". + * Eg. `size=4` gives values between "0000" and "9999". + * + * @param size + */ + createRandomIntegerString ( + size: number + ) : string; + +} diff --git a/data/Column.test.ts b/data/Column.test.ts new file mode 100644 index 0000000..1e4c1cc --- /dev/null +++ b/data/Column.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityField, EntityField } from "./types/EntityField"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; + +describe('Column', () => { + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set fields metadata for string id field', () => { + const expectedField : EntityField = createEntityField("fooId", "foo_id"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for string property', () => { + const expectedField : EntityField = createEntityField("fooName", "foo_name"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for number property', () => { + const expectedField : EntityField = createEntityField("fooNumber","foo_number"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for boolean property', () => { + const expectedField : EntityField = createEntityField("fooBoolean", "foo_boolean"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + +}); diff --git a/data/Column.ts b/data/Column.ts new file mode 100644 index 0000000..6947eb3 --- /dev/null +++ b/data/Column.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isString, isStringOrSymbol } from "../types/String"; +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityField } from "./types/EntityField"; +import { parseColumnDefinition } from "./types/ColumnDefinition"; + +export const Column = ( + columnName : string, + columnDefinition ?: string, + opts ?: { + readonly insertable ?: boolean, + readonly updatable ?: boolean, + readonly nullable ?: boolean, + } +) => { + return (target: any, context : any) : void => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (!isString(propertyName)) throw new TypeError(`Only string properties supported. The type was ${typeof propertyName}.`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.fields.push( + createEntityField( + propertyName, + columnName, + parseColumnDefinition(columnDefinition), + opts?.nullable, + undefined, + undefined, + opts?.insertable, + opts?.updatable, + ) + ); + }); + }; +}; diff --git a/data/CreationTimestamp.test.ts b/data/CreationTimestamp.test.ts new file mode 100644 index 0000000..50a27ea --- /dev/null +++ b/data/CreationTimestamp.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityField, EntityField } from "./types/EntityField"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { Temporal } from "./Temporal"; +import { TemporalType } from "./types/TemporalType"; +import { createTemporalProperty, TemporalProperty } from "./types/TemporalProperty"; +import { CreationTimestamp } from "./CreationTimestamp"; + +describe('CreationTimestamp', () => { + + @Table('bars') + class BarEntity extends Entity { + + constructor (dto ?: {barName: string}) { + super() + this.barName = dto?.barName; + } + + @Id() + @Column('bar_id') + public barId ?: string; + + @Column('bar_name') + public barName ?: string; + + @CreationTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column('bar_date') + public barDate ?: string; + + @Temporal(TemporalType.TIMESTAMP) + @Column('bar_updated') + public barUpdated ?: string; + + } + + let entity : BarEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new BarEntity(); + metadata = entity.getMetadata(); + }); + + it('can set fields metadata for barId field', () => { + const expectedField : EntityField = createEntityField("barId", "bar_id"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barName property', () => { + const expectedField : EntityField = createEntityField("barName", "bar_name"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barDate property', () => { + const expectedField : EntityField = createEntityField("barDate","bar_date"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barUpdated property', () => { + const expectedField : EntityField = createEntityField("barUpdated", "bar_updated"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set temporal metadata for barDate property', () => { + const expectedField : TemporalProperty = createTemporalProperty("barDate", TemporalType.TIMESTAMP); + expect(metadata.temporalProperties).toBeArray(); + expect(metadata.temporalProperties).toContainEqual(expectedField); + }); + + it('can set temporal metadata for barUpdated property', () => { + const expectedField : TemporalProperty = createTemporalProperty("barUpdated", TemporalType.TIMESTAMP); + expect(metadata.temporalProperties).toBeArray(); + expect(metadata.temporalProperties).toContainEqual(expectedField); + }); + + it('can set creation timestamp metadata for barDate property', () => { + expect(metadata.creationTimestamps).toBeArray(); + expect(metadata.creationTimestamps).toContainEqual("barDate"); + }); + + it('cannot set creation timestamp metadata for barUpdated property', () => { + expect(metadata.creationTimestamps).toBeArray(); + expect(metadata.creationTimestamps).not.toContainEqual("barUpdated"); + }); + +}); diff --git a/data/CreationTimestamp.ts b/data/CreationTimestamp.ts new file mode 100644 index 0000000..cf8217d --- /dev/null +++ b/data/CreationTimestamp.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { isString, isStringOrSymbol } from "../types/String"; + +/** + * Annotation which marks the property to be automatically initialized by + * creation time. + * + * Right now this does not affect PostgreSQL or MySQL implementations where + * this functionality is handled by the database configuration. It is + * only used in the memory persister for now. + * + * However, some day when we have SQL initialization functionality, this may be + * used there, to initialize database table schemas automatically. + */ +export const CreationTimestamp = () => { + return (target: any, context : any) : void => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (!isString(propertyName)) throw new TypeError(`Symbols not supported for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.creationTimestamps.push(propertyName); + }); + }; +}; diff --git a/data/Entity.test.ts b/data/Entity.test.ts new file mode 100644 index 0000000..944da03 --- /dev/null +++ b/data/Entity.test.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createEntityMetadata, EntityMetadata } from "./types/EntityMetadata"; +import "../../testing/jest/matchers/index"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { cloneEntity, Entity } from "./Entity"; +import { createEntityField } from "./types/EntityField"; + +describe('Entity', () => { + + + @Table('bars') + class BarEntity extends Entity { + constructor (dto ?: {barName: string}) { + super() + this.barId = undefined; + this.barName = dto?.barName ?? undefined; + } + + @Id() + @Column('bar_id') + public barId ?: string; + + @Column('bar_name') + public barName ?: string; + + } + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + @Column('foo_nullable', undefined, {nullable: false}) + public nullableFoo ?: boolean; + + @Column('foo_insertable', undefined, {insertable: false}) + public insertableFoo ?: boolean; + + @Column('foo_updatable', undefined, {updatable: false}) + public updatableFoo ?: boolean; + + } + + let entity : FooEntity; + // let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + entity.fooId = '123'; + entity.fooName = 'Foo 123'; + entity.fooNumber = 123; + entity.fooBoolean = true; + // metadata = entity.getMetadata(); + }); + + describe('#getMetadata', () => { + + it('can get metadata', () => { + const metadata = entity.getMetadata(); + expect(metadata?.tableName).toBe("foos"); + expect(metadata?.idPropertyName).toBe("fooId"); + expect(metadata?.createEntity).toBeFunction(); + expect(metadata?.fields).toStrictEqual( + [ + { + "columnName": "foo_id", + "propertyName": "fooId", + "fieldType": "UNKNOWN", + "insertable": true, + "updatable": true, + "nullable": true, + }, + { + "columnName": "foo_name", + "propertyName": "fooName", + "fieldType": "UNKNOWN", + "insertable": true, + "updatable": true, + "nullable": true + }, + { + "columnName": "foo_number", + "propertyName": "fooNumber", + "fieldType": "UNKNOWN", + "insertable": true, + "updatable": true, + "nullable": true + }, + { + "columnName": "foo_boolean", + "propertyName": "fooBoolean", + "fieldType": "UNKNOWN", + "insertable": true, + "updatable": true, + "nullable": true + }, + { + "columnName": "foo_nullable", + "propertyName": "nullableFoo", + "fieldType": "UNKNOWN", + "insertable": true, + "updatable": true, + "nullable": false + }, + { + "columnName": "foo_insertable", + "propertyName": "insertableFoo", + "fieldType": "UNKNOWN", + "insertable": false, + "updatable": true, + "nullable": true + }, + { + "columnName": "foo_updatable", + "propertyName": "updatableFoo", + "fieldType": "UNKNOWN", + "insertable": true, + "updatable": false, + "nullable": true + } + ] + ); + expect(metadata?.oneToManyRelations).toStrictEqual([]); + expect(metadata?.manyToOneRelations).toStrictEqual([]); + }); + + }); + + describe('#toJSON', () => { + + // let fooMetadata : EntityMetadata; + let fooEntity : FooEntity; + let fooEntityWithId : FooEntity; + + beforeEach(() => { + + fooEntity = new FooEntity({fooName: 'Hello world'}); + + fooEntityWithId = new FooEntity({fooName: 'Hello world'}); + fooEntityWithId.fooId = '123'; + + // fooMetadata = createEntityMetadata( + // 'foos', + // 'fooId', + // [ + // createEntityField('fooId', 'foo_id'), + // createEntityField('fooName', 'foo_name') + // ], + // [], + // [], + // [], + // (dto?: any) => new FooEntity(dto), + // [], + // [], + // [] + // ); + + }); + + it('can get entity as json', () => { + const json = entity.toJSON(); + expect(json).toStrictEqual( + { + fooId: '123', + fooName: 'Foo 123', + fooNumber: 123, + fooBoolean: true + } + ); + }); + + it('can turn fresh entity as JSON object', () => { + const fooJson = fooEntity.toJSON(); + expect( fooJson ).toBeRegularObject(); + expect( fooJson?.fooId ).not.toBeDefined(); + expect( fooJson?.fooName ).toBe('Hello world'); + }); + + it('can turn older entity with id as JSON object', () => { + const fooJson = fooEntityWithId.toJSON(); + expect( fooJson ).toBeRegularObject(); + expect( fooJson?.fooId ).toBe('123'); + expect( fooJson?.fooName ).toBe('Hello world'); + }); + + }); + + describe('#clone', () => { + + + let fooMetadata : EntityMetadata; + let fooEntity : FooEntity; + let fooEntityWithId : FooEntity; + + let barMetadata : EntityMetadata; + // let barEntity : BarEntity; + let barEntityWithId : BarEntity; + + + beforeEach(() => { + + fooEntity = new FooEntity({fooName: 'Hello world'}); + + fooEntityWithId = new FooEntity({fooName: 'Hello world'}); + fooEntityWithId.fooId = '123'; + + fooMetadata = createEntityMetadata( + 'foos', + 'fooId', + [ + createEntityField('fooId', 'foo_id'), + createEntityField('fooName', 'foo_name') + ], + [], + [], + [], + (dto?: any) => new FooEntity(dto), + [], + [], + [] + ); + + // barEntity = new BarEntity({barName: 'Hello world'}); + + barEntityWithId = new BarEntity(); + barEntityWithId.barId = '123'; + barEntityWithId.barName = 'Hello world'; + + barMetadata = createEntityMetadata( + 'bars', + 'barId', + [ + createEntityField('barId', 'bar_id'), + createEntityField('barName', 'bar_name') + ], + [], + [], + [], + (dto?: any) => new BarEntity(dto), + [], + [], + [] + ); + + }); + + it('can clone entity', () => { + const clonedEntity : FooEntity = entity.clone(); + entity.fooBoolean = false; + expect(clonedEntity?.fooBoolean).toBe(true); + }); + + it('can clone fresh entity without id and changes do not propagate to the parent', () => { + const clonedEntity : FooEntity = cloneEntity(fooEntity, fooMetadata); + expect( clonedEntity?.fooId ).not.toBeDefined(); + expect( clonedEntity?.fooName ).toBe('Hello world'); + expect( fooEntity?.fooId ).not.toBeDefined(); + expect( fooEntity?.fooName ).toBe('Hello world'); + clonedEntity.fooName = '123'; + expect( clonedEntity?.fooId ).not.toBeDefined(); + expect( clonedEntity?.fooName ).toBe('123'); + expect( fooEntity?.fooId ).not.toBeDefined(); + expect( fooEntity?.fooName ).toBe('Hello world'); + }); + + it('can clone fresh entity without id and changes in the parent do not propagate to the child', () => { + const clonedEntity : FooEntity = cloneEntity(fooEntity, fooMetadata); + expect( clonedEntity?.fooId ).not.toBeDefined(); + expect( clonedEntity?.fooName ).toBe('Hello world'); + expect( fooEntity?.fooId ).not.toBeDefined(); + expect( fooEntity?.fooName ).toBe('Hello world'); + fooEntity.fooName = '123'; + expect( clonedEntity?.fooId ).not.toBeDefined(); + expect( clonedEntity?.fooName ).toBe('Hello world'); + expect( fooEntity?.fooId ).not.toBeDefined(); + expect( fooEntity?.fooName ).toBe('123'); + }); + + it('can clone older entity with ID and changes do not propagate to the parent', () => { + const clonedEntity : FooEntity = cloneEntity(fooEntityWithId, fooMetadata); + expect( clonedEntity?.fooId ).toBe('123'); + expect( clonedEntity?.fooName ).toBe('Hello world'); + expect( fooEntityWithId?.fooId ).toBe('123'); + expect( fooEntityWithId?.fooName ).toBe('Hello world'); + clonedEntity.fooName = '123'; + expect( clonedEntity?.fooId ).toBe('123'); + expect( clonedEntity?.fooName ).toBe('123'); + expect( fooEntityWithId?.fooId ).toBe('123'); + expect( fooEntityWithId?.fooName ).toBe('Hello world'); + }); + + it('can clone older entity with ID and changes in the parent do not propagate to the child', () => { + const clonedEntity : FooEntity = cloneEntity(fooEntityWithId, fooMetadata); + expect( clonedEntity?.fooId ).toBe('123'); + expect( clonedEntity?.fooName ).toBe('Hello world'); + expect( fooEntityWithId?.fooId ).toBe('123'); + expect( fooEntityWithId?.fooName ).toBe('Hello world'); + fooEntityWithId.fooName = '123'; + expect( clonedEntity?.fooId ).toBe('123'); + expect( clonedEntity?.fooName ).toBe('Hello world'); + expect( fooEntityWithId?.fooId ).toBe('123'); + expect( fooEntityWithId?.fooName ).toBe('123'); + }); + + it('can clone entity with properties that are not initialized in the entity constructor', () => { + expect( barEntityWithId?.barId ).toBe('123'); + expect( barEntityWithId?.barName ).toBe('Hello world'); + + const clonedEntity : BarEntity = cloneEntity(barEntityWithId, barMetadata); + expect( clonedEntity?.barId ).toBe('123'); + expect( clonedEntity?.barName ).toBe('Hello world'); + expect( barEntityWithId?.barId ).toBe('123'); + expect( barEntityWithId?.barName ).toBe('Hello world'); + barEntityWithId.barName = '123'; + expect( clonedEntity?.barId ).toBe('123'); + expect( clonedEntity?.barName ).toBe('Hello world'); + expect( barEntityWithId?.barId ).toBe('123'); + expect( barEntityWithId?.barName ).toBe('123'); + }); + + }); + +}); diff --git a/data/Entity.ts b/data/Entity.ts new file mode 100644 index 0000000..e1a7781 --- /dev/null +++ b/data/Entity.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2022-2023. Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +import { isArray, isArrayOf } from "../types/Array"; +import { map } from "../functions/map"; +import { reduce } from "../functions/reduce"; +import { isBoolean } from "../types/Boolean"; +import { forEach } from "../functions/forEach"; +import { isString } from "../types/String"; +import { isNumber } from "../types/Number"; +import { isUndefined } from "../types/undefined"; +import { isNull } from "../types/Null"; +import { isFunction } from "../types/Function"; +import { CreateEntityLikeCallback, EntityLike } from "./types/EntityLike"; +import { cloneJson, isJsonAny, isReadonlyJsonAny, ReadonlyJsonObject } from "../Json"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityField } from "./types/EntityField"; +import { EntityRelationOneToMany } from "./types/EntityRelationOneToMany"; +import { EntityRelationManyToOne } from "./types/EntityRelationManyToOne"; +import { LogService } from "../LogService"; +import { EntityFieldType } from "./types/EntityFieldType"; + +const LOG = LogService.createLogger( 'Entity' ); + +/** + * Base type for all supported ID types + */ +export type EntityIdTypes = string | number; + +export interface EntityConstructor { + new (...args: any): Entity; +} + +export class Entity implements EntityLike { + + protected constructor () { + } + + /** + * @inheritDoc + */ + public getMetadata (): EntityMetadata { + return EntityMetadataUtils.getMetadata(this.constructor); + } + + /** + * @inheritDoc + */ + public toJSON () : ReadonlyJsonObject { + return entityToJSON(this, this.getMetadata()); + } + + /** + * @inheritDoc + */ + public clone () : Entity { + return cloneEntity(this, this.getMetadata()); + } + +} + +export function isEntity (value: unknown) : value is Entity { + return !!value && (value instanceof Entity); +} + +/** + * + * @param entity + * @param metadata + */ +export function entityToJSON ( + entity: Entity, + metadata: EntityMetadata +) : ReadonlyJsonObject { + + let json = reduce( + metadata.fields, + (prev: ReadonlyJsonObject, field: EntityField) : ReadonlyJsonObject => { + const propertyName = field?.propertyName; + if (!propertyName) throw new TypeError(`The field did not have propertyName defined`); + const value : unknown = (entity as any)[propertyName]; + if (value === undefined) return prev; + if (!isReadonlyJsonAny(value)) { + LOG.warn(`Could not convert property "${propertyName}" as JSON: Value not compatible for JSON:`, value); + return prev; + } + return { + ...prev, + [propertyName]: value + }; + }, + {} as ReadonlyJsonObject + ); + + json = reduce( + metadata.oneToManyRelations, + (prev: ReadonlyJsonObject, oneToMany: EntityRelationOneToMany) : ReadonlyJsonObject => { + const { propertyName } = oneToMany; + if (propertyName) { + const value : unknown = (entity as any)[propertyName]; + if (value === undefined) return prev; + if (!isArrayOf(value, isEntity)) { + LOG.warn(`Could not convert property "${propertyName}" as JSON: Value not entity array:`, value); + return prev; + } + return { + ...prev, + [propertyName]: map(value, (item : Entity) : ReadonlyJsonObject => item.toJSON()) + }; + } + return prev; + }, + json + ); + + json = reduce( + metadata.manyToOneRelations, + (prev: ReadonlyJsonObject, manyToOne: EntityRelationManyToOne) : ReadonlyJsonObject => { + const { propertyName } = manyToOne; + if (propertyName) { + const value : unknown = (entity as any)[propertyName]; + if (value === undefined) return prev; + if (!isEntity(value)) { + LOG.warn(`Could not convert property "${propertyName}" as JSON: Value not entity:`, value); + return prev; + } + return { + ...prev, + [propertyName]: value.toJSON() + }; + } + return prev; + }, + json + ); + + return json; + +} + +/** + * + * @param entity + * @param metadata + */ +export function cloneEntity ( + entity: Entity, + metadata: EntityMetadata +) : Entity { + const idPropertyName = metadata?.idPropertyName; + if (!idPropertyName) throw new TypeError(`The entity metadata did not have id property name defined`); + const createEntity : CreateEntityLikeCallback | undefined = metadata?.createEntity; + if (!isFunction(createEntity)) { + throw new TypeError(`The entity metadata did not have ability to create new entities. Did you forget '@table()' annotation?`); + } + const clonedEntity = createEntity(); + + // We need to copy all normal fields because entity constructor might not + // initialize everything same way + forEach( + metadata?.fields, + (field: EntityField) => { + const propertyName = field.propertyName; + // Note: Joined entities will be copied below at manyToOneRelations + if (propertyName && field.fieldType !== EntityFieldType.JOINED_ENTITY) { + (clonedEntity as any)[propertyName] = cloneEntityValue((entity as any)[propertyName]); + } + } + ); + + // We also need to copy @OneToMany relations + forEach( + metadata.oneToManyRelations, + (oneToMany: EntityRelationOneToMany) : void => { + const { propertyName } = oneToMany; + if (propertyName) { + (clonedEntity as any)[propertyName] = cloneEntityValue((entity as any)[propertyName]); + } + }, + ); + + // We also need to copy @ManyToOne relations + forEach( + metadata.manyToOneRelations, + (manyToOne: EntityRelationManyToOne) : void => { + const { propertyName } = manyToOne; + if (propertyName) { + (clonedEntity as any)[propertyName] = cloneEntityValue((entity as any)[propertyName]); + } + }, + ); + + return clonedEntity; +} + +/** + * + * @param value + */ +function cloneEntityValue (value: T) : T { + + if ( isString(value) + || isNumber(value) + || isBoolean(value) + || isUndefined(value) + || isNull(value) + ) { + return value; + } + + if ( isArray(value) ) { + return map( + value, + (item: any) => cloneEntityValue(item) + ) as unknown as T; + } + + if ( isEntity(value) ) { + return value.clone() as unknown as T; + } + + if ( isJsonAny(value) ) { + return cloneJson(value) as unknown as T; + } + + LOG.debug(`value = `, value); + throw new TypeError(`Could not clone value: ${value}`); +} diff --git a/data/Id.test.ts b/data/Id.test.ts new file mode 100644 index 0000000..2d88cb5 --- /dev/null +++ b/data/Id.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Entity } from "./Entity"; +import { Id } from "./Id"; +import { Column } from "./Column"; + +describe('Id', () => { + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set idPropertyName metadata', () => { + expect(metadata.idPropertyName).toBe('fooId'); + }); + +}); diff --git a/data/Id.ts b/data/Id.ts new file mode 100644 index 0000000..58afd64 --- /dev/null +++ b/data/Id.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isString, isStringOrSymbol } from "../types/String"; +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; + +export const Id = () => { + return (target: any, context : any) => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (!isString(propertyName)) throw new TypeError(`Only string properties supported. The type was ${typeof propertyName}.`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.idPropertyName = propertyName; + }); + }; +}; diff --git a/data/JoinColumn.test.ts b/data/JoinColumn.test.ts new file mode 100644 index 0000000..1f4b197 --- /dev/null +++ b/data/JoinColumn.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityField, EntityField } from "./types/EntityField"; +import { OneToMany } from "./OneToMany"; +import { JoinColumn } from "./JoinColumn"; +import { EntityFieldType } from "./types/EntityFieldType"; +import { ManyToOne } from "./ManyToOne"; +import { Table } from "./Table"; +import { Entity } from "./Entity"; +import { Id } from "./Id"; +import { Column } from "./Column"; + +describe('JoinColumn', () => { + + @Table('carts') + class CartEntity extends Entity { + + constructor () { + super(); + } + + @Id() + @Column('cart_id') + public cartId ?: string; + + @OneToMany('cart_items', "cart") + public cartItems ?: readonly CartItemEntity[]; + + } + + @Table('cart_items') + class CartItemEntity extends Entity { + + constructor () { + super(); + } + + @Id() + @Column('cart_item_id') + public cartItemId ?: string; + + @ManyToOne(CartEntity) + @JoinColumn('cart_id', false) + public cart ?: CartEntity; + + } + + let entity : CartItemEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new CartItemEntity(); + metadata = entity.getMetadata(); + }); + + it('can set fields metadata for cart property', () => { + const expectedField : EntityField = createEntityField("cart","cart_id", undefined, false, EntityFieldType.JOINED_ENTITY); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + +}); diff --git a/data/JoinColumn.ts b/data/JoinColumn.ts new file mode 100644 index 0000000..fc0632c --- /dev/null +++ b/data/JoinColumn.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isString, isStringOrSymbol } from "../types/String"; +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityField } from "./types/EntityField"; +import { EntityFieldType } from "./types/EntityFieldType"; + +/** + * This annotation defines a reference to the mapped column. Use it with + * `@ManyToOne` annotation. + * + * **Note!** The remote table name will be looked by using the `columnName`, so + * make sure that it is unique among other columns marked with `@id` annotation. + * + * @param columnName The name of the column + * @param nullable If `true` the column can be undefined. + */ +export const JoinColumn = ( + columnName : string, + nullable ?: boolean | undefined +) => { + return (target: any, context : any) : void => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (!isString(propertyName)) throw new TypeError(`Only string properties supported. The type was ${typeof propertyName}.`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.fields.push(createEntityField(propertyName, columnName, undefined, nullable, EntityFieldType.JOINED_ENTITY)); + }); + }; +}; diff --git a/data/ManyToOne.test.ts b/data/ManyToOne.test.ts new file mode 100644 index 0000000..0588897 --- /dev/null +++ b/data/ManyToOne.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { OneToMany } from "./OneToMany"; +import { JoinColumn } from "./JoinColumn"; +import { ManyToOne } from "./ManyToOne"; +import { createEntityRelationManyToOne, EntityRelationManyToOne } from "./types/EntityRelationManyToOne"; +import { Table } from "./Table"; +import { Entity } from "./Entity"; +import { Id } from "./Id"; +import { Column } from "./Column"; + +describe('ManyToOne', () => { + + @Table('carts') + class CartEntity extends Entity { + + constructor () { + super(); + } + + @Id() + @Column('cart_id') + public cartId ?: string; + + @OneToMany("cart_items", "cart") + public cartItems ?: readonly CartItemEntity[]; + + } + + @Table('cart_items') + class CartItemEntity extends Entity { + + constructor () { + super(); + } + + @Id() + @Column('cart_item_id') + public cartItemId ?: string; + + @ManyToOne(CartEntity) + @JoinColumn('cart_id', false) + public cart ?: CartEntity; + + } + + let entity : CartItemEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new CartItemEntity(); + metadata = entity.getMetadata(); + }); + + it('can set fields metadata for cart property', () => { + const expectedItem : EntityRelationManyToOne = createEntityRelationManyToOne("cart", "carts"); + expect(metadata.manyToOneRelations).toBeArray(); + expect(metadata.manyToOneRelations).toContainEqual(expectedItem); + }); + +}); diff --git a/data/ManyToOne.ts b/data/ManyToOne.ts new file mode 100644 index 0000000..20145a4 --- /dev/null +++ b/data/ManyToOne.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isString, isStringOrSymbol } from "../types/String"; +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityRelationManyToOne } from "./types/EntityRelationManyToOne"; +import { EntityConstructor } from "./Entity"; + +export const ManyToOne = ( + mappedTo : string | EntityConstructor +) => { + return ( + target: any, + context : any + ) => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (!isString(propertyName)) throw new TypeError(`Only string properties supported. The type was ${typeof propertyName}.`); + + let mappedTable : string; + if (isString(mappedTo)) { + if (!mappedTo) throw new TypeError(`The mapped property "${propertyName}" cannot be empty`); + mappedTable = mappedTo; + } else if (mappedTo) { + const metadata = EntityMetadataUtils.getMetadata( mappedTo ); + if ( !metadata ) throw new TypeError( `Could not find metadata for property "${propertyName}" Entity constructor: ${mappedTo}` ); + if ( !metadata.tableName ) throw new TypeError( `Could not find table name for property "${propertyName}" from metadata: ${metadata}` ); + mappedTable = metadata.tableName; + } else { + throw new TypeError(`The @manyToOne property "${propertyName}" requires table name or entity constructor`); + } + + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.manyToOneRelations.push(createEntityRelationManyToOne(propertyName, mappedTable)); + }); + }; +}; diff --git a/data/OneToMany.test.ts b/data/OneToMany.test.ts new file mode 100644 index 0000000..e2e2f30 --- /dev/null +++ b/data/OneToMany.test.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { OneToMany } from "./OneToMany"; +import { JoinColumn } from "./JoinColumn"; +import { ManyToOne } from "./ManyToOne"; +import { createEntityRelationOneToMany, EntityRelationOneToMany } from "./types/EntityRelationOneToMany"; +import { Table } from "./Table"; +import { Entity } from "./Entity"; +import { Id } from "./Id"; +import { Column } from "./Column"; + +describe('OneToMany', () => { + + @Table('carts') + class CartEntity extends Entity { + + constructor () { + super(); + } + + @Id() + @Column('cart_id') + public cartId ?: string; + + @OneToMany("cart_items", "cart") + public cartItems ?: readonly CartItemEntity[]; + + } + + @Table('cart_items') + class CartItemEntity extends Entity { + + constructor () { + super(); + } + + @Id() + @Column('cart_item_id') + public cartItemId ?: string; + + @ManyToOne(CartEntity) + @JoinColumn('cart_id', false) + public cart ?: CartEntity; + + } + + let entity : CartEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new CartEntity(); + metadata = entity.getMetadata(); + }); + + it('can set fields metadata for cart property', () => { + const expectedItem : EntityRelationOneToMany = createEntityRelationOneToMany("cartItems", "cart", "cart_items"); + // .toBeArray() is only available in the testing mode + // @ts-ignore + expect(metadata.oneToManyRelations).toBeArray(); + expect(metadata.oneToManyRelations).toContainEqual(expectedItem); + }); + +}); diff --git a/data/OneToMany.ts b/data/OneToMany.ts new file mode 100644 index 0000000..20266f9 --- /dev/null +++ b/data/OneToMany.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isString, isStringOrSymbol } from "../types/String"; +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityRelationOneToMany } from "./types/EntityRelationOneToMany"; +import { EntityConstructor } from "./Entity"; + +export const OneToMany = ( + mappedTo : string | EntityConstructor, + mappedBy : string +) => { + return (target: any, context : any) : void => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (!isString(propertyName)) throw new TypeError(`Only string properties supported. The type was ${typeof propertyName}.`); + + let mappedTable : string; + if (isString(mappedTo)) { + if (!mappedTo) throw new TypeError(`The mapped property "${propertyName}" cannot be empty`); + mappedTable = mappedTo; + } else if (mappedTo) { + const metadata = EntityMetadataUtils.getMetadata( mappedTo ); + if ( !metadata ) throw new TypeError( `Could not find metadata for property "${propertyName}" Entity constructor: ${mappedTo}` ); + if ( !metadata.tableName ) throw new TypeError( `Could not find table name for property "${propertyName}" from metadata: ${metadata}` ); + mappedTable = metadata.tableName; + } else { + throw new TypeError(`The @manyToOne property "${propertyName}" requires table name or entity constructor`); + } + + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.oneToManyRelations.push(createEntityRelationOneToMany(propertyName, mappedBy, mappedTable)); + }); + }; +}; diff --git a/data/PostLoad.test.ts b/data/PostLoad.test.ts new file mode 100644 index 0000000..4b23a58 --- /dev/null +++ b/data/PostLoad.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { PostLoad } from "./PostLoad"; +import { createEntityCallback, EntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogLevel } from "../types/LogLevel"; + +PostLoad.setLogLevel(LogLevel.NONE); +describe('PostLoad', () => { + + const callback = jest.fn(); + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + @PostLoad() + public onPostLoad () : void { + callback(); + } + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + callback.mockClear(); + }); + + it('can set callbacks metadata for PostLoad', () => { + const expectedCallback : EntityCallback = createEntityCallback("onPostLoad", EntityCallbackType.POST_LOAD); + expect(metadata.callbacks).toBeArray(); + expect(metadata.callbacks).toContainEqual(expectedCallback); + }); + +}); diff --git a/data/PostLoad.ts b/data/PostLoad.ts new file mode 100644 index 0000000..ff2f9c5 --- /dev/null +++ b/data/PostLoad.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { isStringOrSymbol } from "../types/String"; + +const LOG = LogService.createLogger( 'PostLoad' ); +LOG.setLogLevel(LogLevel.INFO); + +/** + * PostLoad decorator. + * Registers a callback to be executed after loading an entity. + * + * This callback is invoked after an entity is loaded from the database. It is + * typically used to perform post-loading tasks or updates. + * + * TODO: Document the invocation order of lifecycle callbacks. + * + * @returns The decorator function. + * @throws {Error} If an exception is thrown from the callback. + */ +export const PostLoad = () => { + + /** + * Decorator function. + * + * @param {Object} target - The target object (class or prototype). + * @param {string | symbol} propertyName - The name of the property being decorated. + * @throws {TypeError} If the property name is not defined. + */ + return ( + target: any, + context: any, + ) => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (propertyName !== undefined) { + LOG.debug(`Installing POST_LOAD callback for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.callbacks.push( + createEntityCallback( + propertyName, + EntityCallbackType.POST_LOAD + ) + ); + }); + } else { + throw new TypeError(`The property name was not defined`); + } + }; +}; + +/** + * Sets the log level of the "PostLoad" logger context. + * + * @param {LogLevel} level - The log level to set. + */ +PostLoad.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/data/PostPersist.test.ts b/data/PostPersist.test.ts new file mode 100644 index 0000000..6c9e1a7 --- /dev/null +++ b/data/PostPersist.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { PostPersist } from "./PostPersist"; +import { createEntityCallback, EntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogLevel } from "../types/LogLevel"; + +PostPersist.setLogLevel(LogLevel.NONE); +describe('PostPersist', () => { + + const callback = jest.fn(); + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + @PostPersist() + public onPostPersist () : void { + callback(); + } + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set callbacks metadata for PostPersist', () => { + const expectedCallback : EntityCallback = createEntityCallback("onPostPersist", EntityCallbackType.POST_PERSIST); + expect(metadata.callbacks).toBeArray(); + expect(metadata.callbacks).toContainEqual(expectedCallback); + }); + +}); diff --git a/data/PostPersist.ts b/data/PostPersist.ts new file mode 100644 index 0000000..04f378b --- /dev/null +++ b/data/PostPersist.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { isStringOrSymbol } from "../types/String"; + +const LOG = LogService.createLogger( 'PostPersist' ); +LOG.setLogLevel(LogLevel.INFO); + +/** + * PostPersist decorator. + * Registers a callback to be executed after persisting an entity. + * + * This callback is invoked after persisting an entity. It is typically used to + * perform post-persistence tasks or updates. + * + * Cascaded persist operations trigger the corresponding lifecycle methods of + * the associated entities. + * + * TODO: Document the invocation order of lifecycle callbacks. + * + * @returns The decorator function. + * @throws {Error} If an exception is thrown from the callback. The transaction will be marked for rollback. + */ +export const PostPersist = () => { + + /** + * Decorator function. + * + * @param {Object} target - The target object (class or prototype). + * @param {string | symbol} propertyName - The name of the property being decorated. + * @throws {TypeError} If the property name is not defined. + */ + return ( + target: any, + context : any + ) : void => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (propertyName !== undefined) { + LOG.debug(`Installing POST_PERSIST callback for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.callbacks.push( + createEntityCallback( + propertyName, + EntityCallbackType.POST_PERSIST + ) + ); + }); + } else { + throw new TypeError(`The property name was not defined`); + } + }; +}; + +/** + * Sets the log level of the "PostPersist" logger context. + * + * @param {LogLevel} level - The log level to set. + */ +PostPersist.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/data/PostRemove.test.ts b/data/PostRemove.test.ts new file mode 100644 index 0000000..b6b11e3 --- /dev/null +++ b/data/PostRemove.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { PostRemove } from "./PostRemove"; +import { createEntityCallback, EntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogLevel } from "../types/LogLevel"; + +PostRemove.setLogLevel(LogLevel.NONE); +describe('PostRemove', () => { + + const callback = jest.fn(); + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + @PostRemove() + public onPostRemove () : void { + callback(); + } + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set callbacks metadata for PostRemove', () => { + const expectedCallback : EntityCallback = createEntityCallback("onPostRemove", EntityCallbackType.POST_REMOVE); + expect(metadata.callbacks).toBeArray(); + expect(metadata.callbacks).toContainEqual(expectedCallback); + }); + +}); diff --git a/data/PostRemove.ts b/data/PostRemove.ts new file mode 100644 index 0000000..9ee3d1c --- /dev/null +++ b/data/PostRemove.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { isStringOrSymbol } from "../types/String"; + +const LOG = LogService.createLogger( 'PostRemove' ); +LOG.setLogLevel(LogLevel.INFO); + +/** + * PostRemove decorator. + * Registers a callback to be executed after removing an entity. + * + * This callback is invoked after removing an entity. It is typically used to + * perform post-removal tasks or updates. + * + * Cascaded remove operations trigger the corresponding lifecycle methods of the + * associated entities. + * + * @returns The decorator function. + * @throws {Error} If an exception is thrown from the callback. The transaction will be marked for rollback. + */ +export const PostRemove = () => { + + /** + * Decorator function. + * + * @param {Object} target - The target object (class or prototype). + * @param {string | symbol} propertyName - The name of the property being decorated. + * @throws {TypeError} If the property name is not defined. + */ + return ( + target: any, + context: any + ) => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (propertyName !== undefined) { + LOG.debug(`Installing POST_REMOVE callback for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.callbacks.push( + createEntityCallback( + propertyName, + EntityCallbackType.POST_REMOVE + ) + ); + }); + } else { + throw new TypeError(`The property name was not defined`); + } + }; +}; + +/** + * Sets the log level of the "PostRemove" logger context. + * + * @param {LogLevel} level - The log level to set. + */ +PostRemove.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/data/PostUpdate.test.ts b/data/PostUpdate.test.ts new file mode 100644 index 0000000..5f4ad0f --- /dev/null +++ b/data/PostUpdate.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { PostUpdate } from "./PostUpdate"; +import { createEntityCallback, EntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogLevel } from "../types/LogLevel"; + +PostUpdate.setLogLevel(LogLevel.NONE); +describe('PostUpdate', () => { + + const callback = jest.fn(); + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + @PostUpdate() + public onPostUpdate () : void { + callback(); + } + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set callbacks metadata for PostUpdate', () => { + const expectedCallback : EntityCallback = createEntityCallback("onPostUpdate", EntityCallbackType.POST_UPDATE); + expect(metadata.callbacks).toBeArray(); + expect(metadata.callbacks).toContainEqual(expectedCallback); + }); + +}); diff --git a/data/PostUpdate.ts b/data/PostUpdate.ts new file mode 100644 index 0000000..7340bd9 --- /dev/null +++ b/data/PostUpdate.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { isStringOrSymbol } from "../types/String"; + +const LOG = LogService.createLogger( 'PostUpdate' ); +LOG.setLogLevel(LogLevel.INFO); + +/** + * PostUpdate decorator. + * Registers a callback to be executed after updating an entity. + * + * This callback is invoked after updating an entity. It is typically + * used to perform post-update tasks or updates. + * + * Cascaded update operations trigger the corresponding lifecycle + * methods of the associated entities. + * + * The `@PostUpdate` callback is called regardless of whether anything actually + * changed. + * + * TODO: Document the invocation order of lifecycle callbacks. + * + * @returns The decorator function. + * @throws {Error} If an exception is thrown from the callback. The transaction will be marked for rollback. + */ +export const PostUpdate = () => { + + /** + * Decorator function. + * + * @param {Object} target - The target object (class or prototype). + * @param {string | symbol} propertyName - The name of the property being decorated. + * @throws {TypeError} If the property name is not defined. + */ + return ( + target: any, + context : any + ) => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (propertyName !== undefined) { + LOG.debug(`Installing POST_UPDATE callback for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.callbacks.push( + createEntityCallback( + propertyName, + EntityCallbackType.POST_UPDATE + ) + ); + }); + } else { + throw new TypeError(`The property name was not defined`); + } + }; +}; + +/** + * Sets the log level of the "PostUpdate" logger context. + * + * @param {LogLevel} level - The log level to set. + */ +PostUpdate.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/data/PrePersist.test.ts b/data/PrePersist.test.ts new file mode 100644 index 0000000..5d55bc5 --- /dev/null +++ b/data/PrePersist.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { PrePersist } from "./PrePersist"; +import { createEntityCallback, EntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogLevel } from "../types/LogLevel"; + +PrePersist.setLogLevel(LogLevel.NONE); +describe('PrePersist', () => { + + const callback = jest.fn(); + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + @PrePersist() + public onPrePersist () : void { + callback(); + } + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set callbacks metadata for PrePersist', () => { + const expectedCallback : EntityCallback = createEntityCallback("onPrePersist", EntityCallbackType.PRE_PERSIST); + expect(metadata.callbacks).toBeArray(); + expect(metadata.callbacks).toContainEqual(expectedCallback); + }); + +}); diff --git a/data/PrePersist.ts b/data/PrePersist.ts new file mode 100644 index 0000000..3f1cfca --- /dev/null +++ b/data/PrePersist.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { isStringOrSymbol } from "../types/String"; + +const LOG = LogService.createLogger( 'PrePersist' ); +LOG.setLogLevel(LogLevel.INFO); + +/** + * PrePersist decorator. + * Registers a callback to be executed before persisting an entity. + * + * This callback is invoked before persisting an entity. It is typically used to + * perform pre-persistence tasks or validations. + * + * Cascaded persist operations trigger the corresponding lifecycle methods of + * the associated entities. + * + * TODO: Document the invocation order of lifecycle callbacks. + * + * @returns The decorator function. + * @throws {Error} If an exception is thrown from the callback. The transaction will be marked for rollback. + */ +export const PrePersist = () => { + + /** + * Decorator function. + * + * @param {Object} target - The target object (class or prototype). + * @param {string | symbol} propertyName - The name of the property being decorated. + * @throws {TypeError} If the property name is not defined. + */ + return ( + target: any, + context: any, + ) : void => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (propertyName !== undefined) { + LOG.debug(`Installing PRE_PERSIST callback for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.callbacks.push( + createEntityCallback( + propertyName, + EntityCallbackType.PRE_PERSIST + ) + ); + }); + } else { + throw new TypeError(`The property name was not defined`); + } + }; +}; + +/** + * Sets the log level of the "PrePersist" logger context. + * + * @param {LogLevel} level - The log level to set. + */ +PrePersist.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/data/PreRemove.test.ts b/data/PreRemove.test.ts new file mode 100644 index 0000000..9e287ed --- /dev/null +++ b/data/PreRemove.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { PreRemove } from "./PreRemove"; +import { createEntityCallback, EntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogLevel } from "../types/LogLevel"; + +PreRemove.setLogLevel(LogLevel.NONE); +describe('PreRemove', () => { + + const callback = jest.fn(); + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + @PreRemove() + public onPreRemove () : void { + callback(); + } + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set callbacks metadata for PreRemove', () => { + const expectedCallback : EntityCallback = createEntityCallback("onPreRemove", EntityCallbackType.PRE_REMOVE); + expect(metadata.callbacks).toBeArray(); + expect(metadata.callbacks).toContainEqual(expectedCallback); + }); + +}); diff --git a/data/PreRemove.ts b/data/PreRemove.ts new file mode 100644 index 0000000..2cb5414 --- /dev/null +++ b/data/PreRemove.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { isStringOrSymbol } from "../types/String"; + +const LOG = LogService.createLogger( 'PreRemove' ); +LOG.setLogLevel(LogLevel.INFO); + +/** + * PreRemove decorator. + * Registers a callback to be executed before removing an entity. + * + * This callback is invoked before removing an entity. It is typically used to + * perform pre-removal tasks or validations. + * + * Cascaded remove operations trigger the corresponding lifecycle methods of the + * associated entities. + * + * TODO: Document the invocation order of lifecycle callbacks. + * + * @returns The decorator function. + * @throws {Error} If an exception is thrown from the callback. The transaction will be marked for rollback. + */ +export const PreRemove = () => { + + /** + * Decorator function. + * + * @param {Object} target - The target object (class or prototype). + * @param {string | symbol} propertyName - The name of the property being decorated. + * @throws {TypeError} If the property name is not defined. + */ + return ( + target: any, + context: any + ) : void => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (propertyName !== undefined) { + LOG.debug(`Installing PRE_REMOVE callback for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.callbacks.push( + createEntityCallback( + propertyName, + EntityCallbackType.PRE_REMOVE + ) + ); + }); + } else { + throw new TypeError(`The property name was not defined`); + } + }; +}; + +/** + * Sets the log level of the "PreRemove" logger context. + * + * @param {LogLevel} level - The log level to set. + */ +PreRemove.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/data/PreUpdate.test.ts b/data/PreUpdate.test.ts new file mode 100644 index 0000000..ca0080c --- /dev/null +++ b/data/PreUpdate.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { PreUpdate } from "./PreUpdate"; +import { createEntityCallback, EntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogLevel } from "../types/LogLevel"; + +PreUpdate.setLogLevel(LogLevel.NONE); +describe('PreUpdate', () => { + + const callback = jest.fn(); + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + @PreUpdate() + public onPreUpdate () : void { + callback(); + } + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set callbacks metadata for PreUpdate', () => { + const expectedCallback : EntityCallback = createEntityCallback("onPreUpdate", EntityCallbackType.PRE_UPDATE); + expect(metadata.callbacks).toBeArray(); + expect(metadata.callbacks).toContainEqual(expectedCallback); + }); + +}); diff --git a/data/PreUpdate.ts b/data/PreUpdate.ts new file mode 100644 index 0000000..03ead91 --- /dev/null +++ b/data/PreUpdate.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityCallback } from "./types/EntityCallback"; +import { EntityCallbackType } from "./types/EntityCallbackType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { isStringOrSymbol } from "../types/String"; + +const LOG = LogService.createLogger( 'PreUpdate' ); +LOG.setLogLevel(LogLevel.INFO); + +/** + * PreUpdate decorator. + * Registers a callback to be executed before updating an entity. + * + * This callback is invoked before updating an entity. It is typically used to + * perform pre-update tasks or validations. + * + * Cascaded update operations trigger the corresponding lifecycle methods of the + * associated entities. + * + * The `@PreUpdate` callback is only called if the data is actually changed — + * that is, if there's an actual SQL update statement to run. + * + * TODO: Document the invocation order of lifecycle callbacks. + * + * @returns The decorator function. + * @throws {Error} If an exception is thrown from the callback. The transaction will be marked for rollback. + */ +export const PreUpdate = () => { + + /** + * Decorator function. + * + * @param {Object} target - The target object (class or prototype). + * @param {string | symbol} propertyName - The name of the property being decorated. + * @throws {TypeError} If the property name is not defined. + */ + return ( + target: any, + context : any + ) : void => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (propertyName !== undefined) { + LOG.debug(`Installing PRE_UPDATE callback for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.callbacks.push( + createEntityCallback( + propertyName, + EntityCallbackType.PRE_UPDATE + ) + ); + }); + } else { + throw new TypeError(`The property name was not defined`); + } + }; +}; + +/** + * Sets the log level of the "PreUpdate" logger context. + * + * @param {LogLevel} level - The log level to set. + */ +PreUpdate.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..3c38fee --- /dev/null +++ b/data/README.md @@ -0,0 +1,180 @@ +**Join our [Discord](https://discord.gg/UBTrHxA78f) to discuss about our software!** + +# HG data + +This library is our Spring Data inspired annotation framework for +implementing CRUD style entities and relational repositories for pure +TypeScript. + +Code under this core library does not require any external dependencies. + +See also: + +* [`fi.hg.pg`](https://github.com/heusalagroup/fi.hg.pg) -- The PostgreSQL + persister +* [`fi.hg.mysql`](https://github.com/heusalagroup/fi.hg.mysql) -- The MySQL + persister + +### It doesn't have any runtime dependencies + +### We don't have traditional releases + +We don't have traditional releases. This project evolves directly to our git +repository in an agile manner. + +This git repository contains only the source code for compile time use case. It +is meant to be used as a git submodule in a NodeJS or webpack project. + +### License + +Copyright (c) Heusala Group Oy. All rights reserved. Licensed under the MIT +License (the "[License](../LICENSE.md)"); + +## Installing & using our library + +Run the installation commands from your project's root directory. Usually it's +where your `package.json` is located. + +For these sample commands we expect your source files to be located in `./src` +and we'll use `./src/fi/hg/NAME` for location for our sub modules. + +### The core library (includes memory-only support) + +```shell +mkdir -p src/fi/hg +git submodule add git@github.com:heusalagroup/fi.hg.core.git src/fi/hg/core +git config -f .gitmodules submodule.src/fi/hg/core.branch main +npm install --save-dev lodash @types/lodash reflect-metadata @types/node +``` + +### For PostgreSQL support + +```shell +git submodule add git@github.com:heusalagroup/fi.hg.pg.git src/fi/hg/pg +git config -f .gitmodules submodule.src/fi/hg/pg.branch main +npm install --save pg @types/pg +``` + +### For MySQL support + +```shell +git submodule add git@github.com:heusalagroup/fi.hg.mysql.git src/fi/hg/mysql +git config -f .gitmodules submodule.src/fi/hg/mysql.branch main +npm install --save mysql @types/mysql +``` + +## Documentation + +### Entity class example + +First define a class for your entity -- we'll create `User` class: + +```typescript +@Table("users") +export class User extends Entity { + + @Id() + @Column("id") + public id?: string; + + @Column("name") + public name: string; + + @Column("email") + public email: string; + + @Column("age") + public age: number; + + //... +} +``` + +### Repository interface example + +Then create a repository interface for your entities: + +```typescript +export interface UserRepository extends CrudRepository { + + findAllByEmail (email : string) : Promise; + findByEmail (email : string) : Promise; + countByEmail (email : string) : Promise; + existsByEmail (email : string) : Promise; + deleteAllByEmail (email : string) : Promise; + +} +``` + +**Note!** *You don't need to implement these methods.* + +The framework does that under the hood for you. + +In fact, these methods will always be created -- even if you don't declare them +in your interface. Declaration is only necessary for TypeScript and your IDE +to know they exist in your interface. + +### Controller example + +Then use it in your controller like this: + +```typescript +export interface UserDto { + id ?: string; + email ?: string; +} + +export class UserController { + + private readonly _userRepository : UserRepository; + + constructor (userRepository : UserRepository) { + this._userRepository = userRepository; + } + + public async createUser (): Promise { + + const newUser = new User(/*...*/); + + const addedUser = await this._userRepository.save(newUser); + + return {id: addedUser.id}; + + } + +} +``` + +### Main runtime example + +Finally, put everything together in your main runtime file: + +```typescript +const pgPersister : Persister = new PgPersister(/*...*/); +const userRepository : UserRepository = createCrudRepositoryWithPersister(new User(), pgPersister); +``` + +...or memory-only persister, which does not need runtime libraries, useful for +development and testing purposes: + +```typescript +const pgPersister : Persister = new MemoryPersister(); +const userRepository : UserRepository = createCrudRepositoryWithPersister(new User(), pgPersister); +``` + +## Where we're going on with our Data implementation + +We are also planning to implement `HttpPersister`, which would make it possible +to use the API without a local dependency for these modules. It would +connect over an HTTP REST interface to a separate microservice with the real +MySQL or PostgreSQL pool (including the dependency). + +### Life cycle annotations + +* `@PostLoad()` -- Registers a callback to be executed after loading an entity. +* `@PostPersist()` -- Registers a callback to be executed after persisting an entity. +* `@PostRemove()` -- Registers a callback to be executed after removing an entity. +* `@PostUpdate()` -- Registers a callback to be executed after updating an entity. +* `@PrePersist()` -- Registers a callback to be executed before persisting an entity. +* `@PreRemove()` -- Registers a callback to be executed before removing an entity. +* `@PreUpdate()` -- Registers a callback to be executed before updating an entity. diff --git a/data/Sort.test.ts b/data/Sort.test.ts new file mode 100644 index 0000000..616661e --- /dev/null +++ b/data/Sort.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SortOrder } from "./types/SortOrder"; +import { SortDirection } from "./types/SortDirection"; +import { Sort } from "./Sort"; + +describe("Sort", () => { + + describe("#by", () => { + + it("should create a new instance with given SortOrder array", () => { + const orders = [ + new SortOrder(SortDirection.ASC, "firstName"), + new SortOrder(SortDirection.DESC, "lastName"), + ]; + const sort = Sort.by(orders); + expect(sort.getSortOrders()).toStrictEqual(orders); + }); + + it("should create a new instance with given property names", () => { + const sort = Sort.by("firstName", "lastName"); + + expect(sort.getSortOrders()).toStrictEqual([ + new SortOrder(Sort.DEFAULT_DIRECTION, "firstName"), + new SortOrder(Sort.DEFAULT_DIRECTION, "lastName"), + ]); + }); + + it("should create a new instance with given SortDirection and property names", () => { + const sort = Sort.by(SortDirection.ASC, "firstName", "lastName"); + + expect(sort.getSortOrders()).toStrictEqual([ + new SortOrder(SortDirection.ASC, "firstName"), + new SortOrder(SortDirection.ASC, "lastName"), + ]); + }); + + it("should create a new instance with given SortOrder objects", () => { + const a = new SortOrder(SortDirection.ASC, "firstName"); + const b = new SortOrder(SortDirection.DESC, "lastName"); + const sort = Sort.by(a, b); + expect(sort.getSortOrders()).toStrictEqual([a, b]); + }); + + it("should create a new instance with default SortDirection for property names", () => { + const sort = Sort.by("firstName", "lastName"); + + expect(sort.getSortOrders()).toEqual([ + new SortOrder(Sort.DEFAULT_DIRECTION, "firstName"), + new SortOrder(Sort.DEFAULT_DIRECTION, "lastName"), + ]); + }); + + it("should throw an error for invalid arguments", () => { + // @ts-ignore + expect(() => Sort.by({})).toThrowError(TypeError); + // @ts-ignore + expect(() => Sort.by("firstName", {})).toThrowError(TypeError); + // @ts-ignore + expect(() => Sort.by(3)).toThrowError(TypeError); + }); + + }); + + describe("#Direction", () => { + it("should define sort direction constants", () => { + expect(Sort.Direction.ASC).toEqual(SortDirection.ASC); + expect(Sort.Direction.DESC).toEqual(SortDirection.DESC); + }); + }); + +}); diff --git a/data/Sort.ts b/data/Sort.ts new file mode 100644 index 0000000..88321df --- /dev/null +++ b/data/Sort.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isSortDirection, SortDirection } from "./types/SortDirection"; +import { isSortOrder, SortOrder } from "./types/SortOrder"; +import { isArrayOf } from "../types/Array"; +import { isStringArray } from "../types/StringArray"; +import { map } from "../functions/map"; + +/** + * @see https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Sort.html + */ +export class Sort { + + private readonly _orders : readonly SortOrder[] | undefined; + private readonly _sort : (a: any, b: any) => number; + + public static DEFAULT_DIRECTION : SortDirection = SortDirection.ASC; + + protected constructor ( + orders : readonly SortOrder[], + ) { + this._orders = orders; + this._sort = SortOrder.createSortFunction(this._orders); + } + + public getSortOrders () : SortOrder[] { + return map(this._orders, (o) => o); + } + + public getSortFunction (): (a: any, b: any) => number { + return this._sort; + } + + public static Direction = SortDirection; + + // Signatures + + public static by ( + orders: readonly SortOrder[] + ) : Sort; + + public static by ( + property1: string, + ...properties: readonly string[] + ) : Sort; + + public static by ( + direction: SortDirection, + ...properties: readonly string[] + ) : Sort; + + public static by ( + order1: SortOrder, + ...orders: readonly SortOrder[] + ) : Sort; + + /** + * + * @param arg1 + * @param arg2 + */ + public static by( + arg1 : string | SortDirection | SortOrder | readonly SortOrder[], + ...arg2 : readonly string[] | readonly SortOrder[] + ): Sort { + + // Sort.by(SortOrder[]) + if ( arg2.length === 0 && isArrayOf(arg1, isSortOrder) ) { + return new Sort( map(arg1, (item : SortOrder) : SortOrder => item) ); + } + + // Sort.by(SortDirection, string[]) + if (isSortDirection(arg1)) { + if (isStringArray(arg2)) { + return new Sort( map(arg2, (item : string) : SortOrder => new SortOrder(arg1, item)) ); + } + throw new TypeError(`Invalid function signature: ${arg1} ${arg2.join(' ')}`); + } + + const args = [arg1, ...arg2]; + + // Sort.by(...SortOrder[]) + if (isArrayOf(args, isSortOrder)) { + return new Sort( args ); + } + + // Sort.by(...string[]) + if (isStringArray(args)) { + return new Sort( map(args, (item : string) : SortOrder => new SortOrder(Sort.DEFAULT_DIRECTION, item)) ); + } + + throw new TypeError(`Invalid function signature: ${arg1} ${arg2.join(' ')}`); + + } + + public static createSortFunction ( sort: Sort ): (a: T, b: T) => number { + return SortOrder.createSortFunction(sort.getSortOrders()); + } + +} + +export function isSort (value: unknown): value is Sort { + return value instanceof Sort; +} diff --git a/data/Table.test.ts b/data/Table.test.ts new file mode 100644 index 0000000..f5cf4fa --- /dev/null +++ b/data/Table.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { Table } from "./Table"; +import { Entity } from "./Entity"; +import { Id } from "./Id"; +import { Column } from "./Column"; + +describe('Table', () => { + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {fooName: string}) { + super() + this.fooName = dto?.fooName; + } + + @Id() + @Column('foo_id') + public fooId ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('foo_number') + public fooNumber ?: number; + + @Column('foo_boolean') + public fooBoolean ?: boolean; + + } + + let entity : FooEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new FooEntity(); + metadata = entity.getMetadata(); + }); + + it('can set tableName metadata', () => { + expect(metadata.tableName).toBe('foos'); + }); + + it('can set createEntity metadata', () => { + expect(metadata.createEntity).toBeFunction(); + }); + +}); diff --git a/data/Table.ts b/data/Table.ts new file mode 100644 index 0000000..7e4ddf3 --- /dev/null +++ b/data/Table.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../LogService"; +import { isFunction } from "../types/Function"; +import { isEntity } from "./Entity"; +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; + +const LOG = LogService.createLogger( 'Table' ); + +export const Table = (tableName: string) => { + return ( + target: any, + // @ts-ignore @todo why unused? + context ?: ClassDecoratorContext + ) : void => { + const TargetEntity = isFunction(target) ? target : undefined; + EntityMetadataUtils.updateMetadata(target, (metadata: EntityMetadata) => { + metadata.tableName = tableName; + if (TargetEntity) { + + const createEntity = (dto?: any) => new TargetEntity(dto); + + // We'll test if it creates correct entity object + const ret : any = createEntity() as unknown as any; + if (!isEntity(ret)) { + LOG.warn(`Warning! @Table(${JSON.stringify(tableName)}): Your entity class was not extended from the Entity base class. Functionality will be broken.`); + } else { + metadata.createEntity = createEntity; + } + + } + }); + }; +}; diff --git a/data/Temporal.test.ts b/data/Temporal.test.ts new file mode 100644 index 0000000..65420c9 --- /dev/null +++ b/data/Temporal.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityField, EntityField } from "./types/EntityField"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { Temporal } from "./Temporal"; +import { TemporalType } from "./types/TemporalType"; +import { createTemporalProperty, TemporalProperty } from "./types/TemporalProperty"; + +describe('Temporal', () => { + + @Table('bars') + class BarEntity extends Entity { + + constructor (dto ?: {barName: string}) { + super() + this.barName = dto?.barName; + } + + @Id() + @Column('bar_id') + public barId ?: string; + + @Column('bar_name') + public barName ?: string; + + @Temporal(TemporalType.TIMESTAMP) + @Column('bar_date') + public barDate ?: string; + + @Temporal(TemporalType.TIMESTAMP) + @Column('bar_updated') + public barUpdated ?: string; + + } + + let entity : BarEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new BarEntity(); + metadata = entity.getMetadata(); + }); + + it('can set fields metadata for barId field', () => { + const expectedField : EntityField = createEntityField("barId", "bar_id"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barName property', () => { + const expectedField : EntityField = createEntityField("barName", "bar_name"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barDate property', () => { + const expectedField : EntityField = createEntityField("barDate","bar_date"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barUpdated property', () => { + const expectedField : EntityField = createEntityField("barUpdated", "bar_updated"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set temporal metadata for barDate property', () => { + const expectedField : TemporalProperty = createTemporalProperty("barDate", TemporalType.TIMESTAMP); + expect(metadata.temporalProperties).toBeArray(); + expect(metadata.temporalProperties).toContainEqual(expectedField); + }); + + it('can set temporal metadata for barUpdated property', () => { + const expectedField : TemporalProperty = createTemporalProperty("barUpdated", TemporalType.TIMESTAMP); + expect(metadata.temporalProperties).toBeArray(); + expect(metadata.temporalProperties).toContainEqual(expectedField); + }); + +}); diff --git a/data/Temporal.ts b/data/Temporal.ts new file mode 100644 index 0000000..698773f --- /dev/null +++ b/data/Temporal.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { explainTemporalType, isTemporalType, TemporalType } from "./types/TemporalType"; +import { createTemporalProperty } from "./types/TemporalProperty"; +import { isString, isStringOrSymbol } from "../types/String"; + +export const Temporal = ( + type : TemporalType = TemporalType.TIMESTAMP +) => { + return (target: any, context : any) => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (!isString(propertyName)) throw new TypeError(`Symbols not supported for property "${propertyName.toString()}"`); + if (!isTemporalType(type)) throw new TypeError(`Only TemporalType properties supported for property "${propertyName}". The type was ${explainTemporalType(type)}.`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.temporalProperties.push(createTemporalProperty(propertyName, type)); + }); + }; +}; diff --git a/data/UpdateTimestamp.test.ts b/data/UpdateTimestamp.test.ts new file mode 100644 index 0000000..a09855a --- /dev/null +++ b/data/UpdateTimestamp.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { createEntityField, EntityField } from "./types/EntityField"; +import { Table } from "./Table"; +import { Id } from "./Id"; +import { Column } from "./Column"; +import { Entity } from "./Entity"; +import { Temporal } from "./Temporal"; +import { TemporalType } from "./types/TemporalType"; +import { createTemporalProperty, TemporalProperty } from "./types/TemporalProperty"; +import { UpdateTimestamp } from "./UpdateTimestamp"; + +describe('UpdateTimestamp', () => { + + @Table('bars') + class BarEntity extends Entity { + + constructor (dto ?: {barName: string}) { + super() + this.barName = dto?.barName; + } + + @Id() + @Column('bar_id') + public barId ?: string; + + @Column('bar_name') + public barName ?: string; + + @Temporal(TemporalType.TIMESTAMP) + @Column('bar_date') + public barDate ?: string; + + @UpdateTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column('bar_updated') + public barUpdated ?: string; + + } + + let entity : BarEntity; + let metadata : EntityMetadata; + + beforeEach(() => { + entity = new BarEntity(); + metadata = entity.getMetadata(); + }); + + it('can set fields metadata for barId field', () => { + const expectedField : EntityField = createEntityField("barId", "bar_id"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barName property', () => { + const expectedField : EntityField = createEntityField("barName", "bar_name"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barDate property', () => { + const expectedField : EntityField = createEntityField("barDate","bar_date"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set fields metadata for barUpdated property', () => { + const expectedField : EntityField = createEntityField("barUpdated", "bar_updated"); + expect(metadata.fields).toBeArray(); + expect(metadata.fields).toContainEqual(expectedField); + }); + + it('can set temporal metadata for barDate property', () => { + const expectedField : TemporalProperty = createTemporalProperty("barDate", TemporalType.TIMESTAMP); + expect(metadata.temporalProperties).toBeArray(); + expect(metadata.temporalProperties).toContainEqual(expectedField); + }); + + it('can set temporal metadata for barUpdated property', () => { + const expectedField : TemporalProperty = createTemporalProperty("barUpdated", TemporalType.TIMESTAMP); + expect(metadata.temporalProperties).toBeArray(); + expect(metadata.temporalProperties).toContainEqual(expectedField); + }); + + it('cannot set creation timestamp metadata for barDate property', () => { + expect(metadata.creationTimestamps).toBeArray(); + expect(metadata.creationTimestamps).not.toContainEqual("barDate"); + }); + + it('cannot set creation timestamp metadata for barUpdated property', () => { + expect(metadata.creationTimestamps).toBeArray(); + expect(metadata.creationTimestamps).not.toContainEqual("barUpdated"); + }); + + it('cannot set update timestamp metadata for barDate property', () => { + expect(metadata.updateTimestamps).toBeArray(); + expect(metadata.updateTimestamps).not.toContainEqual("barDate"); + }); + + it('cannot set update timestamp metadata for barUpdated property', () => { + expect(metadata.updateTimestamps).toBeArray(); + expect(metadata.updateTimestamps).toContainEqual("barUpdated"); + }); + +}); diff --git a/data/UpdateTimestamp.ts b/data/UpdateTimestamp.ts new file mode 100644 index 0000000..417e92c --- /dev/null +++ b/data/UpdateTimestamp.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadataUtils } from "./utils/EntityMetadataUtils"; +import { EntityMetadata } from "./types/EntityMetadata"; +import { isString, isStringOrSymbol } from "../types/String"; + +/** + * Annotation which marks the property to be automatically initialized by + * update time. + */ +export const UpdateTimestamp = () => { + return (target: any, context : any) => { + const propertyName = isStringOrSymbol(context) ? context : context?.name; + if (!isString(propertyName)) throw new TypeError(`Symbols not supported for property "${propertyName.toString()}"`); + EntityMetadataUtils.updateMetadata(target.constructor, (metadata: EntityMetadata) => { + metadata.updateTimestamps.push(propertyName); + }); + }; +}; diff --git a/data/Where.test.ts b/data/Where.test.ts new file mode 100644 index 0000000..9cc0f0f --- /dev/null +++ b/data/Where.test.ts @@ -0,0 +1,332 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isWhere, Where } from './Where'; +import { Condition } from "./conditions/types/Condition"; +import { PropertyNameTarget } from "./conditions/types/PropertyNameTarget"; +import { BetweenCondition } from "./conditions/BetweenCondition"; +import { EqualCondition } from "./conditions/EqualCondition"; +import { OrCondition } from "./conditions/OrCondition"; +import { AndCondition } from "./conditions/AndCondition"; + +describe('Where', () => { + + describe('#getConditions', () => { + it('returns the list of conditions', () => { + const conditions: readonly Condition[] = [ + BetweenCondition.create(PropertyNameTarget.create('age'), 18, 30), + EqualCondition.create(PropertyNameTarget.create('city'), 'New York') + ]; + const where = Where.fromConditionList(conditions); + expect(where.getConditions()).toStrictEqual(conditions); + }); + }); + + describe('#and', () => { + + it('combines two Where instances', () => { + const where1 = Where.propertyEquals('city', 'New York'); + const where2 = Where.propertyBetween('age', 18, 30); + const whereCombined = where1.and(where2); + + expect(whereCombined.getConditions()).toStrictEqual( + [ + ...where1.getConditions(), + ...where2.getConditions() + ] + ); + }); + + it('combines two Where instances with or logic', () => { + const where1 = Where.propertyEquals('city', 'Oulu'); + const where2 = Where.propertyBetween('age', 17, 25); + + const where3 = Where.propertyEquals('city', 'New York'); + const where4 = Where.propertyBetween('age', 18, 30); + //console.log(`where1 = ${where1}`) + //console.log(`where2 = ${where2}`) + //console.log(`where3 = ${where3}`) + //console.log(`where4 = ${where4}`) + + const whereCombined = Where.or(where1, where2).and( + Where.or(where3, where4) + ); + + //console.log(`whereCombined = ${whereCombined}`) + + // Where( + // OrCondition( + // EqualCondition(PropertyNameTarget(city) === Oulu) + // or BetweenCondition(PropertyNameTarget(age) is between 17 and 25) + // ), + // OrCondition( + // EqualCondition(PropertyNameTarget(city) === New York) + // or BetweenCondition(PropertyNameTarget(age) is between 18 and 30) + // ) + // ) + + expect(whereCombined.getConditions()).toStrictEqual( + [ + OrCondition.create( + Where.fromConditionList( + [ + ...where1.getConditions(), + ...where2.getConditions(), + ] + ) + ), + OrCondition.create( + Where.fromConditionList( + [ + ...where3.getConditions(), + ...where4.getConditions() + ] + ) + ) + ] + ); + + }); + + }); + + describe('#or', () => { + + it('combines two Where instances into a new one with an OrCondition', () => { + + const whereA = Where.propertyEquals('city', 'New York'); + const whereB = Where.propertyEquals('city', 'Los Angeles'); + + const whereCombined = whereA.or(whereB); + + //console.log(`whereCombined = ${whereCombined}`) + + // Where( + // OrCondition( + // EqualCondition(PropertyNameTarget(city) === New York) + // or EqualCondition(PropertyNameTarget(city) === Los Angeles) + // ) + // ) + + const conditions = whereCombined.getConditions(); + + expect(conditions).toHaveLength(1); + expect(conditions[0]).toBeInstanceOf(OrCondition); + expect((conditions[0] as OrCondition).getWhere().getConditions()).toStrictEqual( + [ + ...whereA.getConditions(), + ...whereB.getConditions() + ] + ); + + }); + + it('combines multiple conditions correctly', () => { + const whereA = Where.and( + Where.propertyEquals('city', 'New York'), + Where.propertyEquals('age', 25) + ); + + const whereB = Where.and( + Where.propertyEquals('city', 'Los Angeles'), + Where.propertyBetween('age', 30, 40) + ); + + const whereCombined = whereA.or(whereB); + + const conditions = whereCombined.getConditions(); + + expect(conditions).toHaveLength(1); + expect(conditions[0]).toBeInstanceOf(OrCondition); + expect((conditions[0] as OrCondition).getWhere().getConditions()).toStrictEqual( + [ + AndCondition.create(whereA), + AndCondition.create(whereB), + ] + ); + }); + + it('combines two Where instances with and logic', () => { + + const where1 = Where.propertyEquals('city', 'Oulu'); + const where2 = Where.propertyBetween('age', 17, 25); + + const where3 = Where.propertyEquals('city', 'New York'); + const where4 = Where.propertyBetween('age', 18, 30); + + const whereCombined = Where.and(where1, where2).or( + Where.and(where3, where4) + ); + + //console.log(`whereCombined = ${whereCombined}`) + + // Where( + // OrCondition( + // AndCondition( + // EqualCondition(PropertyNameTarget(city) === Oulu) + // and BetweenCondition(PropertyNameTarget(age) is between 17 and 25) + // ) + // or AndCondition( + // EqualCondition(PropertyNameTarget(city) === New York) + // and BetweenCondition(PropertyNameTarget(age) is between 18 and 30) + // ) + // ) + // ) + + expect(whereCombined.getConditions()).toStrictEqual( + [ + OrCondition.create( + Where.fromConditionList( + [ + AndCondition.create( + Where.fromConditionList( + [ + ...where1.getConditions(), + ...where2.getConditions(), + ] + ) + ), + AndCondition.create( + Where.fromConditionList( + [ + ...where3.getConditions(), + ...where4.getConditions(), + ] + ) + ) + ] + ) + ) + ] + ); + + }); + + }); + + describe('#propertyBetween', () => { + it('creates a Where instance with a between condition', () => { + const where = Where.propertyBetween('age', 18, 30); + const conditions = where.getConditions(); + + expect(conditions.length).toBe(1); + expect(conditions[0]).toBeInstanceOf(BetweenCondition); + }); + }); + + describe('#propertyEquals', () => { + it('creates a Where instance with an equal condition', () => { + const where = Where.propertyEquals('city', 'New York'); + const conditions = where.getConditions(); + + expect(conditions.length).toBe(1); + expect(conditions[0]).toBeInstanceOf(EqualCondition); + }); + }); + + describe('#propertyListEquals', () => { + + it('creates a Where instance with multiple equal conditions', () => { + const values = ['New York', 'Los Angeles', 'Chicago']; + const where = Where.propertyListEquals('city', values); + const conditions = where.getConditions(); + + expect(conditions.length).toBe(1); + conditions.forEach((condition) => { + expect(condition).toBeInstanceOf(OrCondition); + + const childConditions = (condition as OrCondition).getWhere().getConditions(); + + expect(childConditions.length).toBe(3); + childConditions.forEach((childCondition, childIndex) => { + expect(childCondition).toBeInstanceOf(EqualCondition); + expect((childCondition as EqualCondition).getValue()).toStrictEqual(values[childIndex]); + }); + + }); + }); + + it('throws an error when the value list is empty', () => { + expect(() => { + Where.propertyListEquals('city', []); + }).toThrow(TypeError); + }); + + }); + + describe('#and (static)', () => { + it('combines two Where instances', () => { + + const where1 = Where.propertyEquals('city', 'New York'); + const where2 = Where.propertyBetween('age', 18, 30); + const whereCombined = Where.and(where1, where2); + //console.log(`whereCombined = ${whereCombined}`) + + // Where( + // EqualCondition(PropertyNameTarget(city) === New York), + // BetweenCondition(PropertyNameTarget(age) is between 18 and 30) + // ) + + expect(whereCombined.getConditions()).toStrictEqual( + [ + ...where1.getConditions(), + ...where2.getConditions() + ] + ); + }); + }); + + describe('#or (static)', () => { + + it('combines two Where instances', () => { + const where1 = Where.propertyEquals('city', 'New York'); + const where2 = Where.propertyBetween('age', 18, 30); + const whereCombined = Where.or(where1, where2); + //console.log(`whereCombined = ${whereCombined}`) + + // Where( + // OrCondition( + // EqualCondition(PropertyNameTarget(city) === New York) + // or BetweenCondition(PropertyNameTarget(age) is between 18 and 30) + // ) + // ) + + expect(whereCombined.getConditions()).toStrictEqual( + [ + OrCondition.create( + Where.fromConditionList( + [ + ...where1.getConditions(), + ...where2.getConditions() + ] + ) + ) + ] + ); + }); + + it('has identical results with non-static version', () => { + const where1 = Where.propertyEquals('city', 'New York'); + const where2 = Where.propertyBetween('age', 18, 30); + const whereCombined1 = Where.or(where1, where2); + const whereCombined2 = where1.or(where2); + + expect(whereCombined1).toStrictEqual(whereCombined2); + }); + + }); + +}); + +describe('isWhere', () => { + + it('returns true for Where instances', () => { + const where = Where.propertyEquals('city', 'New York'); + expect(isWhere(where)).toBe(true); + }); + + it('returns false for non-Where instances', () => { + const notWhere = { someKey: 'someValue' }; + expect(isWhere(notWhere)).toBe(false); + }); + +}); diff --git a/data/Where.ts b/data/Where.ts new file mode 100644 index 0000000..73e2930 --- /dev/null +++ b/data/Where.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Condition } from "./conditions/types/Condition"; +import { BetweenCondition } from "./conditions/BetweenCondition"; +import { PropertyNameTarget } from "./conditions/types/PropertyNameTarget"; +import { EqualCondition } from "./conditions/EqualCondition"; +import { map } from "../functions/map"; +import { OrCondition } from "./conditions/OrCondition"; +import { AndCondition } from "./conditions/AndCondition"; +import { BeforeCondition } from "./conditions/BeforeCondition"; +import { AfterCondition } from "./conditions/AfterCondition"; + +export class Where { + + private readonly _list : readonly Condition[]; + + protected constructor ( + list : readonly Condition[] + ) { + this._list = list; + } + + public valueOf () : string { + return this.toString(); + } + + public toString () : string { + return `Where(${map(this._list, item => item.toString()).join(', ')})`; + } + + public getConditions () : readonly Condition[] { + return this._list; + } + + public and ( + item: Where + ): Where { + return new Where( + [ + ...this.getConditions(), + ...item.getConditions() + ] + ); + } + + public or ( + item: Where + ): Where { + const myConditions = this.getConditions(); + if (myConditions?.length <= 0) throw new TypeError('At least one conditions must exist on left side where'); + const itemConditions = item.getConditions(); + if (itemConditions?.length <= 0) throw new TypeError('At least one conditions must exist on right side where'); + return new Where( + [ + OrCondition.create( + new Where( + [ + myConditions.length === 1 ? myConditions[0] : AndCondition.create(this), + itemConditions?.length === 1 ? itemConditions[0] : AndCondition.create(item) + ] + ) + ) + ] + ); + } + + public static propertyBetween ( + property: string, + start: T, + end: T + ) : Where { + return new Where( + [ + BetweenCondition.create(PropertyNameTarget.create(property), start, end) + ] + ); + } + + public static propertyAfter ( + property: string, + value: T + ) : Where { + return new Where( + [ + AfterCondition.create(PropertyNameTarget.create(property), value) + ] + ); + } + + public static propertyBefore ( + property: string, + value: T + ) : Where { + return new Where( + [ + BeforeCondition.create(PropertyNameTarget.create(property), value) + ] + ); + } + + public static propertyEquals ( + property: string, + value: T + ) : Where { + return new Where( + [ + EqualCondition.create(PropertyNameTarget.create(property), value) + ] + ); + } + + /** + * Matches if one of the values matches. + * + * @param propertyName + * @param values + */ + public static propertyListEquals ( + propertyName : string, + values : readonly T[] + ) : Where { + if (!values.length) throw new TypeError(`Value list must contain some values for property "${propertyName}"`); + const propertyNameTarget = PropertyNameTarget.create(propertyName); + return new Where( + [ + OrCondition.create( + new Where( + map( + values, + (value: T) : Condition => EqualCondition.create(propertyNameTarget, value) + ) + ) + ) + ] + ); + } + + public static and ( + a: Where, + b: Where, + ) : Where { + return a.and(b); + } + + public static or ( + a: Where, + b: Where, + ) : Where { + return a.or(b); + } + + public static fromConditionList ( + list : readonly Condition[] + ) : Where { + return new Where(list); + } + +} + +export function isWhere (value: unknown): value is Where { + return value instanceof Where; +} diff --git a/data/adapters/simple/NewSimpleDTO.ts b/data/adapters/simple/NewSimpleDTO.ts new file mode 100644 index 0000000..e846319 --- /dev/null +++ b/data/adapters/simple/NewSimpleDTO.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isReadonlyJsonObject, ReadonlyJsonObject } from "../../../Json"; +import { isStringArray } from "../../../types/StringArray"; + +export interface NewSimpleDTO { + readonly data : ReadonlyJsonObject; + readonly members : readonly string[]; +} + +export function createNewSimpleDTO ( + data : ReadonlyJsonObject, + members ?: readonly string[] +) : NewSimpleDTO { + return { + data, + members: members ?? [] + }; +} + + +export function isNewSimpleDTO (value: any): value is NewSimpleDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'data', + 'members' + ]) + && isReadonlyJsonObject(value?.data) + && isStringArray(value?.members) + ); +} + +export function stringifyNewSimpleDTO (value: NewSimpleDTO): string { + if ( !isNewSimpleDTO(value) ) throw new TypeError(`Not NewSimpleDTO: ${value}`); + return `NewSimpleDTO(${value})`; +} + +export function parseNewSimpleDTO (value: any): NewSimpleDTO | undefined { + if ( isNewSimpleDTO(value) ) return value; + return undefined; +} diff --git a/data/adapters/simple/SimpleDTO.ts b/data/adapters/simple/SimpleDTO.ts new file mode 100644 index 0000000..37fc96b --- /dev/null +++ b/data/adapters/simple/SimpleDTO.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isReadonlyJsonObject, ReadonlyJsonObject } from "../../../Json"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isString } from "../../../types/String"; +import { NewSimpleDTO } from "./NewSimpleDTO"; +import { isStringArray } from "../../../types/StringArray"; +import { isNumber } from "../../../types/Number"; +import { isBoolean } from "../../../types/Boolean"; + +/** + * The client object used in the REST API communication + */ +export interface SimpleDTO extends NewSimpleDTO { + readonly id : string; + readonly updated : string; + readonly created : string; + readonly data : ReadonlyJsonObject; + readonly members : readonly string[]; + readonly invited : readonly string[]; + readonly version : number; + readonly deleted : boolean; +} + +export function createSimpleDTO ( + id : string, + updated : string, + created : string, + data : ReadonlyJsonObject, + members : readonly string[], + invited : readonly string[], + version : number, + deleted : boolean +) : SimpleDTO { + return { + id, + updated, + created, + data, + members, + invited, + version, + deleted + }; +} + +export function isSimpleDTO (value: any): value is SimpleDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + "id", + "updated", + "created", + "data", + "members", + "invited", + 'version', + 'deleted', + ]) + && isString(value?.id) + && isString(value?.updated) + && isString(value?.created) + && isReadonlyJsonObject(value?.data) + && isStringArray(value?.members) + && isStringArray(value?.invited) + && isNumber(value?.version) + && isBoolean(value?.deleted) + ); +} + +export function stringifySimpleDTO (value: SimpleDTO): string { + if ( !isSimpleDTO(value) ) throw new TypeError(`Not SimpleDTO: ${value}`); + return `SimpleDTO(${value})`; +} + +export function parseSimpleDTO (value: any): SimpleDTO | undefined { + if ( isSimpleDTO(value) ) return value; + return undefined; +} diff --git a/data/adapters/simple/SimpleEntity.ts b/data/adapters/simple/SimpleEntity.ts new file mode 100644 index 0000000..a41adad --- /dev/null +++ b/data/adapters/simple/SimpleEntity.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../../LogService"; +import { Entity } from "../../Entity"; +import { EntityUtils } from "../../utils/EntityUtils"; +import { createSimpleDTO, isSimpleDTO, SimpleDTO } from "./SimpleDTO"; +import { ReadonlyJsonObject } from "../../../Json"; +import { join } from "../../../functions/join"; + +const LOG = LogService.createLogger('SimpleEntity'); + +export abstract class SimpleEntity extends Entity { + + public abstract entityId?: string; + public abstract entityUpdated?: string; + public abstract entityCreated?: string; + public abstract entityData?: string; + public abstract entityMembers?: string; + public abstract entityInvited?: string; + public abstract entityVersion?: number; + public abstract entityDeleted?: boolean; + + public static parseId (entity: SimpleEntity) : string { + return EntityUtils.parseIntegerAsString(entity.entityId) ?? ''; + } + + public static parseUpdated (entity: SimpleEntity) : string { + return EntityUtils.parseMySQLDateAsIsoString(entity.entityUpdated) ?? ''; + } + + public static parseCreated (entity: SimpleEntity) : string { + return EntityUtils.parseMySQLDateAsIsoString(entity.entityCreated) ?? ''; + } + + public static parseData (entity: SimpleEntity) : ReadonlyJsonObject { + return EntityUtils.parseJsonObject(entity.entityData) ?? {}; + } + + public static parseMembers (entity: SimpleEntity) : readonly string[] { + return EntityUtils.parseStringArray(entity.entityMembers, ' '); + } + + public static parseInvited (entity: SimpleEntity) : readonly string[] { + return EntityUtils.parseStringArray(entity.entityInvited, ' '); + } + + public static prepareMembers (value : readonly string[]) : string { + return join(value, ' '); + } + + public static prepareInvited (value : readonly string[]) : string { + return join(value, ' '); + } + + public static parseVersion (entity: SimpleEntity) : number { + return EntityUtils.parseNumber(entity.entityVersion) ?? 0; + } + + public static parseDeleted (entity: SimpleEntity) : boolean { + return EntityUtils.parseBoolean(entity.entityDeleted) ?? false; + } + + public static parseNextVersion (entity: SimpleEntity) : number { + return this.parseVersion(entity) + 1; + } + + public static toDTO (entity: SimpleEntity) : SimpleDTO { + const dto : SimpleDTO = createSimpleDTO( + this.parseId(entity), + this.parseUpdated(entity), + this.parseCreated(entity), + this.parseData(entity), + this.parseMembers(entity), + this.parseInvited(entity), + this.parseVersion(entity), + this.parseDeleted(entity) + ); + // Redundant fail safe + if (!isSimpleDTO(dto)) { + LOG.debug(`toDTO: dto / entity = `, dto, entity); + throw new TypeError(`Failed to create valid SimpleDTO`); + } + return dto; + } + +} + diff --git a/data/adapters/simple/SimpleEntityRepository.ts b/data/adapters/simple/SimpleEntityRepository.ts new file mode 100644 index 0000000..6d41b07 --- /dev/null +++ b/data/adapters/simple/SimpleEntityRepository.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { SimpleEntity } from "./SimpleEntity"; +import { Repository } from "../../types/Repository"; + +export interface SimpleEntityRepository extends Repository { + + findAllByEntityDeleted (deleted: boolean) : Promise; + findByEntityDeleted (deleted: boolean) : Promise; + deleteAllByEntityDeleted (deleted: boolean) : Promise; + existsByEntityDeleted (deleted: boolean) : Promise; + countByEntityDeleted (deleted: boolean) : Promise; + + findAllByEntityVersion (version: number) : Promise; + findByEntityVersion (version: number) : Promise; + deleteAllByEntityVersion (version: number) : Promise; + existsByEntityVersion (version: number) : Promise; + countByEntityVersion (version: number) : Promise; + +} diff --git a/data/adapters/simple/SimpleRepositoryAdapter.ts b/data/adapters/simple/SimpleRepositoryAdapter.ts new file mode 100644 index 0000000..ac90eb4 --- /dev/null +++ b/data/adapters/simple/SimpleRepositoryAdapter.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { ReadonlyJsonObject } from "../../../Json"; +import { map } from "../../../functions/map"; +import { forEach } from "../../../functions/forEach"; +import { filter } from "../../../functions/filter"; +import { get } from "../../../functions/get"; +import { uniq } from "../../../functions/uniq"; +import { concat } from "../../../functions/concat"; + +import { SimpleStoredRepositoryItem, StoredRepositoryItemExplainCallback, StoredRepositoryItemTestCallback } from "../../../simpleRepository/types/SimpleStoredRepositoryItem"; +import { SimpleRepository as SimpleBaseRepository, REPOSITORY_NEW_IDENTIFIER } from "../../../simpleRepository/types/SimpleRepository"; +import { createRepositoryEntry, SimpleRepositoryEntry } from "../../../simpleRepository/types/SimpleRepositoryEntry"; +import { createSimpleRepositoryMember } from "../../../simpleRepository/types/SimpleRepositoryMember"; +import { SimpleRepositoryUtils } from "../../../simpleRepository/SimpleRepositoryUtils"; + +import { SimpleEntityRepository } from "./SimpleEntityRepository"; +import { SimpleEntity } from "./SimpleEntity"; +import { createNewSimpleDTO, NewSimpleDTO } from "./NewSimpleDTO"; +import { LogService } from "../../../LogService"; +import { LogLevel } from "../../../types/LogLevel"; + +const LOG = LogService.createLogger('SimpleRepositoryAdapter'); + +/** + * This is an adapter between SimpleRepository framework and the Repository + * framework. + */ +export class SimpleRepositoryAdapter implements SimpleBaseRepository { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _repository : SimpleEntityRepository; + private readonly _members : readonly string[]; + private readonly _isT : StoredRepositoryItemTestCallback; + // private readonly _explainT : StoredRepositoryItemExplainCallback; + // private readonly _tName : string; + private readonly _tCreate : (dto: NewSimpleDTO) => SimpleEntityT; + + public constructor ( + repository : SimpleEntityRepository, + tCreate : (dto: NewSimpleDTO) => SimpleEntityT, + isT : StoredRepositoryItemTestCallback, + // @ts-ignore @todo Why not used? + tName : string | undefined, + // @ts-ignore @todo Why not used? + explainT : StoredRepositoryItemExplainCallback | undefined, + members : readonly string[] | undefined + ) { + this._repository = repository; + this._members = members ?? []; + this._isT = isT; + // this._tName = tName ?? 'T'; + // this._explainT = explainT ?? ( (value: any) : string => isT(value) ? explainOk() : explainNot(this._tName) ); + this._tCreate = tCreate; + } + + public async createItem ( + data: T, + members?: readonly string[] + ): Promise> { + LOG.debug(`createItem: data = `, data, 'members =', members); + const newEntity = this._tCreate( + createNewSimpleDTO( + data as unknown as ReadonlyJsonObject, + uniq(concat([], members ? members : [], this._members)) + ) + ); + const savedEntity = await this._repository.save(newEntity); + return this._createRepositoryEntryFromEntity(savedEntity); + } + + public async deleteAll (): Promise[]> { + // FIXME: This call might not return all non-deleted entries + const all = await this._repository.findAllByEntityDeleted(false); + await this._repository.deleteAll(); + return this._createRepositoryEntryArrayFromEntityArray(all); + } + + public async deleteById (id: string): Promise> { + const entity : SimpleEntityT | undefined = await this._repository.findById(id); + if (!entity) throw new TypeError(`Could not find entity by id: ${id}`); + entity.entityVersion = SimpleEntity.parseNextVersion(entity); + entity.entityDeleted = true; + const savedEntity = await this._repository.save(entity); + await this._repository.deleteById(id); + return this._createRepositoryEntryFromEntity(savedEntity); + } + + public async deleteByIdList (list: readonly string[]): Promise[]> { + if (list.length <= 0) throw new TypeError('deleteByIdList: The list argument was empty'); + const entities = await this._repository.findAllById(list); + forEach( + entities, + (item) => { + item.entityVersion = SimpleEntity.parseNextVersion(item); + item.entityDeleted = true; + } + ); + const savedEntities = await this._repository.saveAll(entities); + return this._createRepositoryEntryArrayFromEntityArray(savedEntities); + } + + public async deleteByList (list: SimpleRepositoryEntry[]): Promise[]> { + if (list.length <= 0) throw new TypeError('deleteByList: The list argument was empty'); + return await this.deleteByIdList( map(list, item => item.id) ); + } + + public async findById ( + id: string, + includeMembers?: boolean + ): Promise | undefined> { + const entity : SimpleEntity | undefined = await this._repository.findById(id); + if (!entity) return undefined; + return this._createRepositoryEntryFromEntity(entity, includeMembers); + } + + public async findByIdAndUpdate (id: string, item: T): Promise> { + const entity : SimpleEntityT | undefined = await this._repository.findById(id); + if (!entity) throw new TypeError(`Could not find entity by id: ${id}`); + entity.entityVersion = SimpleEntity.parseNextVersion(entity); + entity.entityData = JSON.stringify(item); + const savedEntity = await this._repository.save(entity); + return this._createRepositoryEntryFromEntity(savedEntity); + } + + /** + * + * @param propertyName + * @param propertyValue + * @fixme Current implementation is slow. Requires better implementation. + */ + public async findByProperty (propertyName: string, propertyValue: any): Promise | undefined> { + const result = await this.getAllByProperty(propertyName, propertyValue); + const resultCount : number = result?.length ?? 0; + if (resultCount === 0) return undefined; + if (resultCount >= 2) throw new TypeError(`MemoryRepository.findByProperty: Multiple items found by property "${propertyName}" as: ${propertyValue}`); + return result[0]; + } + + public async getAll (): Promise[]> { + const entries = await this._repository.findAllByEntityDeleted(false); + return this._createRepositoryEntryArrayFromEntityArray(entries); + } + + /** + * + * @param propertyName + * @param propertyValue + * @FIXME: This is really slow and requires better implementation + */ + public async getAllByProperty (propertyName: string, propertyValue: any): Promise[]> { + const items : SimpleEntity[] = await this._repository.findAllByEntityDeleted(false); + const filteredEntities = filter( + items, + (item: SimpleEntity) : boolean => get( SimpleEntity.parseData(item), propertyName) === propertyValue + ); + return this._createRepositoryEntryArrayFromEntityArray(filteredEntities); + } + + public async getSome (idList: readonly string[]): Promise[]> { + if (idList.length <= 0) throw new TypeError('getSome: The list argument was empty'); + const list : SimpleEntity[] = await this._repository.findAllById(idList); + return this._createRepositoryEntryArrayFromEntityArray(list); + } + + public async inviteToItem (id: string, members: readonly string[]): Promise { + const entity : SimpleEntityT | undefined = await this._repository.findById(id); + if (!entity) throw new TypeError(`Could not find entity by id: ${id}`); + const prevMembers = SimpleEntity.parseMembers(entity) ?? []; + const prevInvited = SimpleEntity.parseInvited(entity) ?? []; + const newInvited = filter( + uniq( + concat( + [], + prevInvited, + members + ) + ), + (item : string) => !prevMembers.includes(item) + ); + entity.entityVersion = SimpleEntity.parseNextVersion(entity); + entity.entityInvited = SimpleEntity.prepareInvited(newInvited); + const savedEntity = await this._repository.save(entity); + await this._createRepositoryEntryFromEntity(savedEntity); + } + + public isRepositoryEntryList (list: any): list is SimpleRepositoryEntry[] { + return SimpleRepositoryUtils.isRepositoryEntryList(list, this._isT); + } + + /** + * + * @param id + * @FIXME This will accept all received invites. Should we just accept our + * own? The implementation was copied from MemoryRepository. + */ + public async subscribeToItem (id: string): Promise { + const entity : SimpleEntityT | undefined = await this._repository.findById(id); + if (!entity) throw new TypeError(`Could not find entity by id: ${id}`); + const prevMembers = SimpleEntity.parseMembers(entity) ?? []; + const prevInvited = SimpleEntity.parseInvited(entity) ?? []; + const newMembers : string[] = concat(prevMembers, prevInvited); + const newInvited : string[] = []; + entity.entityVersion = SimpleEntity.parseNextVersion(entity); + entity.entityMembers = SimpleEntity.prepareMembers(newMembers); + entity.entityInvited = SimpleEntity.prepareInvited(newInvited); + const savedEntity = await this._repository.save(entity); + await this._createRepositoryEntryFromEntity(savedEntity); + } + + public async update (id: string, data: T): Promise> { + LOG.debug(`update: id=`, id, 'data=', data); + const entity : SimpleEntityT | undefined = await this._repository.findById(id); + if (!entity) throw new TypeError(`Could not find entity by id: ${id}`); + entity.entityVersion = SimpleEntity.parseNextVersion(entity); + entity.entityData = JSON.stringify(data); + const savedEntity = await this._repository.save(entity); + return this._createRepositoryEntryFromEntity(savedEntity); + } + + public async updateOrCreateItem (item: T): Promise> { + LOG.debug(`updateOrCreateItem: item = `, item); + const id = item.id; + LOG.debug(`updateOrCreateItem: id = `, id); + const foundItem : SimpleRepositoryEntry | undefined = id && id !== REPOSITORY_NEW_IDENTIFIER ? await this.findById(id) : undefined; + LOG.debug(`updateOrCreateItem: foundItem = `, foundItem); + if (foundItem) { + return await this.update(foundItem.id, item); + } else { + return await this.createItem(item); + } + } + + /** + * + * @param id + * @param includeMembers + * @param timeout + * @FIXME: Implement real long polling + */ + public async waitById (id: string, includeMembers?: boolean, timeout?: number): Promise | undefined> { + return new Promise((resolve, reject) => { + try { + setTimeout( + () => { + try { + resolve(this.findById(id, includeMembers)); + } catch (err) { + reject(err); + } + }, + timeout ?? 4000 + ); + } catch (err) { + reject(err); + } + }); + } + + private _createRepositoryEntryFromEntity ( + entity : SimpleEntity, + includeMembers ?: boolean | undefined + ) : SimpleRepositoryEntry { + const dto = SimpleEntity.toDTO(entity); + return createRepositoryEntry( + dto.data as unknown as T, + dto.id, + dto.version, + dto.deleted, + includeMembers && dto.members ? map(dto.members, (id: string) => createSimpleRepositoryMember(id)) : undefined + ); + } + + private _createRepositoryEntryArrayFromEntityArray (list: readonly SimpleEntity[]) : SimpleRepositoryEntry[] { + return map(list, (item: SimpleEntity) => this._createRepositoryEntryFromEntity(item)); + } + +} diff --git a/data/adapters/simple/SimpleRepositoryAdapterInitializer.ts b/data/adapters/simple/SimpleRepositoryAdapterInitializer.ts new file mode 100644 index 0000000..8d2756d --- /dev/null +++ b/data/adapters/simple/SimpleRepositoryAdapterInitializer.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { SimpleStoredRepositoryItem, StoredRepositoryItemExplainCallback, StoredRepositoryItemTestCallback } from "../../../simpleRepository/types/SimpleStoredRepositoryItem"; +import { SimpleRepository } from "../../../simpleRepository/types/SimpleRepository"; +import { SimpleRepositoryAdapter } from "./SimpleRepositoryAdapter"; +import { SimpleRepositoryInitializer } from "../../../simpleRepository/types/SimpleRepositoryInitializer"; +import { explainNot, explainOk } from "../../../types/explain"; +import { SimpleEntityRepository } from "./SimpleEntityRepository"; +import { SimpleEntity } from "./SimpleEntity"; +import { NewSimpleDTO } from "./NewSimpleDTO"; + +export class SimpleRepositoryAdapterInitializer< + T extends SimpleStoredRepositoryItem, + SimpleEntityT extends SimpleEntity +> implements SimpleRepositoryInitializer { + + private readonly _repository : SimpleEntityRepository; + private readonly _members : readonly string[] | undefined; + private readonly _isT : StoredRepositoryItemTestCallback; + private readonly _explainT : StoredRepositoryItemExplainCallback; + private readonly _tName : string; + private readonly _tCreate : (dto: NewSimpleDTO) => SimpleEntityT; + + public constructor ( + repository : SimpleEntityRepository, + tCreate : (dto: NewSimpleDTO) => SimpleEntityT, + isT : StoredRepositoryItemTestCallback, + tName : string, + explainT : StoredRepositoryItemExplainCallback, + members : readonly string[] | undefined + ) { + this._repository = repository; + this._members = members; + this._isT = isT; + this._tName = tName ?? 'T'; + this._tCreate = tCreate; + this._explainT = explainT ?? ( (value: any) : string => isT(value) ? explainOk() : explainNot(this._tName) ); + } + + public async initializeRepository () : Promise> { + return new SimpleRepositoryAdapter( + this._repository, + this._tCreate, + this._isT, + this._tName, + this._explainT, + this._members + ); + } + +} diff --git a/data/conditions/AfterCondition.ts b/data/conditions/AfterCondition.ts new file mode 100644 index 0000000..4958d73 --- /dev/null +++ b/data/conditions/AfterCondition.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Condition } from "./types/Condition"; +import { ConditionTarget } from "./types/ConditionTarget"; + +export class AfterCondition extends Condition { + + private readonly _value : T; + + protected constructor ( + target: ConditionTarget, + value: T + ) { + super(target); + this._value = value; + } + + public valueOf () { + return this.toString(); + } + + public toString () { + return `AfterCondition(${this.getConditionTarget()} is after ${this._value})`; + } + + public getValue () : T { + return this._value; + } + + public static create ( + target: ConditionTarget, + value: T + ) : AfterCondition { + return new AfterCondition(target, value); + } + +} + +export function isAfterCondition (value: unknown): value is AfterCondition { + return value instanceof AfterCondition; +} diff --git a/data/conditions/AndCondition.ts b/data/conditions/AndCondition.ts new file mode 100644 index 0000000..660e460 --- /dev/null +++ b/data/conditions/AndCondition.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Condition } from "./types/Condition"; +import { WhereConditionTarget } from "./types/WhereConditionTarget"; +import { Where } from "../Where"; + +export class AndCondition extends Condition { + + private readonly _where : Where; + + protected constructor ( + where: Where + ) { + super( WhereConditionTarget.create(where) ); + this._where = where; + } + + public valueOf () { + return this.toString(); + } + + public toString () { + return `AndCondition(${this._where.getConditions().map(item => item.toString()).join(' and ')})`; + } + + public getWhere () : Where { + return this._where; + } + + public static create ( + value: Where + ) : AndCondition { + return new AndCondition(value); + } + +} + +export function isAndCondition (value: unknown): value is AndCondition { + return value instanceof AndCondition; +} diff --git a/data/conditions/BeforeCondition.ts b/data/conditions/BeforeCondition.ts new file mode 100644 index 0000000..a0fb027 --- /dev/null +++ b/data/conditions/BeforeCondition.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Condition } from "./types/Condition"; +import { ConditionTarget } from "./types/ConditionTarget"; + +export class BeforeCondition extends Condition { + + private readonly _value : T; + + protected constructor ( + target: ConditionTarget, + value: T + ) { + super(target); + this._value = value; + } + + public valueOf () { + return this.toString(); + } + + public toString () { + return `BeforeCondition(${this.getConditionTarget()} is before ${this._value})`; + } + + public getValue () : T { + return this._value; + } + + public static create ( + target: ConditionTarget, + value: T + ) : BeforeCondition { + return new BeforeCondition(target, value); + } + +} + +export function isBeforeCondition (value: unknown): value is BeforeCondition { + return value instanceof BeforeCondition; +} diff --git a/data/conditions/BetweenCondition.ts b/data/conditions/BetweenCondition.ts new file mode 100644 index 0000000..aebb82a --- /dev/null +++ b/data/conditions/BetweenCondition.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Condition } from "./types/Condition"; +import { ConditionTarget } from "./types/ConditionTarget"; + +export class BetweenCondition extends Condition { + + private readonly _start : T; + private readonly _end : T; + + protected constructor ( + target: ConditionTarget, + start: T, + end: T + ) { + super(target); + this._start = start; + this._end = end; + } + + public valueOf () { + return this.toString(); + } + + public toString () { + return `BetweenCondition(${this.getConditionTarget()} is between ${this._start} and ${this._end})`; + } + + public getRangeStart () : T { + return this._start; + } + + public getRangeEnd () : T { + return this._end; + } + + public static create ( + target: ConditionTarget, + start: T, + end: T + ) : BetweenCondition { + return new BetweenCondition(target, start, end); + } + +} + +export function isBetweenCondition (value: unknown): value is BetweenCondition { + return value instanceof BetweenCondition; +} diff --git a/data/conditions/EqualCondition.ts b/data/conditions/EqualCondition.ts new file mode 100644 index 0000000..b3959b0 --- /dev/null +++ b/data/conditions/EqualCondition.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Condition } from "./types/Condition"; +import { ConditionTarget } from "./types/ConditionTarget"; + +export class EqualCondition extends Condition { + + private readonly _value : T; + + protected constructor ( + target: ConditionTarget, + value: T + ) { + super(target); + this._value = value; + } + + public valueOf () { + return this.toString(); + } + + public toString () { + return `EqualCondition(${this.getConditionTarget()} === ${this._value})`; + } + + public getValue () : T { + return this._value; + } + + public static create ( + target: ConditionTarget, + value: T + ) : EqualCondition { + return new EqualCondition(target, value); + } + +} + +export function isEqualCondition (value: unknown): value is EqualCondition { + return value instanceof EqualCondition; +} diff --git a/data/conditions/OrCondition.ts b/data/conditions/OrCondition.ts new file mode 100644 index 0000000..18c0b38 --- /dev/null +++ b/data/conditions/OrCondition.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Condition } from "./types/Condition"; +import { WhereConditionTarget } from "./types/WhereConditionTarget"; +import { Where } from "../Where"; + +export class OrCondition extends Condition { + + private readonly _where : Where; + + protected constructor ( + where: Where + ) { + super( WhereConditionTarget.create(where) ); + this._where = where; + } + + public valueOf () { + return this.toString(); + } + + public toString () { + return `OrCondition(${this._where.getConditions().map(item => item.toString()).join(' or ')})`; + } + + public getWhere () : Where { + return this._where; + } + + public static create ( + value: Where + ) : OrCondition { + return new OrCondition(value); + } + +} + +export function isOrCondition (value: unknown): value is OrCondition { + return value instanceof OrCondition; +} diff --git a/data/conditions/types/Condition.ts b/data/conditions/types/Condition.ts new file mode 100644 index 0000000..56568ce --- /dev/null +++ b/data/conditions/types/Condition.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ConditionTarget } from "./ConditionTarget"; + +export class Condition { + + private readonly _target : ConditionTarget; + + protected constructor ( + target: ConditionTarget + ) { + this._target = target; + } + + public valueOf () : string { + return this.toString(); + } + + public toString () : string { + return `Condition(${this._target})`; + } + + public getConditionTarget () : ConditionTarget { + return this._target + } + +} + +export function isCondition (value: unknown): value is Condition { + return value instanceof Condition; +} diff --git a/data/conditions/types/ConditionTarget.ts b/data/conditions/types/ConditionTarget.ts new file mode 100644 index 0000000..766503a --- /dev/null +++ b/data/conditions/types/ConditionTarget.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export interface ConditionTarget { + + valueOf() : string; + toString() : string; + +} + diff --git a/data/conditions/types/PropertyNameTarget.ts b/data/conditions/types/PropertyNameTarget.ts new file mode 100644 index 0000000..533f553 --- /dev/null +++ b/data/conditions/types/PropertyNameTarget.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ConditionTarget } from "./ConditionTarget"; + +export class PropertyNameTarget implements ConditionTarget { + + private readonly _name : string; + + protected constructor ( + name : string + ) { + this._name = name; + } + + public valueOf () { + return this.toString(); + } + + public toString () { + return `PropertyNameTarget(${this._name})`; + } + + public getPropertyName () : string { + return this._name; + } + + public static create ( + name: string + ) : PropertyNameTarget { + return new PropertyNameTarget(name); + } + +} + +export function isPropertyNameTarget (value: unknown): value is PropertyNameTarget { + return value instanceof PropertyNameTarget; +} diff --git a/data/conditions/types/WhereConditionTarget.ts b/data/conditions/types/WhereConditionTarget.ts new file mode 100644 index 0000000..3e098d5 --- /dev/null +++ b/data/conditions/types/WhereConditionTarget.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ConditionTarget } from "./ConditionTarget"; +import { Where } from "../../Where"; + +export class WhereConditionTarget implements ConditionTarget { + + private readonly _where : Where; + + protected constructor ( + where : Where + ) { + this._where = where; + } + + public valueOf () { + return this.toString(); + } + + public toString () { + return `WhereConditionTarget(${this._where})`; + } + + public getWhere () : Where { + return this._where; + } + + public static create ( + where: Where + ) : WhereConditionTarget { + return new WhereConditionTarget(where); + } + +} + +export function isWhereConditionTarget (value: unknown): value is WhereConditionTarget { + return value instanceof WhereConditionTarget; +} diff --git a/data/persisters/memory/MemoryPersister.test.ts b/data/persisters/memory/MemoryPersister.test.ts new file mode 100644 index 0000000..c537a23 --- /dev/null +++ b/data/persisters/memory/MemoryPersister.test.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MemoryPersister } from "./MemoryPersister"; +import { createEntityMetadata, EntityMetadata } from "../../types/EntityMetadata"; +import { createEntityField } from "../../types/EntityField"; +import { LogLevel } from "../../../types/LogLevel"; +import { Table } from "../../Table"; +import { Entity } from "../../Entity"; +import { Id } from "../../Id"; +import { Column } from "../../Column"; +import { Where } from "../../Where"; + +describe('MemoryPersister', () => { + + beforeAll(() => { + MemoryPersister.setLogLevel(LogLevel.NONE); + }); + + @Table('foos') + class FooEntity extends Entity { + + constructor (dto ?: {name: string}) { + super() + this.id = undefined; + this.name = dto?.name; + } + + @Id() + @Column('foo_id') + public id ?: string; + + @Column('foo_name') + public name ?: string; + + } + + + @Table('bars') + class BarEntity extends Entity { + + constructor (dto ?: {name: string}) { + super() + this.id = undefined; + this.name = dto?.name; + } + + @Id() + @Column('bar_id') + public id ?: string; + + @Column('bar_name') + public name ?: string; + + } + + describe('#constructor', () => { + it('can create a persister', () => { + expect( new MemoryPersister() ).toBeDefined(); + }); + }); + + describe('instance', () => { + + let persister : MemoryPersister; + let fooMetadata : EntityMetadata; + let barMetadata : EntityMetadata; + let barEntity1 : BarEntity; + let barEntity2 : BarEntity; + let barEntity3 : BarEntity; + + beforeEach(async () => { + persister = new MemoryPersister(); + fooMetadata = createEntityMetadata( + 'foos', + 'id', + [ + createEntityField('id', 'foo_id'), + createEntityField('name', 'foo_name') + ], + [], + [], + [], + (dto?: any) => new FooEntity(dto), + [], + [], + [] + ); + barMetadata = createEntityMetadata( + 'bars', + 'id', + [ + createEntityField('id', 'bar_id'), + createEntityField('name', 'bar_name') + ], + [], + [], + [], + (dto?: any) => new BarEntity(dto), + [], + [], + [] + ); + barEntity1 = await persister.insert( + barMetadata, + new BarEntity({name: 'Bar 123'}), + ); + barEntity2 = await persister.insert( + barMetadata, + new BarEntity({name: 'Bar 456'}), + ); + barEntity3 = await persister.insert( + barMetadata, + new BarEntity({name: 'Bar 789'}), + ); + }); + + describe('#count', () => { + + it('can count all items when there is none', async () => { + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + }); + it('can count all items when there is three', async () => { + expect( await persister.count(barMetadata, undefined) ).toBe(3); + }); + + it('can count items by property when there is none', async () => { + expect( await persister.count(fooMetadata, Where.propertyEquals('name', 'Foo 123')) ).toBe(0); + }); + + it('can count items by property when there is three', async () => { + expect( await persister.count(barMetadata, Where.propertyEquals('name', 'Bar 456')) ).toBe(1); + }); + + }); + + describe('#existsBy', () => { + it('can detect there is no matches', async () => { + expect( await persister.existsBy(fooMetadata, Where.propertyEquals('name', 'Bar 456')) ).toBe(false); + }); + it('can detect there is one match', async () => { + expect( await persister.existsBy(barMetadata, Where.propertyEquals('name', 'Bar 456')) ).toBe(true); + }); + }); + + describe('#deleteAll', () => { + + it('can delete all items when there is none', async () => { + await persister.deleteAll(fooMetadata, undefined); + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + expect( await persister.count(barMetadata, undefined) ).toBe(3); + }); + + it('can delete all items when there is three', async () => { + expect( await persister.count(barMetadata, undefined) ).toBe(3); + await persister.deleteAll(barMetadata, undefined); + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + expect( await persister.count(barMetadata, undefined) ).toBe(0); + }); + + it('can delete items by property when there is none', async () => { + expect(barEntity2.id).toBeDefined(); + await persister.deleteAll(fooMetadata, Where.propertyEquals(fooMetadata.idPropertyName, barEntity2.id as string)); + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + expect( await persister.count(barMetadata, undefined) ).toBe(3); + }); + + it('can delete items by property when there is three', async () => { + expect(barEntity2.id).toBeDefined(); + await persister.deleteAll(barMetadata, Where.propertyEquals(barMetadata.idPropertyName, barEntity2.id as string)); + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + expect( await persister.count(barMetadata, undefined) ).toBe(2); + }); + + it('can delete items by property list when there is none', async () => { + expect(barEntity2.id).toBeDefined(); + await persister.deleteAll(fooMetadata, Where.propertyListEquals(fooMetadata.idPropertyName, [barEntity2.id as string])); + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + expect( await persister.count(barMetadata, undefined) ).toBe(3); + }); + it('can delete items by property list when there is three', async () => { + expect(barEntity2.id).toBeDefined(); + await persister.deleteAll(barMetadata, Where.propertyListEquals(barMetadata.idPropertyName, [barEntity2.id as string])); + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + expect( await persister.count(barMetadata, undefined) ).toBe(2); + }); + + it('can delete items by named property when there is none', async () => { + expect(barEntity2.id).toBeDefined(); + await persister.deleteAll(fooMetadata, Where.propertyEquals('name', 'Bar 456')); + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + expect( await persister.count(barMetadata, undefined) ).toBe(3); + }); + + it('can delete items by named property when there is three', async () => { + expect(barEntity2.id).toBeDefined(); + await persister.deleteAll(barMetadata, Where.propertyEquals('name', 'Bar 456')); + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + expect( await persister.count(barMetadata, undefined) ).toBe(2); + }); + + }); + + describe('#findAll', () => { + + it('can detect there is no matches', async () => { + expect( await persister.findAll(fooMetadata, undefined, undefined) ).toStrictEqual([]); + }); + + it('can detect there is three matches', async () => { + const list : BarEntity[] = await persister.findAll(barMetadata, undefined, undefined); + expect(list?.length).toBe(3); + expect(list[0].id).toBe(barEntity1.id); + expect(list[1].id).toBe(barEntity2.id); + expect(list[2].id).toBe(barEntity3.id); + expect(list[0].name).toBe(barEntity1.name); + expect(list[1].name).toBe(barEntity2.name); + expect(list[2].name).toBe(barEntity3.name); + }); + + it('can find by id when there is no matches', async () => { + expect(barEntity2.id).toBeDefined(); + expect( await persister.findAll(fooMetadata, Where.propertyEquals(fooMetadata.idPropertyName, barEntity2.id as string), undefined) ).toStrictEqual([]); + }); + + it('can find by id when there is one match', async () => { + expect(barEntity2.id).toBeDefined(); + const list : BarEntity[] = await persister.findAll(barMetadata, Where.propertyEquals(barMetadata.idPropertyName, barEntity2.id as string), undefined); + expect(list?.length).toBe(1); + expect(list[0].id).toBe(barEntity2.id); + expect(list[0].name).toBe(barEntity2.name); + }); + + it('can find by property when there is no matches', async () => { + expect( await persister.findAll(fooMetadata, Where.propertyEquals('name', 'Bar 456'), undefined) ).toStrictEqual([]); + }); + + it('can find by property when detect there is one match', async () => { + const list : BarEntity[] = await persister.findAll(barMetadata, Where.propertyEquals('name', 'Bar 456'), undefined); + expect(list?.length).toBe(1); + expect(list[0].id).toBe(barEntity2.id); + expect(list[0].name).toBe(barEntity2.name); + }); + + }); + + describe('#findBy', () => { + + it('can find by id when there is no matches', async () => { + expect(barEntity2.id).toBeDefined(); + expect( await persister.findBy(fooMetadata, Where.propertyEquals(fooMetadata.idPropertyName, barEntity2.id as string), undefined) ).toBeUndefined(); + }); + + it('can find by id when there is one match', async () => { + expect(barEntity2.id).toBeDefined(); + const item : BarEntity | undefined = await persister.findBy(barMetadata, Where.propertyEquals(barMetadata.idPropertyName, barEntity2.id as string), undefined); + expect(item).toBeDefined(); + expect(item?.id).toBe(barEntity2.id); + expect(item?.name).toBe(barEntity2.name); + }); + + it('can find by property name when there is no matches', async () => { + expect( await persister.findBy(fooMetadata, Where.propertyEquals('name', 'Bar 456'), undefined) ).toBeUndefined(); + }); + + it('can find by property name there is one match', async () => { + const item : BarEntity | undefined = await persister.findBy(barMetadata, Where.propertyEquals('name', 'Bar 456'), undefined); + expect(item).toBeDefined(); + expect(item?.id).toBe(barEntity2.id); + expect(item?.name).toBe(barEntity2.name); + }); + + }); + + describe('#insert', () => { + it('can insert new item', async () => { + expect( await persister.count(fooMetadata, undefined) ).toBe(0); + const entity = await persister.insert(fooMetadata, new FooEntity({name: 'Hello world'})); + expect(entity).toBeDefined(); + expect(entity.name).toBe('Hello world'); + expect(entity.id).toBeDefined(); + expect( await persister.count(fooMetadata, undefined) ).toBe(1); + }); + }); + + describe('#update', () => { + + it('can update an item', async () => { + expect(barEntity2.id).toBeDefined(); + barEntity2.name = 'Hello world'; + const entity = await persister.update(barMetadata, barEntity2); + expect(entity).toBeDefined(); + expect(entity.name).toBe('Hello world'); + expect(entity.id).toBe(barEntity2.id); + expect( await persister.count(barMetadata, undefined) ).toBe(3); + }); + + it('cannot update an item without update call', async () => { + expect(barEntity2.id).toBeDefined(); + barEntity2.name = 'Hello world'; + const entity : BarEntity | undefined = await persister.findBy( barMetadata, Where.propertyEquals(barMetadata.idPropertyName, barEntity2.id as string), undefined ); + expect(entity).toBeDefined(); + expect(entity?.name).toBe('Bar 456'); + expect(entity?.id).toBe(barEntity2.id); + }); + + + }); + + }); + +}); diff --git a/data/persisters/memory/MemoryPersister.ts b/data/persisters/memory/MemoryPersister.ts new file mode 100644 index 0000000..ed7e34b --- /dev/null +++ b/data/persisters/memory/MemoryPersister.ts @@ -0,0 +1,854 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Persister } from "../../types/Persister"; +import { Entity } from "../../Entity"; +import { EntityMetadata } from "../../types/EntityMetadata"; +import { first } from "../../../functions/first"; +import { isArray } from "../../../types/Array"; +import { has } from "../../../functions/has"; +import { filter } from "../../../functions/filter"; +import { some } from "../../../functions/some"; +import { map } from "../../../functions/map"; +import { find } from "../../../functions/find"; +import { forEach } from "../../../functions/forEach"; +import { EntityRelationOneToMany } from "../../types/EntityRelationOneToMany"; +import { PersisterMetadataManager } from "../types/PersisterMetadataManager"; +import { PersisterMetadataManagerImpl } from "../types/PersisterMetadataManagerImpl"; +import { LogLevel } from "../../../types/LogLevel"; +import { LogService } from "../../../LogService"; +import { EntityUtils } from "../../utils/EntityUtils"; +import { EntityField } from "../../types/EntityField"; +import { EntityRelationManyToOne } from "../../types/EntityRelationManyToOne"; +import { Sort } from "../../Sort"; +import { Where } from "../../Where"; +import { createMemoryItem, MemoryItem } from "./types/MemoryItem"; +import { createMemoryTable } from "./types/MemoryTable"; +import { MemoryIdType } from "./types/MemoryIdType"; +import { MemoryValueUtils } from "./utils/MemoryValueUtils"; +import { PersisterType } from "../types/PersisterType"; +import { PersisterEntityManager } from "../types/PersisterEntityManager"; +import { PersisterEntityManagerImpl } from "../types/PersisterEntityManagerImpl"; +import { cloneMemoryDatabase, MemoryDatabase } from "./types/MemoryDatabase"; +import { EntityCallbackUtils } from "../../utils/EntityCallbackUtils"; +import { EntityCallbackType } from "../../types/EntityCallbackType"; + +const LOG = LogService.createLogger('MemoryPersister'); + +/** + * Internal ID sequencer for memory items + */ +let ID_SEQUENCER = 0; + +/** + * This persister stores everything in the process memory. It is useful for + * development purposes. + * + * @see {@link Persister} + */ +export class MemoryPersister implements Persister { + + /** + * Set log level + * @param level + */ + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _idType : MemoryIdType; + private _data : MemoryDatabase; + private readonly _metadataManager : PersisterMetadataManager; + private readonly _entityManager : PersisterEntityManager; + + /** + * + * @param idType + * @FIXME: The `idType` should probably be detected from metadata and changeable through annotations + */ + constructor ( + idType ?: MemoryIdType + ) { + this._data = {}; + this._idType = idType ?? MemoryIdType.STRING; + this._metadataManager = new PersisterMetadataManagerImpl(); + this._entityManager = PersisterEntityManagerImpl.create(); + } + + /** + * @inheritDoc + * @see {@link Persister.getPersisterType} + */ + public getPersisterType (): PersisterType { + return PersisterType.MEMORY; + } + + /** + * @inheritDoc + * @see {@link Persister.destroy} + */ + public destroy (): void { + } + + /** + * @inheritDoc + * @see {@link Persister.setupEntityMetadata} + * @see {@link PersisterMetadataManager.setupEntityMetadata} + */ + public setupEntityMetadata (metadata: EntityMetadata) : void { + this._metadataManager.setupEntityMetadata(metadata); + } + + /** + * @inheritDoc + * @see {@link Persister.count} + */ + public async count ( + metadata : EntityMetadata, + where : Where | undefined, + ): Promise { + return await this._transaction( async (db: MemoryDatabase) : Promise => { + return this._count(db, metadata, where); + }); + } + + /** + * @inheritDoc + * @see {@link Persister.existsBy} + */ + public async existsBy ( + metadata : EntityMetadata, + where : Where, + ): Promise { + return await this._transaction( async (db: MemoryDatabase) : Promise => { + return this._existsBy(db, metadata, where); + }); + } + + /** + * @inheritDoc + * @see {@link Persister.deleteAll} + */ + public async deleteAll ( + metadata : EntityMetadata, + where : Where | undefined, + ): Promise { + return await this._transaction( async (db: MemoryDatabase) : Promise => { + return await this._deleteAll(db, metadata, where); + }); + } + + /** + * @inheritDoc + * @see {@link Persister.findAll} + */ + public async findAll ( + metadata : EntityMetadata, + where : Where | undefined, + sort : Sort | undefined + ): Promise { + return await this._transaction( async (db: MemoryDatabase) : Promise => { + return await this._findAll(db, metadata, where, sort); + }); + } + + /** + * @inheritDoc + * @see {@link Persister.findBy} + */ + public async findBy ( + metadata : EntityMetadata, + where : Where, + sort : Sort | undefined + ): Promise { + return await this._transaction( async (db: MemoryDatabase) : Promise => { + return await this._findBy(db, metadata, where, sort); + }); + } + + /** + * @inheritDoc + * @see {@link Persister.insert} + */ + public async insert ( + metadata: EntityMetadata, + entity: T | readonly T[], + ): Promise { + return await this._transaction( async (db: MemoryDatabase) : Promise => { + return await this._insert( db, metadata, entity ); + }); + } + + /** + * @inheritDoc + * @see {@link Persister.update} + */ + public async update ( + metadata: EntityMetadata, + entity: T, + ): Promise { + return await this._transaction( async (db: MemoryDatabase) : Promise => { + return await this._update(db, metadata, entity); + }); + } + + private async _transaction ( + callback: (db: MemoryDatabase) => Promise | T + ) : Promise { + let ret : any = undefined; + // FIXME: This is ugly but works for now. CoW-approach would be faster. + const backup = cloneMemoryDatabase(this._data); + try { + ret = await callback(backup); + this._data = backup; + } catch (err) { + throw err; + } + return ret; + } + + /** + * @inheritDoc + * @see {@link Persister.deleteAll} + */ + private async _deleteAll ( + db : MemoryDatabase, + metadata : EntityMetadata, + where : Where | undefined, + ): Promise { + let entities : T[] = []; + + const { + tableName, + // fields, + // temporalProperties, + callbacks, + idPropertyName + } = metadata; + + if (!has(db, tableName)) return; + + const hasPreRemoveCallbacks = EntityCallbackUtils.hasCallbacks(callbacks, EntityCallbackType.PRE_REMOVE); + const hasPostRemoveCallbacks = EntityCallbackUtils.hasCallbacks(callbacks, EntityCallbackType.POST_REMOVE); + + if (hasPreRemoveCallbacks || hasPostRemoveCallbacks) { + entities = await this._findAll(db, metadata, where, undefined); + if ( hasPreRemoveCallbacks && entities?.length ) { + await EntityCallbackUtils.runPreRemoveCallbacks( + entities, + callbacks + ); + } + } + + if ( !hasPreRemoveCallbacks && !hasPostRemoveCallbacks ) { + + const matcher = where !== undefined ? MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd( where ) : undefined; + if ( matcher !== undefined ) { + db[tableName].items = filter( + db[tableName].items, + (item: MemoryItem): boolean => !matcher( item.value ) + ); + return; + } else { + delete db[tableName]; + } + + } else { + + const entityIds = map(entities, item => (item as any)[idPropertyName]); + + db[tableName].items = filter( + db[tableName].items, + (item: MemoryItem): boolean => !entityIds.includes(item.id) + ); + + if (hasPostRemoveCallbacks) { + await EntityCallbackUtils.runPostRemoveCallbacks( + entities, + callbacks + ); + } + + } + + } + + private async _findAll ( + db : MemoryDatabase, + metadata : EntityMetadata, + where : Where | undefined, + sort : Sort | undefined + ): Promise { + LOG.debug(`findAll: `, metadata, where, sort); + const {tableName, callbacks} = metadata; + if (!has(db, tableName)) return []; + const matcher = where !== undefined ? MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where) : undefined; + const allItems = db[tableName].items; + const matchedItems = matcher !== undefined ? filter(allItems, (item: MemoryItem) : boolean => matcher(item.value)) : allItems; + const items : T[] = this._prepareItemList(matchedItems, metadata, true, sort); + const ret : T[] = this._populateRelationsToList(db, items, metadata); + LOG.debug(`findAll: returns: items 2: ${ret.length}`); + this._entityManager.saveLastEntityListState(ret); + + if (ret?.length) { + await EntityCallbackUtils.runPostLoadCallbacks( + ret, + callbacks + ); + } + + return ret; + } + + private async _findBy ( + db : MemoryDatabase, + metadata : EntityMetadata, + where : Where, + sort : Sort | undefined + ): Promise { + const {tableName, callbacks} = metadata; + if (!has(db, tableName)) return undefined; + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + const allItems = db[tableName].items; + const matchedItems = matcher !== undefined ? filter(allItems, (item: MemoryItem) : boolean => matcher(item.value)) : allItems; + const items : T[] = this._prepareItemList(matchedItems, metadata, true, sort); + const item : T | undefined = first(items); + if (item === undefined) return undefined; + const entity = this._populateRelations( db, item, metadata ); + this._entityManager.saveLastEntityState(entity); + if (entity) { + await EntityCallbackUtils.runPostLoadCallbacks( + [entity], + callbacks + ); + } + return entity; + } + + /** + * @inheritDoc + * @see {@link Persister.insert} + */ + private async _insert ( + db : MemoryDatabase, + metadata: EntityMetadata, + entity: T | readonly T[], + ): Promise { + + const list = map( + isArray(entity) ? entity : [entity], + (item : T) : T => item.clone() as T + ); + + const { tableName, idPropertyName, callbacks, creationTimestamps, updateTimestamps } = metadata; + + await EntityCallbackUtils.runPrePersistCallbacks( + list, + callbacks + ); + + if (!has(db, tableName)) { + db[tableName] = createMemoryTable(); + } + const allIds = map(db[tableName].items, (item) => item.id); + + const now = (new Date()).toISOString(); + + const newItems : MemoryItem[] = map( + list, + (item: T) : MemoryItem => { + + // Prepare the ID field + if ( !( has(item, idPropertyName) && (item as any)[idPropertyName]) ) { + const newId : number = ++ID_SEQUENCER; + (item as any)[idPropertyName] = this._idType === MemoryIdType.STRING ? `${newId}` : newId; + } + + const id = (item as any)[idPropertyName]; + if (!id) { + throw new TypeError(`Entity cannot be saved with id as "${id}"`); + } + if (allIds.includes(id)) { + throw new TypeError(`Entity already stored with id "${id}"`); + } + allIds.push(id); + + // Prepare the creation timestamp + forEach( + creationTimestamps, + (propertyName) : void => { + (item as any)[propertyName] = now; + } + ); + + // Prepare the update timestamp + forEach( + updateTimestamps, + (propertyName) : void => { + (item as any)[propertyName] = now; + } + ); + + return createMemoryItem(id, item); + } + ); + + // Let's call this outside above loop for better error management + forEach( + newItems, + (item) => { + db[tableName].items.push(item); + } + ); + + // FIXME: We should return more than one if there were more than one + const firstItem = first(newItems); + if (!firstItem) throw new TypeError(`Could not add items`); + const addedEntity = this._populateRelations(db, this._prepareItem(firstItem, metadata, true), metadata); + this._entityManager.saveLastEntityState(addedEntity); + + await EntityCallbackUtils.runPostLoadCallbacks( + [addedEntity], + callbacks + ); + + // FIXME: Only single item is returned even if multiple are added {@see https://github.com/heusalagroup/fi.hg.core/issues/72} + await EntityCallbackUtils.runPostPersistCallbacks( + [addedEntity], + callbacks + ); + + return addedEntity; + } + + /** + * @inheritDoc + * @see {@link Persister.update} + */ + private async _update ( + db : MemoryDatabase, + metadata : EntityMetadata, + entity : T, + ): Promise { + + const { tableName, fields, idPropertyName, callbacks, updateTimestamps } = metadata; + + const idField = find(fields, item => item.propertyName === idPropertyName); + if (!idField) throw new TypeError(`Could not find id field using property "${idPropertyName}"`); + const entityId = has(entity,idPropertyName) ? (entity as any)[idPropertyName] : undefined; + if (!entityId) throw new TypeError(`Could not find entity id column using property "${idPropertyName}"`); + + const updateFields = this._entityManager.getChangedFields( + entity, + fields + ); + + if (updateFields.length === 0) { + LOG.debug(`Entity did not any updatable properties changed. Saved nothing.`); + const item : T | undefined = await this._findBy( + db, + metadata, + Where.propertyEquals(idPropertyName, entityId), + Sort.by(idPropertyName) + ); + if (!item) { + throw new TypeError(`Entity was not stored in this persister for ID: ${entityId}`); + } + + await EntityCallbackUtils.runPostUpdateCallbacks( + [item], + callbacks + ); + + return item; + } + + entity = entity.clone() as T; + + await EntityCallbackUtils.runPreUpdateCallbacks( + [entity], + callbacks + ); + + // Prepare the update timestamp + if (updateTimestamps?.length) { + const now = (new Date()).toISOString(); + forEach( + updateTimestamps, + (propertyName) : void => { + (entity as any)[propertyName] = now; + } + ); + } + + if (!has(db, tableName)) { + db[tableName] = createMemoryTable(); + } + + let savedItem : MemoryItem | undefined = find( + db[tableName].items, + (item: MemoryItem) : boolean => item.id === entityId + ); + if (savedItem) { + const savedValue = savedItem.value.clone() as T; + forEach( + updateFields, + (field: EntityField) : void => { + const { propertyName } = field; + (savedValue as any)[propertyName] = (entity as any)[propertyName]; + } + ); + savedItem.value = savedValue; + } else { + savedItem = createMemoryItem(entityId, entity); + db[tableName].items.push( savedItem ); + } + + const savedEntity : Entity = this._populateRelations(db, savedItem.value.clone(), metadata); + this._entityManager.saveLastEntityState(savedEntity); + + await EntityCallbackUtils.runPostLoadCallbacks( + [savedEntity], + callbacks + ); + + await EntityCallbackUtils.runPostUpdateCallbacks( + [savedEntity], + callbacks + ); + + return savedEntity as unknown as T; + } + + private _count ( + db : MemoryDatabase, + metadata : EntityMetadata, + where : Where | undefined, + ): number { + const tableName = metadata.tableName; + if (!has(db, tableName)) return 0; + const matcher = where !== undefined ? MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where) : undefined; + if (matcher) { + return filter( + db[tableName].items, + (item: MemoryItem) : boolean => matcher(item.value) + ).length; + } + return db[tableName].items.length; + } + + private _existsBy ( + db : MemoryDatabase, + metadata : EntityMetadata, + where : Where, + ): boolean { + const tableName = metadata.tableName; + if (!has(db, tableName)) return false; + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + return some( + db[tableName].items, + (item: MemoryItem) : boolean => matcher(item.value) + ); + } + + + /** + * Find previously saved memory item from internal memory. + * + * @param db The database + * @param callback The match callback + * @param tableName The table to use for + * @returns The item if found, otherwise `undefined` + * @private + */ + private _findItem ( + db : MemoryDatabase, + callback: (item: MemoryItem) => boolean, + tableName: string + ) : MemoryItem | undefined { + if (!has(db, tableName)) return undefined; + const item = find(db[tableName].items, callback); + if (!item) return undefined; + return item; + } + + /** + * Filters memory items based on the callback result + * + * @param db The database + * @param callback The test callback + * @param tableName The table to use + * @returns The filtered items + * @private + */ + private _filterItems ( + db : MemoryDatabase, + callback : (item: MemoryItem) => boolean, + tableName : string + ): MemoryItem[] { + if (!has(db, tableName)) return []; + return filter(db[tableName].items, callback); + } + + /** + * Returns cloned entities, save to pass outside. + * + * @param items + * @param metadata + * @param simplify If true, any external relations will be nullified. + * @param sort + * @private + */ + private _prepareItemList ( + items : readonly MemoryItem[], + metadata : EntityMetadata, + simplify : boolean, + sort : Sort | undefined + ) : T[] { + const sortFunction = sort ? sort.getSortFunction() : undefined; + const list = map(items, (item: MemoryItem) : T => this._prepareItem(item, metadata, simplify)); + if (sortFunction) { + list.sort(sortFunction); + } + return list; + } + + /** + * Returns the cloned entity, save to pass outside. + * + * This will also populate relate linked resources. + * + * @param item The item to clone + * @param metadata + * @param simplify If true, any external relations will be nullified. + * @private + */ + private _prepareItem ( + item: MemoryItem, + metadata: EntityMetadata, + simplify: boolean + ) : T { + if (simplify) { + return EntityUtils.removeEntityRelations(item.value as T, metadata); + } + return item.value.clone() as T; + } + + /** + * Populates relations to complete list of entities + */ + private _populateRelationsToList ( + db : MemoryDatabase, + list: readonly T[], + metadata: EntityMetadata + ) : T[] { + return map( + list, + (item) => this._populateRelations(db, item, metadata) + ); + } + + /** + * Returns the cloned entity, save to pass outside. + * + * This will also populate relate linked resources. + * + * @param db + * @param entity The item to populate. + * @param metadata + * @private + */ + private _populateRelations ( + db : MemoryDatabase, + entity: T, + metadata: EntityMetadata + ) : T { + entity = entity.clone() as T; + LOG.debug(`_populateRelations: entity = `, entity); + entity = this._populateOneToManyRelations(db, entity, metadata); + LOG.debug(`_populateRelations: oneToMany: `, entity); + entity = this._populateManyToOneRelations(db, entity, metadata); + LOG.debug(`_populateRelations: returns: `, entity); + return entity; + } + + /** + * Returns the cloned entity, save to pass outside. + * + * This will also populate relate linked resources. + * + * @param entity The item to populate. Note! We don't clone this! + * @param metadata + * @param db + * @private + */ + private _populateOneToManyRelations ( + db : MemoryDatabase, + entity: T, + metadata: EntityMetadata + ) : T { + const tableName = metadata.tableName; + const idPropertyName = metadata.idPropertyName; + const entityId : string | number | undefined = has(entity, idPropertyName) ? (entity as any)[idPropertyName] as string|number : undefined; + LOG.debug(`_populateOneToManyRelations: 0. entityId = `, entityId, entity, idPropertyName, tableName); + const oneToManyRelations = metadata?.oneToManyRelations; + + if (oneToManyRelations?.length) { + forEach( + oneToManyRelations, + (oneToMany: EntityRelationOneToMany) => { + + let { propertyName, mappedBy, mappedTable } = oneToMany; + LOG.debug(`_populateOneToManyRelations: "${propertyName}": 1. oneToMany = `, mappedBy, mappedTable); + + if ( !(mappedTable && mappedBy) ) { + throw new TypeError( `No link to table exists to populate property "${propertyName}" in table "${tableName}"` ); + } + + const manyToOneMetadata = this._metadataManager.getMetadataByTable( mappedTable ); + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 2. mappedToMetadata = `, manyToOneMetadata ); + if ( !manyToOneMetadata ) { + throw new TypeError( `Could not find metadata for linked table "${mappedTable} to populate property "${propertyName}" in table "${tableName}"` ); + } + + const manyToOneFieldInfo: EntityField | undefined = find( manyToOneMetadata.fields, (field: EntityField): boolean => field.propertyName === mappedBy ); + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 3. joinColumn = `, manyToOneFieldInfo ); + if ( !manyToOneFieldInfo ) { + LOG.debug( `_populateOneToManyRelations: "${propertyName}": No field definition found for matching ManyToOne relation` ); + return; + } + + const joinColumnName = manyToOneFieldInfo.columnName; + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 4. joinColumnName = `, joinColumnName, metadata.fields ); + const joinPropertyName = EntityUtils.getPropertyName( joinColumnName, manyToOneMetadata.fields ); + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 5. joinPropertyName = `, joinPropertyName ); + + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 6. Searching related items for column name "${joinColumnName}" and inner property "${joinPropertyName}" mapped to table "${mappedTable}" by id "${entityId}"` ); + const linkedEntities: MemoryItem[] = this._filterItems( + db, + (relatedItem: MemoryItem): boolean => { + const relatedEntity = relatedItem.value; + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 7. relatedEntity = `, relatedEntity ); + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 7. joinPropertyName = `, joinPropertyName ); + const innerId: string | number | undefined = has( relatedEntity, joinPropertyName ) ? (relatedEntity as any)[joinPropertyName] : undefined; + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 10. innerId = `, innerId ); + LOG.debug( `_populateOneToManyRelations: "${propertyName}": 10. entityId = `, entityId ); + return !!innerId && innerId === entityId; + }, + mappedTable + ); + LOG.debug( `_populateOneToManyRelations: "${propertyName}": linkedEntities = `, linkedEntities ); + const preparedEntities = this._prepareItemList( + linkedEntities, + manyToOneMetadata, + true, + undefined + ); + LOG.debug( `_populateOneToManyRelations: "${propertyName}": PREPARED: linkedEntities = `, linkedEntities ); + (entity as any)[propertyName] = preparedEntities; + } + ); + } + + return entity; + } + + /** + * Returns the cloned entity, save to pass outside. + * + * This will also populate relate linked resources. + * + * @param entity The item to populate. Note! We don't clone this! + * @param metadata + * @param db + * @private + */ + private _populateManyToOneRelations ( + db : MemoryDatabase, + entity: T, + metadata: EntityMetadata + ) : T { + const tableName = metadata.tableName; + const manyToOneRelations = metadata?.manyToOneRelations; + + LOG.debug(`ManyToOneRelations: 0. tableName = `, tableName, manyToOneRelations); + + if (manyToOneRelations?.length) { + forEach( + manyToOneRelations, + (manyToOne: EntityRelationManyToOne) => { + + let { propertyName, mappedTable } = manyToOne; + LOG.debug(`ManyToOneRelations: 1. propertyName = `, propertyName, mappedTable); + + const joinColumn : EntityField | undefined = find(metadata.fields, (field: EntityField) : boolean => field.propertyName === propertyName); + LOG.debug(`ManyToOneRelations: 2. joinColumn = `, joinColumn); + if (joinColumn) { + + const joinColumnName = joinColumn.columnName; + LOG.debug(`ManyToOneRelations: 3. joinColumnName = `, joinColumnName, metadata.fields); + + if ( !mappedTable ) { + throw new TypeError(`No link to table exists to populate property "${propertyName}" in table "${tableName}"`); + } + + const mappedToMetadata = this._metadataManager.getMetadataByTable(mappedTable); + LOG.debug(`ManyToOneRelations: 4. mappedToMetadata = `, mappedToMetadata); + if ( !mappedToMetadata ) { + throw new TypeError(`Could not find metadata for linked table "${mappedTable} to populate property "${propertyName}" in table "${tableName}"`); + } + + const joinPropertyName = EntityUtils.getPropertyName(joinColumnName, metadata.fields); + LOG.debug(`ManyToOneRelations: 5. joinPropertyName = `, joinPropertyName); + LOG.debug(`ManyToOneRelations: 5. entity = `, entity); + + const mappedId : string = has(entity, joinPropertyName) ? (entity as any)[joinPropertyName] : undefined; + if ( !mappedId ) throw new TypeError(`Could not find related entity id ("${joinPropertyName}" from "${joinColumnName}") by property "${propertyName}"`); + LOG.debug(`ManyToOneRelations: 5. mappedId = `, mappedId); + + const relatedMemoryItem : MemoryItem | undefined = find(db[mappedTable].items, (item: MemoryItem) : boolean => item.id === mappedId); + if ( !relatedMemoryItem ) { + (entity as any)[propertyName] = undefined; + return; + } + + LOG.debug(`ManyToOneRelations: 5. relatedMemoryItem = `, relatedMemoryItem); + + const relatedEntity = relatedMemoryItem.value; + LOG.debug(`ManyToOneRelations: 5. relatedEntity = `, relatedEntity); + if ( !relatedEntity ) throw new TypeError(`Could not find related entity by property "${propertyName}"`); + + LOG.debug(`ManyToOneRelations: 6. Entity = `, entity); + LOG.debug(`ManyToOneRelations: 6. Related Entity = `, relatedMemoryItem); + + // const relatedId : string | undefined = has(relatedEntity, joinPropertyName) ? (relatedEntity as any)[joinPropertyName] : undefined; + // LOG.debug(`ManyToOneRelations: 7. relatedId = `, relatedId); + // if ( !relatedId ) throw new TypeError(`Could not find related entity id by property "${joinPropertyName}"`); + + // const relatedTableName = mappedToMetadata.tableName; + LOG.debug(`ManyToOneRelations: 8. Searching for #${mappedId} from tableq ${mappedTable}`); + const storedRelatedItem : MemoryItem | undefined = this._findItem( + db, + (item: MemoryItem) : boolean => item.id === mappedId, + mappedTable + ); + LOG.debug(`ManyToOneRelations: 9. storedRelatedItem = `, storedRelatedItem); + if (!storedRelatedItem) throw new TypeError(`Could not find related entity by id "${mappedId}" from table "${mappedTable}"`); + + const preparedItem = this._prepareItem( + storedRelatedItem, + mappedToMetadata, + true + ); + LOG.debug(`ManyToOneRelations: 1. PREPARED = `, preparedItem); + (entity as any)[propertyName] = preparedItem; + + } + } + ); + } + + return entity; + } + +} diff --git a/data/persisters/memory/MemoryRepositoryIntegration.test.ts b/data/persisters/memory/MemoryRepositoryIntegration.test.ts new file mode 100644 index 0000000..6149e00 --- /dev/null +++ b/data/persisters/memory/MemoryRepositoryIntegration.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../../../testing/jest/matchers/index"; +import { RepositoryUtils } from "../../utils/RepositoryUtils"; +import { LogLevel } from "../../../types/LogLevel"; +import { CrudRepositoryImpl } from "../../types/CrudRepositoryImpl"; +import { MemoryPersister } from "./MemoryPersister"; +import { allRepositoryTests } from "../../tests/allRepositoryTests"; +import { setCrudRepositoryLogLevel } from "../../types/CrudRepository"; +import { PersisterMetadataManagerImpl } from "../types/PersisterMetadataManagerImpl"; +import { PersisterType } from "../types/PersisterType"; + +describe('Repository integrations', () => { + + beforeAll(() => { + RepositoryUtils.setLogLevel(LogLevel.NONE); + setCrudRepositoryLogLevel(LogLevel.NONE); + CrudRepositoryImpl.setLogLevel(LogLevel.NONE); + PersisterMetadataManagerImpl.setLogLevel(LogLevel.NONE); + MemoryPersister.setLogLevel(LogLevel.NONE); + }); + + describe('Memory-based', () => { + allRepositoryTests( + PersisterType.MEMORY, + () => new MemoryPersister() + ); + }); + +}); diff --git a/data/persisters/memory/types/MemoryDatabase.ts b/data/persisters/memory/types/MemoryDatabase.ts new file mode 100644 index 0000000..d5b7d2e --- /dev/null +++ b/data/persisters/memory/types/MemoryDatabase.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { cloneMemoryTable, MemoryTable } from "./MemoryTable"; +import { keys } from "../../../../functions/keys"; +import { reduce } from "../../../../functions/reduce"; + +export interface MemoryDatabase { + + [tableName: string] : MemoryTable + +} + +export function createMemoryDatabase (): MemoryDatabase { + return {}; +} + +export function cloneMemoryDatabase ( + database: MemoryDatabase +) : MemoryDatabase { + const tables = keys(database); + return reduce( + tables, + (db: MemoryDatabase, tableName: string) : MemoryDatabase => { + db[tableName] = cloneMemoryTable(database[tableName]); + return db; + }, + createMemoryDatabase() + ); +} diff --git a/data/persisters/memory/types/MemoryIdType.ts b/data/persisters/memory/types/MemoryIdType.ts new file mode 100644 index 0000000..f2a2257 --- /dev/null +++ b/data/persisters/memory/types/MemoryIdType.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export enum MemoryIdType { + STRING = "STRING", + NUMBER = "NUMBER", +} diff --git a/data/persisters/memory/types/MemoryItem.ts b/data/persisters/memory/types/MemoryItem.ts new file mode 100644 index 0000000..88f3e70 --- /dev/null +++ b/data/persisters/memory/types/MemoryItem.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Entity } from "../../../Entity"; + +export interface MemoryItem { + readonly id: string | number; + value: Entity; +} + +export function createMemoryItem ( + id: string | number, + value: Entity +): MemoryItem { + return { + id, + value + }; +} + +export function cloneMemoryItem ( + item: MemoryItem +) : MemoryItem { + const { id, value } = item; + return { + id, + value: value.clone() + }; +} diff --git a/data/persisters/memory/types/MemoryTable.ts b/data/persisters/memory/types/MemoryTable.ts new file mode 100644 index 0000000..01bbe2e --- /dev/null +++ b/data/persisters/memory/types/MemoryTable.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { cloneMemoryItem, MemoryItem } from "./MemoryItem"; +import { map } from "../../../../functions/map"; + +export interface MemoryTable { + + items: MemoryItem[]; + +} + +export function createMemoryTable ( + items ?: MemoryItem[] +): MemoryTable { + return { + items: items ?? [] + }; +} + +export function cloneMemoryTable ( + table: MemoryTable +) : MemoryTable { + const { items } = table; + return { + items: map(items, item => cloneMemoryItem(item)) + }; +} diff --git a/data/persisters/memory/utils/MemoryValueUtils.test.ts b/data/persisters/memory/utils/MemoryValueUtils.test.ts new file mode 100644 index 0000000..5f6385d --- /dev/null +++ b/data/persisters/memory/utils/MemoryValueUtils.test.ts @@ -0,0 +1,494 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MemoryValueTestCallback, MemoryValueUtils } from "./MemoryValueUtils"; +import { Where } from "../../../Where"; +import { PropertyNameTarget } from "../../../conditions/types/PropertyNameTarget"; +import { BetweenCondition } from "../../../conditions/BetweenCondition"; +import { EqualCondition } from "../../../conditions/EqualCondition"; + +describe('MemoryValueUtils', () => { + + describe('#buildMatcherFunctionFromWhereUsingAnd', () => { + + it('builds a matcher function from a Where instance with equal conditions', () => { + const where = Where.propertyEquals('city', 'New York'); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMatching = { city: 'New York', age: 25 }; + const itemNotMatching = { city: 'Los Angeles', age: 25 }; + + expect(matcher(itemMatching)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + it('builds a matcher function from a Where instance with between conditions', () => { + const where = Where.propertyBetween('age', 18, 30); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMatching = { city: 'New York', age: 25 }; + const itemNotMatching = { city: 'New York', age: 35 }; + + expect(matcher(itemMatching)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + it('builds a matcher function from a Where instance with multiple conditions', () => { + const where = Where.and( + Where.propertyEquals('city', 'New York'), + Where.propertyBetween('age', 18, 30) + ); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMatching = { city: 'New York', age: 25 }; + const itemNotMatchingAge = { city: 'New York', age: 35 }; + const itemNotMatchingCity = { city: 'Los Angeles', age: 25 }; + + expect(matcher(itemMatching)).toBe(true); + expect(matcher(itemNotMatchingAge)).toBe(false); + expect(matcher(itemNotMatchingCity)).toBe(false); + }); + + it('throws an error when an unsupported condition target is used', () => { + const unsupportedTarget = {}; // Assuming this is not a PropertyNameTarget + const where = Where.fromConditionList([{ getConditionTarget: () => unsupportedTarget }] as any ); + expect(() => { + MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + }).toThrow(TypeError); + }); + + it('throws an error when an unsupported condition is used', () => { + const unsupportedCondition = {}; // Assuming this is not a supported condition + const propertyNameTarget = PropertyNameTarget.create('city'); + const where = Where.fromConditionList([{ getConditionTarget: () => propertyNameTarget, ...unsupportedCondition }] as any); + expect(() => { + MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + }).toThrow(TypeError); + }); + + it('builds a matcher function from a Where instance with propertyListEquals conditions', () => { + const values = ['New York', 'Los Angeles', 'Chicago']; + const where = Where.propertyListEquals('city', values); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMatching = { city: 'New York', age: 25 }; + const itemNotMatching = { city: 'San Francisco', age: 25 }; + + expect(matcher(itemMatching)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + it('builds a matcher function from a Where instance with multiple conditions, including propertyListEquals', () => { + const values = ['New York', 'Los Angeles', 'Chicago']; + const where = Where.and( + Where.propertyListEquals('city', values), + Where.propertyBetween('age', 18, 30) + ); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMatching = { city: 'New York', age: 25 }; + const itemNotMatchingAge = { city: 'New York', age: 35 }; + const itemNotMatchingCity = { city: 'San Francisco', age: 25 }; + + expect(matcher(itemMatching)).toBe(true); + expect(matcher(itemNotMatchingAge)).toBe(false); + expect(matcher(itemNotMatchingCity)).toBe(false); + }); + + it('returns false for items with a missing property in propertyListEquals conditions', () => { + const values = ['New York', 'Los Angeles', 'Chicago']; + const where = Where.propertyListEquals('city', values); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMissingProperty = { age: 25 }; + + expect(matcher(itemMissingProperty)).toBe(false); + }); + + it('builds a matcher function from a Where instance with multiple equal conditions using OR', () => { + const where = Where.or( + Where.propertyEquals('city', 'New York'), + Where.propertyEquals('city', 'Los Angeles') + ); + + //console.log(`where = ${where}`); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMatching1 = { city: 'New York', age: 25 }; + const itemMatching2 = { city: 'Los Angeles', age: 25 }; + const itemNotMatching = { city: 'San Francisco', age: 25 }; + + expect(matcher(itemMatching1)).toBe(true); + expect(matcher(itemMatching2)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + it('builds a matcher function from a Where instance with multiple between conditions using OR', () => { + const where = Where.or( + Where.propertyBetween('age', 18, 30), + Where.propertyBetween('age', 50, 65) + ); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMatching1 = { city: 'New York', age: 25 }; + const itemMatching2 = { city: 'New York', age: 55 }; + const itemNotMatching = { city: 'New York', age: 40 }; + + expect(matcher(itemMatching1)).toBe(true); + expect(matcher(itemMatching2)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + it('builds a matcher function from a Where instance with a mix of equal and between conditions using OR', () => { + const where = Where.or( + Where.propertyEquals('city', 'New York'), + Where.propertyBetween('age', 50, 65) + ); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingAnd(where); + + const itemMatching1 = { city: 'New York', age: 25 }; + const itemMatching2 = { city: 'Los Angeles', age: 55 }; + const itemNotMatching = { city: 'Los Angeles', age: 25 }; + + expect(matcher(itemMatching1)).toBe(true); + expect(matcher(itemMatching2)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + }); + + describe('#buildMatcherFunctionFromWhereUsingOr', () => { + + it('builds a matcher function from a Where instance with equal conditions', () => { + const where = Where.propertyEquals('city', 'New York'); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingOr(where); + + const itemMatching = { city: 'New York', age: 25 }; + const itemNotMatching = { city: 'Los Angeles', age: 25 }; + + expect(matcher(itemMatching)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + it('builds a matcher function from a Where instance with between conditions', () => { + const where = Where.propertyBetween('age', 18, 30); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingOr(where); + + const itemMatching = { city: 'New York', age: 25 }; + const itemNotMatching = { city: 'New York', age: 35 }; + + expect(matcher(itemMatching)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + it('builds a matcher function from a Where instance with multiple conditions', () => { + const where = Where.or( + Where.propertyEquals('city', 'New York'), + Where.propertyBetween('age', 18, 30) + ); + const matcher = MemoryValueUtils.buildMatcherFunctionFromWhereUsingOr(where); + + const itemMatchingBoth = { city: 'New York', age: 25 }; + const itemMatchingCity = { city: 'New York', age: 35 }; + const itemMatchingAge = { city: 'Los Angeles', age: 25 }; + const itemNotMatching = { city: 'San Francisco', age: 35 }; + + expect(matcher(itemMatchingBoth)).toBe(true); + expect(matcher(itemMatchingCity)).toBe(true); + expect(matcher(itemMatchingAge)).toBe(true); + expect(matcher(itemNotMatching)).toBe(false); + }); + + }); + + describe('#buildBetweenRangeConditionAndTest', () => { + + it('should return true if the condition is met', () => { + const target = PropertyNameTarget.create('age'); + const condition = BetweenCondition.create(target, 20, 30); + const item = { city: 'New York', age: 25 }; + const test = MemoryValueUtils.buildBetweenRangeConditionAndTest(undefined, condition, target); + + expect(test(item)).toBe(true); + }); + + it('should return false if the condition is not met', () => { + const target = PropertyNameTarget.create('age'); + const condition = BetweenCondition.create(target, 20, 30); + const item = { city: 'New York', age: 35 }; + const test = MemoryValueUtils.buildBetweenRangeConditionAndTest(undefined, condition, target); + + expect(test(item)).toBe(false); + }); + + }); + + describe('#buildEqualConditionAndTest', () => { + + it('should return true if the condition is met', () => { + const target = PropertyNameTarget.create('city'); + const condition = EqualCondition.create(target, 'New York'); + const item = { city: 'New York', age: 25 }; + const test = MemoryValueUtils.buildEqualConditionAndTest(undefined, condition, target); + + expect(test(item)).toBe(true); + }); + + it('should return false if the condition is not met', () => { + const target = PropertyNameTarget.create('city'); + const condition = EqualCondition.create(target, 'New York'); + const item = { city: 'Chicago', age: 25 }; + const test = MemoryValueUtils.buildEqualConditionAndTest(undefined, condition, target); + + expect(test(item)).toBe(false); + }); + + }); + + describe('#buildBetweenRangeConditionOrTest', () => { + + it('should return true if the condition is met', () => { + const target = PropertyNameTarget.create('age'); + const condition = BetweenCondition.create(target, 20, 30); + const item = { city: 'New York', age: 25 }; + const test = MemoryValueUtils.buildBetweenRangeConditionOrTest(undefined, condition, target); + + expect(test(item)).toBe(true); + }); + + it('should return false if the condition is not met', () => { + const target = PropertyNameTarget.create('age'); + const condition = BetweenCondition.create(target, 20, 30); + const item = { city: 'New York', age: 35 }; + const test = MemoryValueUtils.buildBetweenRangeConditionOrTest(undefined, condition, target); + + expect(test(item)).toBe(false); + }); + + }); + + describe('#buildEqualConditionOrTest', () => { + + it('should return true if the condition is met', () => { + const target = PropertyNameTarget.create('city'); + const condition = EqualCondition.create(target, 'New York'); + const item = { city: 'New York', age: 25 }; + const test = MemoryValueUtils.buildEqualConditionOrTest(undefined, condition, target); + + expect(test(item)).toBe(true); + }); + + it('should return false if the condition is not met', () => { + const target = PropertyNameTarget.create('city'); + const condition = EqualCondition.create(target, 'New York'); + const item = { city: 'Chicago', age: 25 }; + const test = MemoryValueUtils.buildEqualConditionOrTest(undefined, condition, target); + + expect(test(item)).toBe(false); + }); + + it('should return true if the previous condition or the current condition is met #1', () => { + + // Non-matching condition + const target1 = PropertyNameTarget.create('city'); + const condition1 = EqualCondition.create(target1, 'New York'); + + // Matching condition + const target2 = PropertyNameTarget.create('age'); + const condition2 = EqualCondition.create(target2, 30); + + const item = { city: 'Chicago', age: 30 }; + + const previousTest = MemoryValueUtils.buildEqualConditionOrTest(undefined, condition1, target1); + const test = MemoryValueUtils.buildEqualConditionOrTest(previousTest, condition2, target2); + + expect(test(item)).toBe(true); + }); + + it('should return true if the previous condition or the current condition is met #2', () => { + + // Matching condition + const target1 = PropertyNameTarget.create('city'); + const condition1 = EqualCondition.create(target1, 'New York'); + + // Non-matching condition + const target2 = PropertyNameTarget.create('age'); + const condition2 = EqualCondition.create(target2, 30); + + const item = { city: 'New York', age: 40 }; + + const previousTest = MemoryValueUtils.buildEqualConditionOrTest(undefined, condition1, target1); + const test = MemoryValueUtils.buildEqualConditionOrTest(previousTest, condition2, target2); + + expect(test(item)).toBe(true); + }); + + it('should return true if the previous condition and the current condition are met #3', () => { + + // Matching condition + const target1 = PropertyNameTarget.create('city'); + const condition1 = EqualCondition.create(target1, 'New York'); + + // Matching condition + const target2 = PropertyNameTarget.create('age'); + const condition2 = EqualCondition.create(target2, 30); + + const item = { city: 'New York', age: 30 }; + + const previousTest = MemoryValueUtils.buildEqualConditionOrTest(undefined, condition1, target1); + const test = MemoryValueUtils.buildEqualConditionOrTest(previousTest, condition2, target2); + + expect(test(item)).toBe(true); + }); + + it('should return false if the previous condition and the current condition is not met #4', () => { + + // Non-matching condition + const target1 = PropertyNameTarget.create('city'); + const condition1 = EqualCondition.create(target1, 'New York'); + + // Non-matching condition + const target2 = PropertyNameTarget.create('age'); + const condition2 = EqualCondition.create(target2, 30); + + const item = { city: 'Oulu', age: 40 }; + + const previousTest = MemoryValueUtils.buildEqualConditionOrTest(undefined, condition1, target1); + const test = MemoryValueUtils.buildEqualConditionOrTest(previousTest, condition2, target2); + + expect(test(item)).toBe(false); + }); + + }); + + describe('#buildOrTest', () => { + + it('should return true if either condition is met', () => { + const test1: MemoryValueTestCallback = () => true; + const test2: MemoryValueTestCallback = () => false; + const combinedTest = MemoryValueUtils.buildOrTest(test1, test2); + const item = { city: 'New York', age: 25 }; + + expect(combinedTest(item)).toBe(true); + }); + + it('should return false if none of the conditions is met', () => { + const test1: MemoryValueTestCallback = () => false; + const test2: MemoryValueTestCallback = () => false; + const combinedTest = MemoryValueUtils.buildOrTest(test1, test2); + const item = { city: 'New York', age: 25 }; + + expect(combinedTest(item)).toBe(false); + }); + + }); + + describe('#buildAndTest', () => { + + it('should return true if both conditions are met', () => { + const test1: MemoryValueTestCallback = () => true; + const test2: MemoryValueTestCallback = () => true; + const combinedTest = MemoryValueUtils.buildAndTest(test1, test2); + const item = { city: 'New York', age: 25 }; + + expect(combinedTest(item)).toBe(true); + }); + + it('should return false if any of the conditions is not met', () => { + const test1: MemoryValueTestCallback = () => true; + const test2: MemoryValueTestCallback = () => false; + const combinedTest = MemoryValueUtils.buildAndTest(test1, test2); + const item = { city: 'New York', age: 25 }; + + expect(combinedTest(item)).toBe(false); + }); + + }); + + describe('#buildValueGetter', () => { + + it('creates a function that retrieves the value of a property in a MemoryItem', () => { + const getCity = MemoryValueUtils.buildValueGetter('city'); + const getAge = MemoryValueUtils.buildValueGetter('age'); + + const item = { city: 'New York', age: 25 }; + + expect(getCity(item)).toBe('New York'); + expect(getAge(item)).toBe(25); + }); + + }); + + describe('#buildRangeTest', () => { + + it('creates a function that checks if a property value is within a specified range', () => { + const isAgeWithinRange = MemoryValueUtils.buildRangeTest('age', 18, 30); + + const itemInRange = { city: 'New York', age: 25 }; + const itemOutOfRange = { city: 'New York', age: 35 }; + + expect(isAgeWithinRange(itemInRange)).toBe(true); + expect(isAgeWithinRange(itemOutOfRange)).toBe(false); + }); + + }); + + describe('#buildEqualTest', () => { + + it('creates a function that checks if a property value equals a specified value', () => { + const isCityNewYork = MemoryValueUtils.buildEqualTest('city', 'New York'); + + const itemMatching = { city: 'New York', age: 25 }; + const itemNotMatching = { city: 'Los Angeles', age: 25 }; + + expect(isCityNewYork(itemMatching)).toBe(true); + expect(isCityNewYork(itemNotMatching)).toBe(false); + }); + + }); + + describe('#getPropertyValue', () => { + + it('retrieves the value of a property in a MemoryItem', () => { + const item = { city: 'New York', age: 25 }; + + expect(MemoryValueUtils.getPropertyValue(item, 'city')).toBe('New York'); + expect(MemoryValueUtils.getPropertyValue(item, 'age')).toBe(25); + }); + + it('returns undefined for non-existent properties', () => { + const item = { city: 'New York', age: 25 }; + + expect(MemoryValueUtils.getPropertyValue(item, 'nonexistent')).toBeUndefined(); + }); + + }); + + describe('#rangeTest', () => { + it('checks if a value is within a specified range', () => { + expect(MemoryValueUtils.rangeTest(25, 18, 30)).toBe(true); + expect(MemoryValueUtils.rangeTest(35, 18, 30)).toBe(false); + }); + }); + + describe('#equalTest', () => { + it('checks if a value equals a specified value', () => { + expect(MemoryValueUtils.equalTest('New York', 'New York')).toBe(true); + expect(MemoryValueUtils.equalTest('Los Angeles', 'New York')).toBe(false); + }); + }); + + describe('#beforeTest', () => { + it('checks if a value is before a specified value', () => { + expect(MemoryValueUtils.beforeTest(25, 30)).toBe(true); + expect(MemoryValueUtils.beforeTest(40, 30)).toBe(false); + }); + }); + + describe('#afterTest', () => { + it('checks if a value is after a specified value', () => { + expect(MemoryValueUtils.afterTest(25, 30)).toBe(false); + expect(MemoryValueUtils.afterTest(40, 30)).toBe(true); + }); + }); + +}); diff --git a/data/persisters/memory/utils/MemoryValueUtils.ts b/data/persisters/memory/utils/MemoryValueUtils.ts new file mode 100644 index 0000000..852b690 --- /dev/null +++ b/data/persisters/memory/utils/MemoryValueUtils.ts @@ -0,0 +1,292 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Where } from "../../../Where"; +import { reduce } from "../../../../functions/reduce"; +import { Condition } from "../../../conditions/types/Condition"; +import { isPropertyNameTarget, PropertyNameTarget } from "../../../conditions/types/PropertyNameTarget"; +import { BetweenCondition, isBetweenCondition } from "../../../conditions/BetweenCondition"; +import { EqualCondition, isEqualCondition } from "../../../conditions/EqualCondition"; +import { isWhereConditionTarget } from "../../../conditions/types/WhereConditionTarget"; +import { isOrCondition } from "../../../conditions/OrCondition"; +import { isAndCondition } from "../../../conditions/AndCondition"; +import { BeforeCondition, isBeforeCondition } from "../../../conditions/BeforeCondition"; +import { AfterCondition, isAfterCondition } from "../../../conditions/AfterCondition"; +import { isEqual } from "../../../../functions/isEqual"; +import { get } from "../../../../functions/get"; + +// import { LogService } from "../../../../LogService"; +// const LOG = LogService.createLogger('MemoryItemUtils'); + +export type MemoryValueTestCallback = ((item: T) => boolean); +export type MemoryValueFetchCallback = ((item: T) => T | undefined); + +export class MemoryValueUtils { + + /** + * + * @param where + */ + public static buildMatcherFunctionFromWhereUsingAnd ( + where: Where + ) : MemoryValueTestCallback { + return reduce( + where.getConditions(), + ( + ret : MemoryValueTestCallback | undefined, + item : Condition + ) : MemoryValueTestCallback => { + //LOG.debug(`reduce and: start: item = `, item); + const target = item.getConditionTarget(); + //LOG.debug(`reduce and: target = `, target); + if (isWhereConditionTarget(target)) { + //LOG.debug(`reduce and: target where = `, target.getWhere()); + if (isOrCondition(item)) return this.buildMatcherFunctionFromWhereUsingOr( item.getWhere() ); + if (isAndCondition(item)) return this.buildMatcherFunctionFromWhereUsingAnd( item.getWhere() ); + //LOG.debug(`reduce and: item = `, item); + throw new TypeError(`Unsupported condition target for where clause: ${item}`); + } + if (!isPropertyNameTarget(target)) { + if (isOrCondition(item)) throw new TypeError(`Unsupported condition target for or conditions: ${target}`); + if (isAndCondition(item)) throw new TypeError(`Unsupported condition target for and conditions: ${target}`); + //LOG.debug(`reduce and: target = `, target); + throw new TypeError(`Unsupported condition target: ${target}`); + } + if (isBetweenCondition(item)) { + //LOG.debug(`reduce and: isBetweenCondition: item = `, item); + return this.buildBetweenRangeConditionAndTest(ret, item, target); + } else if (isEqualCondition(item)) { + //LOG.debug(`reduce and: isEqualCondition: item = `, item); + return this.buildEqualConditionAndTest(ret, item, target); + } else if (isBeforeCondition(item)) { + //LOG.debug(`reduce and: isBeforeCondition: item = `, item); + return this.buildBeforeConditionAndTest(ret, item, target); + } else if (isAfterCondition(item)) { + //LOG.debug(`reduce and: isAfterCondition: item = `, item); + return this.buildAfterConditionAndTest(ret, item, target); + } else { + //LOG.debug(`reduce and: unknown: item = `, item); + throw new TypeError(`Unsupported condition: ${item}`); + } + }, + undefined + ) as MemoryValueTestCallback; + } + + /** + * + * @param where + */ + public static buildMatcherFunctionFromWhereUsingOr ( + where: Where + ) : MemoryValueTestCallback { + return reduce( + where.getConditions(), + ( + ret : MemoryValueTestCallback | undefined, + item : Condition + ) : MemoryValueTestCallback => { + + //LOG.debug(`reduce or: start: item = `, item); + + const target = item.getConditionTarget(); + //LOG.debug(`reduce or: target = `, target); + if (isWhereConditionTarget(target)) { + //LOG.debug(`reduce or: target where = `, target.getWhere()); + if (isOrCondition(item)) return this.buildMatcherFunctionFromWhereUsingOr( item.getWhere() ); + if (isAndCondition(item)) return this.buildMatcherFunctionFromWhereUsingAnd( item.getWhere() ); + //LOG.debug(`reduce or: item = `, item); + throw new TypeError(`Unsupported condition target for where clause: ${item}`); + } + if (!isPropertyNameTarget(target)) { + //LOG.debug(`reduce or: target = `, target); + if (isOrCondition(item)) throw new TypeError(`Unsupported condition target for or conditions: ${target}`); + if (isAndCondition(item)) throw new TypeError(`Unsupported condition target for and conditions: ${target}`); + //LOG.debug(`reduce or: item = `, item, target); + throw new TypeError(`Unsupported condition target or item: ${target} / ${item}`); + } + if (isBetweenCondition(item)) { + //LOG.debug(`reduce or: isBetweenCondition: item = `, item); + return this.buildBetweenRangeConditionOrTest(ret, item, target); + } else if (isEqualCondition(item)) { + //LOG.debug(`reduce or: isEqualCondition: item = `, item); + return this.buildEqualConditionOrTest(ret, item, target); + } else { + //LOG.debug(`reduce or: unknown: item = `, item); + throw new TypeError(`Unsupported condition: ${item}`); + } + }, + undefined + ) as MemoryValueTestCallback; + } + + public static buildBetweenRangeConditionAndTest ( + ret : MemoryValueTestCallback | undefined, + item : BetweenCondition, + target : PropertyNameTarget, + ) : MemoryValueTestCallback { + const propertyName = target.getPropertyName(); + const rangeStart = item.getRangeStart(); + const rangeEnd = item.getRangeEnd(); + const stagingTest = this.buildRangeTest(propertyName, rangeStart, rangeEnd); + if (ret === undefined) return stagingTest; + return this.buildAndTest(ret, stagingTest); + } + + public static buildEqualConditionAndTest ( + ret : MemoryValueTestCallback | undefined, + item : EqualCondition, + target : PropertyNameTarget + ) : MemoryValueTestCallback { + const propertyName = target.getPropertyName(); + const propertyValue = item.getValue(); + const stagingTest = this.buildEqualTest(propertyName, propertyValue); + if (ret === undefined) return stagingTest; + return this.buildAndTest(ret, stagingTest); + } + + public static buildBeforeConditionAndTest ( + ret : MemoryValueTestCallback | undefined, + item : BeforeCondition, + target : PropertyNameTarget + ) : MemoryValueTestCallback { + const propertyName = target.getPropertyName(); + const propertyValue = item.getValue(); + const stagingTest = this.buildBeforeTest(propertyName, propertyValue); + if (ret === undefined) return stagingTest; + return this.buildAndTest(ret, stagingTest); + } + + public static buildAfterConditionAndTest ( + ret : MemoryValueTestCallback | undefined, + item : AfterCondition, + target : PropertyNameTarget + ) : MemoryValueTestCallback { + const propertyName = target.getPropertyName(); + const propertyValue = item.getValue(); + const stagingTest = this.buildAfterTest(propertyName, propertyValue); + if (ret === undefined) return stagingTest; + return this.buildAndTest(ret, stagingTest); + } + + public static buildBetweenRangeConditionOrTest ( + ret : MemoryValueTestCallback | undefined, + item : BetweenCondition, + target : PropertyNameTarget, + ) : MemoryValueTestCallback { + const propertyName = target.getPropertyName(); + const rangeStart = item.getRangeStart(); + const rangeEnd = item.getRangeEnd(); + const stagingTest = this.buildRangeTest(propertyName, rangeStart, rangeEnd); + if (ret === undefined) return stagingTest; + return this.buildOrTest(ret, stagingTest); + } + + public static buildEqualConditionOrTest ( + ret : MemoryValueTestCallback | undefined, + item : EqualCondition, + target : PropertyNameTarget + ) : MemoryValueTestCallback { + const propertyName = target.getPropertyName(); + const propertyValue = item.getValue(); + const stagingTest = this.buildEqualTest(propertyName, propertyValue); + if ( ret === undefined ) return stagingTest; + return this.buildOrTest(ret, stagingTest); + } + + public static buildOrTest ( + a : MemoryValueTestCallback, + b : MemoryValueTestCallback, + ) : MemoryValueTestCallback { + return (m: T) : boolean => a(m) || b(m); + } + + public static buildAndTest ( + a : MemoryValueTestCallback, + b : MemoryValueTestCallback, + ) : MemoryValueTestCallback { + return (m: T) : boolean => a(m) && b(m); + } + + public static buildValueGetter ( + propertyName: string + ) : MemoryValueFetchCallback { + return (m: T) : any => this.getPropertyValue(m, propertyName); + } + + public static buildRangeTest ( + propertyName: string, + start: T, + end: T + ) : MemoryValueTestCallback { + const getValue = this.buildValueGetter(propertyName); + return (m: T) : boolean => this.rangeTest(getValue(m), start, end); + } + + public static buildEqualTest ( + propertyName: string, + propertyValue: any + ) : MemoryValueTestCallback { + const getValue = this.buildValueGetter(propertyName); + return (m: T) : boolean => this.equalTest(getValue(m), propertyValue); + } + + public static buildBeforeTest ( + propertyName: string, + propertyValue: any + ) : MemoryValueTestCallback { + const getValue = this.buildValueGetter(propertyName); + return (m: T) : boolean => this.beforeTest(getValue(m), propertyValue); + } + + public static buildAfterTest ( + propertyName: string, + propertyValue: any + ) : MemoryValueTestCallback { + const getValue = this.buildValueGetter(propertyName); + return (m: T) : boolean => this.afterTest(getValue(m), propertyValue); + } + + public static getPropertyValue ( + value: T, + propertyName: string + ) : T | undefined { + return get(value, propertyName); + } + + /** + * This method is used in the between tests. + * + * @param value + * @param rangeStart + * @param rangeEnd + */ + public static rangeTest ( + value : T, + rangeStart : T, + rangeEnd : T + ) : boolean { + return value >= rangeStart && value <= rangeEnd; + } + + public static equalTest ( + value : T, + testValue : T + ) : boolean { + return isEqual(value, testValue); + } + + public static beforeTest ( + value : T, + testValue : T + ) : boolean { + return value < testValue; + } + + public static afterTest ( + value : T, + testValue : T + ) : boolean { + return value > testValue; + } + +} diff --git a/data/persisters/mock/MockPersister.ts b/data/persisters/mock/MockPersister.ts new file mode 100644 index 0000000..551fb34 --- /dev/null +++ b/data/persisters/mock/MockPersister.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Persister } from "../../types/Persister"; +import { Entity } from "../../Entity"; +import { EntityMetadata } from "../../types/EntityMetadata"; +import { first } from "../../../functions/first"; +import { isArray } from "../../../types/Array"; +import { Sort } from "../../Sort"; +import { Where } from "../../Where"; +import { PersisterType } from "../types/PersisterType"; + +/** + * This persister implements every method but doesn't really do anything. + * It is useful for testing purposes. + * + * @see {@link Persister} + */ +export class MockPersister implements Persister { + + public getPersisterType (): PersisterType { + return PersisterType.MOCK; + } + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.destroy} + */ + public destroy () {} + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.setupEntityMetadata} + */ + public async setupEntityMetadata ( + // @ts-ignore + metadata : EntityMetadata + ) : Promise { + } + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.count} + */ + public async count ( + // @ts-ignore + metadata : EntityMetadata, + // @ts-ignore + where : Where | undefined + ): Promise { + return 0; + } + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.existsBy} + */ + public async existsBy ( + // @ts-ignore + metadata : EntityMetadata, + // @ts-ignore + where : Where, + ): Promise { + return false; + } + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.deleteAll} + */ + public async deleteAll ( + // @ts-ignore + metadata : EntityMetadata, + // @ts-ignore + where : Where | undefined + ): Promise { + } + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.findAll} + */ + public async findAll ( + // @ts-ignore + metadata : EntityMetadata, + // @ts-ignore + where : Where | undefined, + // @ts-ignore + sort : Sort | undefined + ): Promise { + return []; + } + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.findBy} + */ + public async findBy ( + // @ts-ignore + metadata : EntityMetadata, + // @ts-ignore + where : Where | undefined, + // @ts-ignore + sort : Sort | undefined + ): Promise { + return undefined; + } + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.insert} + */ + public async insert ( + // @ts-ignore + metadata : EntityMetadata, + entity : readonly T[] | T, + ): Promise { + const item = isArray(entity) ? first(entity) : entity; + if(!item) throw new TypeError('Could not create item'); + return item; + } + + /** + * **MOCKED VERSION!** + * @inheritDoc + * @see {@link Persister.update} + */ + public async update ( + // @ts-ignore + metadata : EntityMetadata, + entity : T, + ): Promise { + return entity; + } + +} diff --git a/data/persisters/mysql/types/MySqlCharset.ts b/data/persisters/mysql/types/MySqlCharset.ts new file mode 100644 index 0000000..10522b6 --- /dev/null +++ b/data/persisters/mysql/types/MySqlCharset.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum MySqlCharset { + UTF8_GENERAL_CI = "UTF8_GENERAL_CI", + LATIN1_SWEDISH_CI = "LATIN1_SWEDISH_CI" +} + +export function isMySqlCharset (value: any) : value is MySqlCharset { + switch (value) { + case MySqlCharset.UTF8_GENERAL_CI: + case MySqlCharset.LATIN1_SWEDISH_CI: + return true; + default: + return false; + } +} + +export function explainMySqlCharset (value : any) : string { + return explainEnum("MySqlCharset", MySqlCharset, isMySqlCharset, value); +} + +export function stringifyMySqlCharset (value : MySqlCharset) : string { + switch (value) { + case MySqlCharset.UTF8_GENERAL_CI : return 'UTF8_GENERAL_CI'; + case MySqlCharset.LATIN1_SWEDISH_CI : return 'LATIN1_SWEDISH_CI'; + } + throw new TypeError(`Unsupported MySqlCharset value: ${value}`) +} + +export function parseMySqlCharset (value: any) : MySqlCharset | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'UTF8_GENERAL_CI' : return MySqlCharset.UTF8_GENERAL_CI; + case 'LATIN1_SWEDISH_CI' : return MySqlCharset.LATIN1_SWEDISH_CI; + default : return undefined; + } +} diff --git a/data/persisters/mysql/types/MySqlDateTime.test.ts b/data/persisters/mysql/types/MySqlDateTime.test.ts new file mode 100644 index 0000000..99a526b --- /dev/null +++ b/data/persisters/mysql/types/MySqlDateTime.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MySqlDateTime } from "./MySqlDateTime"; + +describe('MySqlDateTime', () => { + + describe('#create', () => { + + it('can create object', () => { + expect( MySqlDateTime.create("2023-04-23T10:51:32.000Z") ).toBeDefined(); + }); + + it('cannot create invalid object', () => { + expect( () => MySqlDateTime.create("Sun Apr 23 2023 10:51:32 GMT+0000 (Coordinated Universal Time)") ).toThrow('Time was not valid ISO date string: \'Sun Apr 23 2023 10:51:32 GMT+0000 (Coordinated Universal Time)\''); + }); + + }); + + describe('members', () => { + + let time : MySqlDateTime; + + beforeEach( + () => { + time = new MySqlDateTime("2023-04-23T10:51:32.000Z"); + } + ); + + describe('#getISOString', () => { + it('can get ISO date string', () => { + expect( time.getISOString() ).toBe('2023-04-23T10:51:32.000Z'); + }); + }); + + describe('#toJSON', () => { + it('can get ISO date string', () => { + expect( time.toJSON() ).toBe('2023-04-23T10:51:32.000Z'); + }); + + }); + + describe('#valueOf', () => { + + it('can get ISO date string', () => { + expect( time.valueOf() ).toBe('2023-04-23T10:51:32.000Z'); + }); + + }); + + describe('#toString', () => { + + it('can get ISO date string', () => { + expect( time.toString() ).toBe('2023-04-23 10:51:32'); + }); + + }); + + }); + +}); diff --git a/data/persisters/mysql/types/MySqlDateTime.ts b/data/persisters/mysql/types/MySqlDateTime.ts new file mode 100644 index 0000000..43f54d5 --- /dev/null +++ b/data/persisters/mysql/types/MySqlDateTime.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { MySqlUtils } from "../utils/MySqlUtils"; +import { JsonAny } from "../../../../Json"; +import { isIsoDateStringWithMilliseconds, parseIsoDateStringWithMilliseconds } from "../../../../types/Date"; + +export class MySqlDateTime { + + private readonly _time : string; + + public constructor (time: string) { + if (!isIsoDateStringWithMilliseconds(time)) throw new TypeError(`Time was not valid ISO date string: '${time}'`); + this._time = time; + } + + public getISOString () : string { + return this._time; + } + + public toString (): string { + return MySqlUtils.getDateTimeStringFromISOString(this._time); + } + + public toJSON (): JsonAny { + return this.getISOString(); + } + + public valueOf (): JsonAny { + return this.getISOString(); + } + + public static create (time : string) : MySqlDateTime { + return new MySqlDateTime(time); + } + + public static parse (time : unknown) : MySqlDateTime | undefined { + const value = parseIsoDateStringWithMilliseconds(time); + return value ? new MySqlDateTime(value) : undefined; + } + +} diff --git a/data/persisters/mysql/utils/MySqlUtils.test.ts b/data/persisters/mysql/utils/MySqlUtils.test.ts new file mode 100644 index 0000000..bc87df9 --- /dev/null +++ b/data/persisters/mysql/utils/MySqlUtils.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MySqlUtils } from "./MySqlUtils"; + +describe('MySqlUtils', () => { + + describe('#getDateTimeStringFromISOString', () => { + + it('can parse string dates', () => { + expect( MySqlUtils.getDateTimeStringFromISOString("2023-04-23T10:51:32.000Z") ).toBe('2023-04-23 10:51:32'); + expect( MySqlUtils.getDateTimeStringFromISOString("Sun Apr 23 2023 10:51:32 GMT+0000 (Coordinated Universal Time)") ).toBe('2023-04-23 10:51:32'); + }); + + it('can cannot parse invalid dates', () => { + expect( () => MySqlUtils.getDateTimeStringFromISOString("Sun Apr 23 2023 99:51:32 GMT+0000 (Coordinated Universal Time)") ).toThrow('Could not parse string: \'Sun Apr 23 2023 99:51:32 GMT+0000 (Coordinated Universal Time)\''); + }); + + }); + + describe('#getDateTimeStringFromDate', () => { + + it('can parse string dates', () => { + const now = new Date(); + now.setTime(1682247559306); + expect( MySqlUtils.getDateTimeStringFromDate( now ) ).toBe('2023-04-23 10:59:19'); + }); + + it('can cannot parse invalid dates', () => { + expect( () => MySqlUtils.getDateTimeStringFromDate(new Date("Sun Apr 23 2023 99:51:32 GMT+0000 (Coordinated Universal Time)") ) ).toThrow("Could not parse date as string: 'Invalid Date'"); + }); + + }); + +}); diff --git a/data/persisters/mysql/utils/MySqlUtils.ts b/data/persisters/mysql/utils/MySqlUtils.ts new file mode 100644 index 0000000..91c5c60 --- /dev/null +++ b/data/persisters/mysql/utils/MySqlUtils.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { padStart } from "../../../../functions/padStart"; +import { isNumber } from "../../../../types/Number"; +import { isValidDate } from "../../../../types/Date"; + +export class MySqlUtils { + + public static getDateTimeStringFromDate ( + time: Date + ) : string { + + const utcFullYear = time.getUTCFullYear(); + const utcMonth = time.getUTCMonth(); + const utcDate = time.getUTCDate(); + const utcHours = time.getUTCHours(); + const utcMinutes = time.getUTCMinutes(); + const utcSeconds = time.getUTCSeconds(); + + const year = isNumber(utcFullYear) && utcFullYear >= 0 ? `${utcFullYear}` : ''; + const month = isNumber(utcMonth) && utcMonth >= 0 ? padStart(`${1 + utcMonth}`, 2, '0') : ''; + const date = isNumber(utcDate) && utcDate >= 0 ? padStart(`${utcDate}`, 2, '0') : ''; + const hours = isNumber(utcHours) && utcHours >= 0 ? padStart(`${utcHours}`, 2, '0') : ''; + const minutes = isNumber(utcMinutes) && utcMinutes >= 0 ? padStart(`${utcMinutes}`, 2, '0') : ''; + const seconds = isNumber(utcSeconds) && utcSeconds >= 0 ? padStart(`${utcSeconds}`, 2, '0') : ''; + if (year && month && date && hours && minutes && seconds) { + return `${year}-${month}-${date} ${hours}:${minutes}:${seconds}`; + } + throw new TypeError(`Could not parse date as string: '${time}'`); + } + + public static getDateTimeStringFromISOString ( + isoDate: string + ) : string { + const date = new Date(isoDate); + if (!isValidDate(date)) { + throw new TypeError(`Could not parse string: '${isoDate}'`); + } + return MySqlUtils.getDateTimeStringFromDate( date ); + } + +} diff --git a/data/persisters/pg/types/PgOid.ts b/data/persisters/pg/types/PgOid.ts new file mode 100644 index 0000000..67a9efc --- /dev/null +++ b/data/persisters/pg/types/PgOid.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../../types/Enum"; + +export enum PgOid { + RECORD = 2249, + ARRAY_OF_RECORD = 2287, + BIGINT = 20 +} + +export function isPgOid (value: unknown) : value is PgOid { + return isEnum(PgOid, value); +} + +export function explainPgOid (value : unknown) : string { + return explainEnum("PgOid", PgOid, isPgOid, value); +} + +export function stringifyPgOid (value : PgOid) : string { + return stringifyEnum(PgOid, value); +} + +export function parsePgOid (value: any) : PgOid | undefined { + return parseEnum(PgOid, value) as PgOid | undefined; +} diff --git a/data/persisters/pg/utils/PgOidParserUtils.test.ts b/data/persisters/pg/utils/PgOidParserUtils.test.ts new file mode 100644 index 0000000..892a0c8 --- /dev/null +++ b/data/persisters/pg/utils/PgOidParserUtils.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { PgOidParserUtils } from "./PgOidParserUtils"; +import { LogLevel } from "../../../../types/LogLevel"; + +const CORRECT_PAIRS : ([string, readonly (string|null)[]])[] = [ + + [ '(,,)', [null, null, null]], + [ '( , , )', [null, null, null]], + [ '(,)', [null, null]], + [ '( , )', [null, null]], + [ '()', []], + [ '(,,1)', [null, null, "1"]], + + [ '(1,2,3)', ["1", "2", "3"]], + [ '(,2,3)', [null, "2", "3"]], + [ '(1,,3)', ["1", null, "3"]], + [ '(1,,"3")', ["1", null, "3"]], + [ '(1,2,)', ["1", "2", null]], + [ '(1,"2",)', ["1", "2", null]], + [ '("1",2,)', ["1", "2", null]], + [ '(1,2)', ["1", "2"]], + [ '("1","2")', ["1", "2"]], + [ '(1)', ["1"]], + [ '("1")', ["1"]], + [ '("(1)")', ["(1)"]], + [ '(",1,")', [",1,"]], + [ '("""1""")', ['"1"']], + [ '("(1,2,3)")', ['(1,2,3)']], + + [ ' (1,2,3)', ["1", "2", "3"]], + [ ' (,2,3)', [null, "2", "3"]], + [ ' (1,,3)', ["1", null, "3"]], + [ ' (1,,"3")', ["1", null, "3"]], + [ ' (1,2,)', ["1", "2", null]], + [ ' (1,"2",)', ["1", "2", null]], + [ ' ("1",2,)', ["1", "2", null]], + [ ' (1,2)', ["1", "2"]], + [ ' ("1","2")', ["1", "2"]], + [ ' (1)', ["1"]], + [ ' ("1")', ["1"]], + [ ' ()', []], + [ ' ("(1)")', ["(1)"]], + [ ' (",1,")', [",1,"]], + [ ' ("""1""")', ['"1"']], + + [ ' ( 1,2,3)', ["1", "2", "3"]], + [ ' ( ,2,3)', [null, "2", "3"]], + [ ' ( 1,,3)', ["1", null, "3"]], + [ ' ( 1,,"3")', ["1", null, "3"]], + [ ' ( 1,2,)', ["1", "2", null]], + [ ' ( 1,"2",)', ["1", "2", null]], + [ ' ( "1",2,)', ["1", "2", null]], + [ ' ( 1,2)', ["1", "2"]], + [ ' ( "1","2")', ["1", "2"]], + [ ' ( 1)', ["1"]], + [ ' ( "1")', ["1"]], + [ ' ( )', []], + [ ' ( "(1)")', ["(1)"]], + [ ' ( ",1,")', [",1,"]], + [ ' ( """1""")', ['"1"']], + + [ ' ( 1 ,2,3)', ["1", "2", "3"]], + [ ' ( , 2,3)', [null, "2", "3"]], + [ ' ( 1 ,,3)', ["1", null, "3"]], + [ ' ( 1 ,,"3")', ["1", null, "3"]], + [ ' ( 1 ,2,)', ["1", "2", null]], + [ ' ( 1 ,"2",)', ["1", "2", null]], + [ ' ( "1" ,2,)', ["1", "2", null]], + [ ' ( 1 ,2)', ["1", "2"]], + [ ' ( "1" ,"2")', ["1", "2"]], + [ ' ( 1 )', ["1"]], + [ ' ( "1" )', ["1"]], + [ ' ( ) ', []], + [ ' ( "(1)" )', ["(1)"]], + [ ' ( ",1," )', [",1,"]], + [ ' ( """1""" )', ['"1"']], + + [ ' ( 1 , 2,3)', ["1", "2", "3"]], + [ ' ( , 2 ,3)', [null, "2", "3"]], + [ ' ( 1 , ,3)', ["1", null, "3"]], + [ ' ( 1 , ,"3")', ["1", null, "3"]], + [ ' ( 1 , 2,)', ["1", "2", null]], + [ ' ( 1 , "2",)', ["1", "2", null]], + [ ' ( "1" , 2,)', ["1", "2", null]], + [ ' ( 1 , 2)', ["1", "2"]], + [ ' ( "1" , "2")', ["1", "2"]], + [ ' ( 1 ) ', ["1"]], + [ ' ( "1" ) ', ["1"]], + + [ ' ( 1 , 2 ,3)', ["1", "2", "3"]], + [ ' ( , 2 , 3)', [null, "2", "3"]], + [ ' ( 1 , , 3)', ["1", null, "3"]], + [ ' ( 1 , , "3")', ["1", null, "3"]], + [ ' ( 1 , 2 ,)', ["1", "2", null]], + [ ' ( 1 , "2" ,)', ["1", "2", null]], + [ ' ( "1" , 2 ,)', ["1", "2", null]], + [ ' ( 1 , 2 )', ["1", "2"]], + [ ' ( "1" , "2" )', ["1", "2"]], + + [ ' ( 1 , 2 ,3 )', ["1", "2", "3"]], + [ ' ( , 2 , 3 )', [null, "2", "3"]], + [ ' ( 1 , , 3 )', ["1", null, "3"]], + [ ' ( 1 , , "3" )', ["1", null, "3"]], + [ ' ( 1 , 2 , )', ["1", "2", null]], + [ ' ( 1 , "2" , )', ["1", "2", null]], + [ ' ( "1" , 2 , )', ["1", "2", null]], + [ ' ( 1 , 2 ) ', ["1", "2"]], + [ ' ( "1" , "2" ) ', ["1", "2"]], + + [ ' ( 1 , 2 ,3 ) ', ["1", "2", "3"]], + [ ' ( , 2 , 3 ) ', [null, "2", "3"]], + [ ' ( 1 , , 3 ) ', ["1", null, "3"]], + [ ' ( 1 , , "3" ) ', ["1", null, "3"]], + [ ' ( 1 , 2 , ) ', ["1", "2", null]], + [ ' ( 1 , "2" , ) ', ["1", "2", null]], + [ ' ( "1" , 2 , ) ', ["1", "2", null]], + +]; + +describe('PgOidParserUtils', () => { + + beforeAll( () => { + PgOidParserUtils.setLogLevel(LogLevel.NONE); + }); + + describe('#parseRecord', () => { + + CORRECT_PAIRS.forEach((pair) => { + const [input, output] = pair; + it(`can parse ${JSON.stringify(input)} as ${JSON.stringify(output)}`, () => { + expect( PgOidParserUtils.parseRecord(input) ).toStrictEqual(output); + }); + }); + + }); + +}); diff --git a/data/persisters/pg/utils/PgOidParserUtils.ts b/data/persisters/pg/utils/PgOidParserUtils.ts new file mode 100644 index 0000000..9213ec6 --- /dev/null +++ b/data/persisters/pg/utils/PgOidParserUtils.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../../../LogService"; +import { LogLevel } from "../../../../types/LogLevel"; +import { startsWith } from "../../../../functions/startsWith"; +import { endsWith } from "../../../../functions/endsWith"; +import { trim } from "../../../../functions/trim"; +import { indexOf } from "../../../../functions/indexOf"; +import { trimStart } from "../../../../functions/trimStart"; + +const LOG = LogService.createLogger('PgOidParserUtils'); + +export class PgOidParserUtils { + + public static setLogLevel (logLevel: LogLevel) : void { + LOG.setLogLevel(logLevel); + } + + public static parseRecord ( + value: string + ) : (string|null)[] { + + let step : number = 0; + + const origValue = value; + value = trim(value); + LOG.debug(`step ${++step}: value = ${value}`); + + if ( !(startsWith(value, '(') && endsWith(value, ')')) ) { + throw new TypeError(`Did not look like a record string: "${value}"`); + } + value = trim(value.substring(1, value.length-1)); + LOG.debug(`step ${++step}: value = ${value}`); + + let ret : (string|null)[] = []; + + while (value.length) { + + if (startsWith(value, '"')) { + value = value.substring(1); + LOG.debug(`step ${++step}: value = ${value}`); + + let part = ''; + + let index = indexOf(value, '"'); + if ( index < 0 ) { + throw new TypeError(`Could not find ending double quote from "${origValue}"`); + } + + part = value.substring(0, index); + value = value.substring(index + 1); + LOG.debug(`step ${++step}: value = ${value}`); + while ( startsWith(value, '"') ) { + value = value.substring(1); + LOG.debug(`step ${++step}: value = ${value}`); + index = indexOf(value, '"'); + if ( index < 0 ) { + throw new TypeError(`Could not find ending double quote from "${origValue}"`); + } + part += '"' + value.substring(0, index); + value = trimStart(value.substring(index + 1)); + LOG.debug(`step ${++step}: value = ${value}`); + } + + LOG.debug(`Adding 1: ${JSON.stringify(part)}`); + ret.push(part); + + value = trimStart(value); + + if (startsWith(value, ",")) { + value = trimStart(value.substring(1)); + if (value.length === 0) { + LOG.debug(`Adding 1 last null`); + ret.push(null); + } + } + + } else if (startsWith(value, ',')) { + + LOG.debug(`Adding null`); + ret.push(null); + value = trimStart(value.substring(1)); + LOG.debug(`step ${++step}: value = ${value}`); + + if (value.length === 0) { + LOG.debug(`Adding last null`); + ret.push(null); + } + + } else { + let index = indexOf(value, ','); + if (index >= 0) { + const item = trim(value.substring(0, index)); + LOG.debug(`Adding 2: ${JSON.stringify(item)}`); + ret.push(item); + value = trimStart(value.substring(index+1)); + LOG.debug(`step ${++step}: value = ${value}`); + + if (value.length === 0) { + LOG.debug(`Adding 2 last null`); + ret.push(null); + } + + } else { + const item = trim(value); + if (item?.length === 0) { + LOG.debug(`Adding 3 null`); + ret.push(null); + } else { + LOG.debug(`Adding 3: ${JSON.stringify(item)}`); + ret.push(item); + value = ''; + LOG.debug(`step ${++step}: value = ${value}`); + } + } + } + + } + + return ret; + } + +} + diff --git a/data/persisters/types/PersisterEntityManager.ts b/data/persisters/types/PersisterEntityManager.ts new file mode 100644 index 0000000..9167e67 --- /dev/null +++ b/data/persisters/types/PersisterEntityManager.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityLike } from "../../types/EntityLike"; +import { EntityField } from "../../types/EntityField"; + +/** + * Manager implementation which can be used to save a state of an entity inside + * the entity object itself. This enables the Persister to know what was the state of + * the entity when it gave it to the library user and detect changes in it. + */ +export interface PersisterEntityManager { + + /** + * Saves current state as the state that is last known state from the + * database persister into the entity provided by the persister. + * + * This is an internal function used by the persister to detect changes in + * entities provided by them. + */ + saveLastEntityState (item: T) : void + + /** + * Saves current state as the state that is last known state from the + * database persister into each entity provided by the persister as a list. + * + * This is an internal function used by the persister to detect changes in + * entities provided by them. + */ + saveLastEntityListState (list: readonly T[]) : void; + + /** + * Get saved state as the state that is the last known state provided by the + * database persister. + * + * This is an internal function used by the persister to detect changes made + * by the user of the persister. + */ + getLastEntityState (item: T) : T | undefined; + + /** + * Get array of fields which have changed since last saved state. + * + * If there were no previous state saved, will return all fields. + * + * This will also ignore fields configured as non-updatable. + * + * @param item + * @param fields + */ + getChangedFields ( + item : T, + fields : readonly EntityField[] + ) : EntityField[]; + +} diff --git a/data/persisters/types/PersisterEntityManagerImpl.test.ts b/data/persisters/types/PersisterEntityManagerImpl.test.ts new file mode 100644 index 0000000..5a4110c --- /dev/null +++ b/data/persisters/types/PersisterEntityManagerImpl.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { PersisterEntityManagerImpl } from "./PersisterEntityManagerImpl"; +import { EntityField } from "../../types/EntityField"; +import { EntityFieldType } from "../../types/EntityFieldType"; +import { EntityLike } from "../../types/EntityLike"; +import { createEntityMetadata, EntityMetadata } from "../../types/EntityMetadata"; +import { LogLevel } from "../../../types/LogLevel"; + +describe('PersisterEntityManagerImpl', () => { + + // Simple mock class for the tests + class MockEntity implements EntityLike { + foo: string; + bar: string; + + constructor (foo: string, bar: string) { + this.foo = foo; + this.bar = bar; + } + + public getMetadata (): EntityMetadata { + return createEntityMetadata( + 'foos', + 'id', + [], + [], + [], + [], + undefined, + [], + [], + [] + ); + } + + toJSON () { + return {}; + } + + clone() { + return new MockEntity(this.foo, this.bar); + } + + } + + let persisterEntityManager: PersisterEntityManagerImpl; + + beforeEach(() => { + persisterEntityManager = PersisterEntityManagerImpl.create(); + }); + + describe('#create', () => { + + it('should create a new instance', () => { + expect(persisterEntityManager).toBeInstanceOf(PersisterEntityManagerImpl); + }); + + }); + + describe('#savePersisterState', () => { + + it('should save the current state of the entity', () => { + const entity = new MockEntity('foo1', 'bar2'); + persisterEntityManager.saveLastEntityState(entity); + // Change the state + entity.foo = 'hello'; + // This is a bit tricky, as your _symbol is private, but you can verify the changes in another way. + // For example, you can verify it with getPersisterState method: + const savedEntity = persisterEntityManager.getLastEntityState(entity) as unknown as MockEntity; + expect(savedEntity).toBeDefined(); + expect(savedEntity.foo).toBe('foo1'); + }); + + }); + + describe('#getPersisterState', () => { + + it('should get the saved state of the entity', () => { + const entity = new MockEntity('foo1', 'bar2'); + persisterEntityManager.saveLastEntityState(entity); + // Change the state + entity.foo = 'hello'; + const savedEntity = persisterEntityManager.getLastEntityState(entity) as unknown as MockEntity; + expect(savedEntity).toBeDefined(); + expect(savedEntity.foo).toBe('foo1'); + }); + + it('should return undefined if no state has been saved', () => { + const entity = new MockEntity('foo1', 'bar2'); + const savedEntity = persisterEntityManager.getLastEntityState(entity); + expect(savedEntity).toBeUndefined(); + }); + + }); + + describe('#getChangedFields', () => { + + // Mock EntityField + let mockFields: EntityField[]; + + beforeEach( () => { + mockFields = [ + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'foo', + columnName: 'foo', + nullable: false, + updatable: true, + insertable: true + }, + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'bar', + columnName: 'bar', + nullable: false, + updatable: true, + insertable: true + }, + ]; + }) + + it('should return changed fields', () => { + const entity = new MockEntity('foo1', 'bar2'); + persisterEntityManager.saveLastEntityState(entity); + // Change the state + entity.foo = 'hello'; + const changedFields = persisterEntityManager.getChangedFields(entity, mockFields); + + // As 'foo' is the only field and it has been updated + expect(changedFields).toStrictEqual([ + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'foo', + columnName: 'foo', + nullable: false, + updatable: true, + insertable: true + } + ]); + + }); + + it('should not return unchanged fields', () => { + const entity = new MockEntity('foo1', 'bar2'); + persisterEntityManager.saveLastEntityState(entity); + // Don't change the state + const changedFields = persisterEntityManager.getChangedFields(entity, mockFields); + expect(changedFields).toStrictEqual([]); // As 'foo' is the only field and it has not been updated + }); + + it('should ignore non-updatable fields', () => { + // Mock EntityField + const mockFields: EntityField[] = [ + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'foo', + columnName: 'foo', + nullable: false, + updatable: false, + insertable: true + }, + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'bar', + columnName: 'bar', + nullable: false, + updatable: false, + insertable: true + }, + ]; + + const entity = new MockEntity('foo1', 'bar2'); + persisterEntityManager.saveLastEntityState(entity); + // Change the state + entity.foo = 'hello'; + const changedFields = persisterEntityManager.getChangedFields(entity, mockFields); + // As 'foo' is non-updatable and bar was not changed + expect(changedFields).toStrictEqual([]); + }); + + it('should ignore non-updatable fields when other fields change', () => { + // Mock EntityField + const mockFields: EntityField[] = [ + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'foo', + columnName: 'foo', + nullable: false, + updatable: false, + insertable: true + }, + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'bar', + columnName: 'bar', + nullable: false, + updatable: true, + insertable: true + }, + ]; + + const entity = new MockEntity('foo1', 'bar2'); + persisterEntityManager.saveLastEntityState(entity); + // Change the state + entity.foo = 'hello'; + entity.bar = 'world'; + const changedFields = persisterEntityManager.getChangedFields(entity, mockFields); + // As 'foo' is non-updatable and bar was not changed + expect(changedFields).toStrictEqual([ + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'bar', + columnName: 'bar', + nullable: false, + updatable: true, + insertable: true + }, + ]); + }); + + it('should return all updatable fields if no previous state was saved', () => { + PersisterEntityManagerImpl.setLogLevel(LogLevel.NONE); + const entity = new MockEntity('foo1', 'bar2'); + const changedFields = persisterEntityManager.getChangedFields(entity, mockFields); + expect(changedFields).toStrictEqual(mockFields); // As 'foo' updatable + }); + + }); + +}); diff --git a/data/persisters/types/PersisterEntityManagerImpl.ts b/data/persisters/types/PersisterEntityManagerImpl.ts new file mode 100644 index 0000000..5f43a88 --- /dev/null +++ b/data/persisters/types/PersisterEntityManagerImpl.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { has } from "../../../functions/has"; +import { filter } from "../../../functions/filter"; +import { isEqual } from "../../../functions/isEqual"; +import { EntityLike } from "../../types/EntityLike"; +import { EntityField } from "../../types/EntityField"; +import { LogService } from "../../../LogService"; +import { LogLevel } from "../../../types/LogLevel"; +import { PersisterEntityManager } from "./PersisterEntityManager"; +import { forEach } from "../../../functions/forEach"; + +const LOG = LogService.createLogger( 'PersisterEntityManagerImpl' ); + +/** + * @inheritDoc + */ +export class PersisterEntityManagerImpl implements PersisterEntityManager { + + /** + * Set internal log level for this scope. These logs are written with logger + * named as "PersisterEntityManagerImpl". + * + * @param level + */ + public static setLogLevel (level: LogLevel) : void { + LOG.setLogLevel(level); + } + + private readonly _symbol : any; + + private constructor () { + this._symbol = Symbol('persisterState'); + } + + /** + * Create instance of PersisterEntityStateManager + */ + public static create () { + return new PersisterEntityManagerImpl(); + } + + /** + * @inheritDoc + */ + public saveLastEntityState (item: T) : void { + (item as any)[this._symbol] = item.clone(); + } + + /** + * @inheritDoc + */ + public saveLastEntityListState (list: readonly T[]) : void { + forEach( + list, + (item: any) : void => this.saveLastEntityState(item) + ); + } + + /** + * @inheritDoc + */ + public getLastEntityState (item: T) : T | undefined { + return has(item, this._symbol) ? (item as any)[this._symbol] : undefined; + } + + /** + * @inheritDoc + */ + public getChangedFields ( + item : T, + fields : readonly EntityField[] + ) : EntityField[] { + const prevState = this.getLastEntityState(item); + if (prevState === undefined) { + LOG.warn(`Warning! Could not detect saved state in entity: `, item); + return filter( + fields, + (field: EntityField) : boolean => { + const { updatable } = field; + return updatable !== false; + } + ); + } + return filter( + fields, + (field: EntityField) : boolean => { + const { propertyName, updatable } = field; + if (updatable === false) return false; + const prevValue : any = has(prevState, propertyName) ? (prevState as any)[propertyName] : undefined; + const nextValue : any = has(item, propertyName) ? (item as any)[propertyName] : undefined; + return !isEqual( prevValue, nextValue ); + } + ); + } + +} diff --git a/data/persisters/types/PersisterMetadataManager.ts b/data/persisters/types/PersisterMetadataManager.ts new file mode 100644 index 0000000..6bfe460 --- /dev/null +++ b/data/persisters/types/PersisterMetadataManager.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadata } from "../../types/EntityMetadata"; + +export interface PersisterMetadataManager { + + setupEntityMetadata (metadata: EntityMetadata) : void; + + getMetadataByTable (tableName: string) : EntityMetadata | undefined; + +} diff --git a/data/persisters/types/PersisterMetadataManagerImpl.test.ts b/data/persisters/types/PersisterMetadataManagerImpl.test.ts new file mode 100644 index 0000000..98bd762 --- /dev/null +++ b/data/persisters/types/PersisterMetadataManagerImpl.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { PersisterMetadataManagerImpl } from "./PersisterMetadataManagerImpl"; +import { PersisterMetadataManager } from "./PersisterMetadataManager"; +import { EntityMetadata } from "../../types/EntityMetadata"; +import { OneToMany } from "../../OneToMany"; +import { JoinColumn } from "../../JoinColumn"; +import { createEntityRelationOneToMany } from "../../types/EntityRelationOneToMany"; +import { LogLevel } from "../../../types/LogLevel"; +import { ManyToOne } from "../../ManyToOne"; +import { createEntityRelationManyToOne } from "../../types/EntityRelationManyToOne"; +import { Table } from "../../Table"; +import { Entity } from "../../Entity"; +import { Column } from "../../Column"; +import { Id } from "../../Id"; + +describe('PersisterMetadataManagerImpl', () => { + + let manager : PersisterMetadataManager; + let cartMetadata : EntityMetadata; + let cartItemMetadata : EntityMetadata; + + beforeEach( + () => { + + PersisterMetadataManagerImpl.setLogLevel(LogLevel.NONE); + @Table('carts') + class CartEntity extends Entity { + + constructor (dto ?: {readonly cartItems ?: readonly CartItemEntity[]}) { + super(); + this.cartItems = dto?.cartItems ?? []; + } + + @Id() + @Column('cart_id') + public cartId ?: string; + + @OneToMany("cart_items","cart") + public cartItems : readonly CartItemEntity[]; + + } + + @Table('cart_items') + class CartItemEntity extends Entity { + + constructor (dto ?: {readonly cart ?: CartEntity}) { + super(); + this.cart = dto?.cart; + } + + @Id() + @Column('cart_item_id') + public cartItemId ?: string; + + @ManyToOne(CartEntity) + @JoinColumn('cart_id', false) + public cart ?: CartEntity; + + } + + cartMetadata = new CartEntity().getMetadata(); + cartItemMetadata = new CartItemEntity().getMetadata(); + + manager = new PersisterMetadataManagerImpl(); + manager.setupEntityMetadata(cartMetadata); + manager.setupEntityMetadata(cartItemMetadata); + } + ); + + describe('#setupEntityMetadata', () => { + it('can link @OneToMany to @ManyToOne', () => { + const metadata = manager.getMetadataByTable('carts'); + expect( metadata?.oneToManyRelations ).toContainEqual( + createEntityRelationOneToMany('cartItems', 'cart', 'cart_items') + ); + }); + it('can link @ManyToOne to @OneToMany', () => { + const metadata = manager.getMetadataByTable('cart_items'); + expect( metadata?.manyToOneRelations ).toContainEqual( + createEntityRelationManyToOne('cart', 'carts') + ); + }); + }); + + describe('#getMetadataByTable', () => { + it('can fetch metadata using table name', () => { + expect( manager.getMetadataByTable('carts') ).toStrictEqual(cartMetadata); + expect( manager.getMetadataByTable('cart_items') ).toStrictEqual(cartItemMetadata); + }); + it('cannot fetch metadata using missing table', () => { + expect( manager.getMetadataByTable('holidays') ).toBe(undefined); + }); + }); + +}); diff --git a/data/persisters/types/PersisterMetadataManagerImpl.ts b/data/persisters/types/PersisterMetadataManagerImpl.ts new file mode 100644 index 0000000..ae28671 --- /dev/null +++ b/data/persisters/types/PersisterMetadataManagerImpl.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadata } from "../../types/EntityMetadata"; +import { PersisterMetadataManager } from "./PersisterMetadataManager"; +import { has } from "../../../functions/has"; +import { forEach } from "../../../functions/forEach"; +import { EntityRelationManyToOne } from "../../types/EntityRelationManyToOne"; +import { find } from "../../../functions/find"; +import { EntityField } from "../../types/EntityField"; +import { EntityFieldType } from "../../types/EntityFieldType"; +import { LogService } from "../../../LogService"; +import { LogLevel } from "../../../types/LogLevel"; +import { values } from "../../../functions/values"; + +const LOG = LogService.createLogger('PersisterMetadataManagerImpl'); + +export class PersisterMetadataManagerImpl implements PersisterMetadataManager { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _metadata : { [tableName: string] : EntityMetadata }; + + constructor () { + this._metadata = {}; + } + + public getMetadataByTable (tableName: string) : EntityMetadata | undefined { + return has(this._metadata, tableName) ? this._metadata[tableName] : undefined; + } + + /** + * Introduce a metadata object to the manager. + * + * Note! Once you have introduced all entities you should call `.setup()`. + * + * @param metadata + */ + public setupEntityMetadata (metadata: EntityMetadata) : void { + const tableName = metadata.tableName; + if (tableName) { + this._metadata[tableName] = metadata; + this._updateRelations(); + LOG.debug(`Updated metadata and relations for table "${tableName}"`); + } else { + LOG.warn(`Warning! Table name did not exist on metadata: `, metadata); + } + } + + /** + * Updates relations between tables + */ + private _updateRelations () : void { + const tables = values(this._metadata); + LOG.debug(`_updateRelations: Updating metadata for all configured tables (${tables.length}`); + forEach( + tables, + (metadata: EntityMetadata) : void => { + + LOG.debug(`Updating metadata = `, metadata); + + /** + * This is the metadata where the @ManyToOne annotation is located + */ + const { tableName, fields, manyToOneRelations } = metadata; + + LOG.debug(`tableName = `, tableName, fields); + + // Update many to one -relations + LOG.debug(`manyToOneRelations = `, manyToOneRelations); + forEach( + manyToOneRelations, + (manyToOne: EntityRelationManyToOne) => { + LOG.debug(`many to one = `, manyToOne); + + const manyToOnePropertyName = manyToOne.propertyName; + if (!manyToOnePropertyName) { + LOG.warn(`Warning! Property name invalid: ${manyToOnePropertyName}`); + return; + } + const manyToOneField : EntityField | undefined = find(fields, (item: EntityField) : boolean => item.propertyName === manyToOnePropertyName); + if (!manyToOneField) { + LOG.warn(`Warning! Property "${manyToOnePropertyName}" had @ManyToOne annotation but no field configuration found.`); + return; + } + if (manyToOneField.fieldType !== EntityFieldType.JOINED_ENTITY) { + LOG.warn(`Warning! Property "${manyToOnePropertyName}" had @ManyToOne annotation but was not joined column type (it was "${manyToOneField.fieldType}").`); + return; + } + + const oneToManyTableName = manyToOne.mappedTable; + if (!oneToManyTableName) { + LOG.debug(`Property "${manyToOnePropertyName}" had @ManyToOne annotation but we could not find remote table name. This might be because the remote entity has not been setup yet.`); + return; + } + + /** + * This is the metadata where the @OneToMany annotation is located + */ + const oneToManyMetadata = this.getMetadataByTable(oneToManyTableName); + if (!oneToManyMetadata) { + LOG.debug(`Property "${manyToOnePropertyName}" had @ManyToOne annotation but we could not find metadata for remote table "${oneToManyTableName}". This might be because the remote entity has not been setup yet.`); + return; + } + + if (!manyToOneField.metadata) { + LOG.debug(`Updated metadata for property "${manyToOne.propertyName}" as `, oneToManyMetadata); + manyToOneField.metadata = oneToManyMetadata; + } else { + LOG.debug(`Metadata was already defined for "${manyToOne.propertyName}" as `, manyToOneField.metadata); + } + + } + ); + + } + ) + } + +} diff --git a/data/persisters/types/PersisterType.ts b/data/persisters/types/PersisterType.ts new file mode 100644 index 0000000..03d37ed --- /dev/null +++ b/data/persisters/types/PersisterType.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../types/Enum"; +import { isUndefined } from "../../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; + +export enum PersisterType { + MEMORY = "MEMORY", + POSTGRESQL = "POSTGRESQL", + MYSQL = "MYSQL", + MOCK = "MOCK", + OTHER = "OTHER", +} + +export function isPersisterType (value: unknown) : value is PersisterType { + return isEnum(PersisterType, value); +} + +export function explainPersisterType (value : unknown) : string { + return explainEnum("PersisterType", PersisterType, isPersisterType, value); +} + +export function stringifyPersisterType (value : PersisterType) : string { + return stringifyEnum(PersisterType, value); +} + +export function parsePersisterType (value: any) : PersisterType | undefined { + return parseEnum(PersisterType, value) as PersisterType | undefined; +} + +export function isPersisterTypeOrUndefined (value: unknown): value is PersisterType | undefined { + return isUndefined(PersisterType) || isPersisterType(value); +} + +export function explainPersisterTypeOrUndefined (value: unknown): string { + return isPersisterTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['PersisterType', 'undefined'])); +} diff --git a/data/query/mysql/constants/mysql-queries.ts b/data/query/mysql/constants/mysql-queries.ts new file mode 100644 index 0000000..5475ed8 --- /dev/null +++ b/data/query/mysql/constants/mysql-queries.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SortDirection } from "../../../types/SortDirection"; + +export const MY_PH_VALUE = `?`; +export const MY_PH_ASSIGN_VALUE = `?? = ?`; +export const MY_PH_ASSIGN_TIMESTAMP_VALUE = `?? = DATE_FORMAT(?, '%Y-%m-%d %H:%i:%s')`; +export const MY_PH_VALUE_AS_TEXT = `?::text`; +export const MY_PH_VALUE_TO_DATETIME = `DATE_FORMAT(?, '%Y-%m-%d %H:%i:%s')`; +export const MY_PH_VALUE_TO_ISO_STRING = `DATE_FORMAT(?, '%Y-%m-%dT%TZ')`; +export const MY_PH_TABLE_NAME = `??`; +export const MY_PH_COLUMN = `??`; +export const MY_PH_TABLE_COLUMN = `??.??`; +export const MY_PH_TABLE_COLUMN_AS = `??.?? AS ??`; +export const MY_PH_TABLE_ALL_COLUMNS = `'??.*'`; +export const MY_PH_TABLE_COLUMN_AS_TEXT = `CAST(??.?? as char)`; +export const MY_PH_FROM_TIME_TABLE_COLUMN_AS_TIME = `TIME_FORMAT(??.??, '%Y-%m-%dT%TZ')`; +export const MY_PH_FROM_DATE_TABLE_COLUMN_AS_DATE = `DATE_FORMAT(??.??, '%Y-%m-%dT%TZ')`; +export const MY_PH_FROM_TIMESTAMP_TABLE_COLUMN_AS_TIMESTAMP = `DATE_FORMAT(??.??, '%Y-%m-%dT%TZ')`; +export const MY_PH_AS = (value: string) => `${value} AS ??`; +export const MY_PH_TABLE_COLUMN_AS_TEXT_AS = MY_PH_AS(MY_PH_TABLE_COLUMN_AS_TEXT); +export const MY_PH_TABLE_COLUMN_AS_TIME_AS = MY_PH_AS(MY_PH_FROM_TIME_TABLE_COLUMN_AS_TIME); +export const MY_PH_TABLE_COLUMN_AS_DATE_AS = MY_PH_AS(MY_PH_FROM_DATE_TABLE_COLUMN_AS_DATE); +export const MY_PH_TABLE_COLUMN_AS_TIMESTAMP_AS = MY_PH_AS(MY_PH_FROM_TIMESTAMP_TABLE_COLUMN_AS_TIMESTAMP); +export const MY_PH_TABLE_COLUMN_WITH_SORT_ORDER = (order : SortDirection) : string => `??.??${ order === SortDirection.ASC ? '' : ' DESC'}`; +export const MY_PH_LEFT_JOIN = `LEFT JOIN ?? ON ??.?? = ??.??`; +export const MY_PH_GROUP_BY_TABLE_COLUMN = `GROUP BY ??.??`; +export const MY_PH_FROM_TABLE = `FROM ??`; +export const MY_PH_INTO_TABLE = `INTO ??`; + +export const MY_PH_TABLE_COLUMN_EQUALS_LAST_INSERT_ID = `??.?? = LAST_INSERT_ID()`; + +export const MY_PH_TABLE_COLUMN_BETWEEN_RANGE = `??.?? BETWEEN ? AND ?`; +export const MY_PH_TABLE_COLUMN_AFTER = `??.?? > ?`; +export const MY_PH_TABLE_COLUMN_BEFORE = `??.?? < ?`; +export const MY_PH_TABLE_COLUMN_EQUAL = `??.?? = ?`; +export const MY_PH_TABLE_COLUMN_EQUAL_AS_JSON = `??.?? = CAST(? AS JSON)`; +export const MY_PH_TABLE_COLUMN_IS_NULL = `??.?? IS NULL`; +export const MY_PH_TABLE_COLUMN_IN = `??.?? IN (?)`; + +export const MY_PH_TABLE_COLUMN_BETWEEN_RANGE_AS_TIME = `??.?? BETWEEN ${MY_PH_VALUE_TO_DATETIME} AND ${MY_PH_VALUE_TO_DATETIME}`; +export const MY_PH_TABLE_COLUMN_AFTER_AS_TIME = `??.?? > ${MY_PH_VALUE_TO_DATETIME}`; +export const MY_PH_TABLE_COLUMN_BEFORE_AS_TIME = `??.?? < ${MY_PH_VALUE_TO_DATETIME}`; +export const MY_PH_TABLE_COLUMN_EQUAL_AS_TIME = `??.??_AS_TIME = ${MY_PH_VALUE_TO_DATETIME}`; +export const MY_PH_TABLE_COLUMN_IN_AS_TIME = `??.?? IN (?)`; diff --git a/data/query/mysql/delete/MySqlDeleteQueryBuilder.ts b/data/query/mysql/delete/MySqlDeleteQueryBuilder.ts new file mode 100644 index 0000000..039e4f7 --- /dev/null +++ b/data/query/mysql/delete/MySqlDeleteQueryBuilder.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { DeleteQueryBuilder } from "../../sql/delete/DeleteQueryBuilder"; +import { MY_PH_FROM_TABLE } from "../constants/mysql-queries"; + +export class MySqlDeleteQueryBuilder implements DeleteQueryBuilder { + + private _tableName : string | undefined; + private _tablePrefix : string = ''; + private _where : QueryBuilder | undefined; + + public constructor () { + this._tableName = undefined; + this._where = undefined; + this._tablePrefix = ''; + } + + + /////////////////////// QueryWhereable /////////////////////// + + + buildWhereQueryString () : string { + return this._where ? this._where.buildQueryString() : ''; + } + + getWhereValueFactories () : readonly QueryValueFactory[] { + return this._where ? this._where.getQueryValueFactories() : []; + } + + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + this._where = builder; + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + */ + public setTablePrefix (prefix: string) { + this._tablePrefix = prefix; + } + + /** + * @inheritDoc + */ + public getTablePrefix (): string { + return this._tablePrefix; + } + + /** + * @inheritDoc + */ + public getTableNameWithPrefix (tableName : string) : string { + return `${this._tablePrefix}${tableName}`; + } + + /** + * @inheritDoc + */ + public setTableName (tableName: string) { + this._tableName = tableName; + } + + /** + * @inheritDoc + */ + public getTableName (): string { + if (!this._tableName) throw new TypeError(`From table has not been initialized yet`); + return this._tableName; + } + + /** + * @inheritDoc + */ + public getCompleteTableName (): string { + if (!this._tableName) throw new TypeError(`From table has not been initialized yet`); + return this.getTableNameWithPrefix(this._tableName); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `DELETE "${this._tablePrefix}${this._tableName}" ${this._where}`; + } + + public build () : QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + public buildQueryString () : string { + if (!this._tableName) throw new TypeError('Table must be selected'); + let query = `DELETE ${MY_PH_FROM_TABLE}`; + if (this._where) { + query += ` WHERE ${this._where.buildQueryString()}`; + } + return query; + } + + public buildQueryValues () : readonly any[] { + return [ + this.getCompleteTableName(), + ...( this._where ? this._where.buildQueryValues() : []) + ]; + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return [ + () => this.getCompleteTableName(), + ...( this._where ? this._where.getQueryValueFactories() : []) + ] + } + + +} diff --git a/data/query/mysql/delete/MySqlEntityDeleteQueryBuilder.ts b/data/query/mysql/delete/MySqlEntityDeleteQueryBuilder.ts new file mode 100644 index 0000000..ec2c2d7 --- /dev/null +++ b/data/query/mysql/delete/MySqlEntityDeleteQueryBuilder.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MySqlDeleteQueryBuilder } from "./MySqlDeleteQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { EntityField } from "../../../types/EntityField"; +import { Where } from "../../../Where"; +import { MySqlAndChainBuilder } from "../formulas/MySqlAndChainBuilder"; +import { ChainQueryBuilderUtils } from "../../utils/ChainQueryBuilderUtils"; +import { MySqlOrChainBuilder } from "../formulas/MySqlOrChainBuilder"; +import { EntityDeleteQueryBuilder } from "../../sql/delete/EntityDeleteQueryBuilder"; +import { ChainQueryBuilder } from "../../types/ChainQueryBuilder"; +import { TemporalProperty } from "../../../types/TemporalProperty"; + +export class MySqlEntityDeleteQueryBuilder implements EntityDeleteQueryBuilder { + + private _builder : MySqlDeleteQueryBuilder; + + public constructor () { + this._builder = new MySqlDeleteQueryBuilder(); + } + + public buildAnd ( + where : Where, + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) : ChainQueryBuilder { + const completeTableName = this.getTableNameWithPrefix(tableName); + const andBuilder = MySqlAndChainBuilder.create(); + ChainQueryBuilderUtils.buildChain( + andBuilder, + where, + completeTableName, + fields, + temporalProperties, + () => MySqlAndChainBuilder.create(), + () => MySqlOrChainBuilder.create() + ); + return andBuilder; + } + + + /////////////////////// EntityDeleteQueryBuilder /////////////////////// + + + + /////////////////////// QueryWhereable /////////////////////// + + + buildWhereQueryString () : string { + return this._builder.buildWhereQueryString(); + } + + getWhereValueFactories () : readonly QueryValueFactory[] { + return this._builder.getWhereValueFactories(); + } + + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + return this._builder.setWhereFromQueryBuilder(builder); + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + */ + public setTablePrefix (prefix: string): void { + return this._builder.setTablePrefix(prefix); + } + + /** + * @inheritDoc + */ + public getTablePrefix (): string { + return this._builder.getTablePrefix(); + } + + /** + * @inheritDoc + */ + public getTableNameWithPrefix (tableName: string): string { + return this._builder.getTableNameWithPrefix(tableName); + } + + /** + * @inheritDoc + */ + public getTableName (): string { + return this._builder.getTableName(); + } + + /** + * @inheritDoc + */ + public setTableName (tableName: string): void { + return this._builder.setTableName(tableName); + } + + /** + * @inheritDoc + */ + public getCompleteTableName (): string { + return this._builder.getCompleteTableName(); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return this._builder.toString(); + } + + public build (): QueryBuildResult { + return this._builder.build(); + } + + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + public buildQueryValues () : readonly any[] { + return this._builder.buildQueryValues(); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + + +} diff --git a/data/query/mysql/formulas/MySqlAndChainBuilder.ts b/data/query/mysql/formulas/MySqlAndChainBuilder.ts new file mode 100644 index 0000000..3378a45 --- /dev/null +++ b/data/query/mysql/formulas/MySqlAndChainBuilder.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../../functions/map"; +import { ChainQueryBuilder } from "../../types/ChainQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { MY_PH_TABLE_COLUMN_AFTER, MY_PH_TABLE_COLUMN_AFTER_AS_TIME, MY_PH_TABLE_COLUMN_BEFORE, MY_PH_TABLE_COLUMN_BEFORE_AS_TIME, MY_PH_TABLE_COLUMN_BETWEEN_RANGE, MY_PH_TABLE_COLUMN_BETWEEN_RANGE_AS_TIME, MY_PH_TABLE_COLUMN_EQUAL, MY_PH_TABLE_COLUMN_EQUAL_AS_JSON, MY_PH_TABLE_COLUMN_EQUAL_AS_TIME, MY_PH_TABLE_COLUMN_EQUALS_LAST_INSERT_ID, MY_PH_TABLE_COLUMN_IN, MY_PH_TABLE_COLUMN_IN_AS_TIME, MY_PH_TABLE_COLUMN_IS_NULL } from "../constants/mysql-queries"; +import { EntityUtils } from "../../../utils/EntityUtils"; + +export class MySqlAndChainBuilder implements ChainQueryBuilder { + + private readonly _formulaQuery : (() => string)[]; + private readonly _formulaValues : QueryValueFactory[]; + + protected constructor () { + this._formulaQuery = []; + this._formulaValues = []; + } + + public static create () : MySqlAndChainBuilder { + return new MySqlAndChainBuilder(); + } + + + /////////////////////// ChainQueryBuilder /////////////////////// + + + public setColumnInList ( + tableName : string, + columnName : string, + values : readonly any[] + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_IN ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => values); + } + + public setColumnEquals ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_EQUAL ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + public setColumnEqualsAsJson ( + tableName : string, + columnName : string, + value : any + ) { + // FIXME: This requires imolementation for https://github.com/heusalagroup/fi.hg.core/issues/67 + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_EQUAL_AS_JSON ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => JSON.stringify(value)); + } + + public setColumnIsNull ( + tableName : string, + columnName : string + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_IS_NULL ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + } + + public setColumnBetween ( + tableName : string, + columnName : string, + start : any, + end : any, + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_BETWEEN_RANGE ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => start); + this._formulaValues.push(() => end); + } + + public setColumnBefore ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_BEFORE ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + public setColumnAfter ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_AFTER ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + + public setColumnInListAsTime ( + tableName : string, + columnName : string, + values : readonly any[] + ) { + // FIXME: Implement list of times + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_IN_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => map(values, i => EntityUtils.parseIsoStringAsMySQLDateString(i))); + } + + public setColumnEqualsAsTime ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_EQUAL_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => EntityUtils.parseIsoStringAsMySQLDateString(value) ); + } + + public setColumnBetweenAsTime ( + tableName : string, + columnName : string, + start : any, + end : any, + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_BETWEEN_RANGE_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => EntityUtils.parseIsoStringAsMySQLDateString(start)); + this._formulaValues.push(() => EntityUtils.parseIsoStringAsMySQLDateString(end)); + } + + public setColumnBeforeAsTime ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_BEFORE_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => EntityUtils.parseIsoStringAsMySQLDateString(value)); + } + + public setColumnAfterAsTime ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_AFTER_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => EntityUtils.parseIsoStringAsMySQLDateString(value)); + } + + public setColumnEqualsByLastInsertId ( + tableName : string, + columnName : string + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_EQUALS_LAST_INSERT_ID ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `"${this._formulaQuery.map(item => item.toString()).join(' OR ')}" with ${this._formulaValues.map(item=>item()).join(' ')}`; + } + + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + public buildQueryString (): string { + const formulaQuery = map(this._formulaQuery, (f) => f()); + return `(${formulaQuery.join(') AND (')})`; + } + + public buildQueryValues () : readonly any[] { + return map(this._formulaValues, (f) => f()); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._formulaValues; + } + + public setFromQueryBuilder (builder: QueryBuilder): void { + this._formulaQuery.push( () => builder.buildQueryString() ); + builder.getQueryValueFactories().forEach((factory)=> { + this._formulaValues.push(factory); + }); + } + +} diff --git a/data/query/mysql/formulas/MySqlEntityJsonObjectBuilder.ts b/data/query/mysql/formulas/MySqlEntityJsonObjectBuilder.ts new file mode 100644 index 0000000..d7a4470 --- /dev/null +++ b/data/query/mysql/formulas/MySqlEntityJsonObjectBuilder.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityField } from "../../../types/EntityField"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { MySqlJsonObjectQueryBuilder } from "./MySqlJsonObjectQueryBuilder"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { EntityBuilderUtils } from "../../../utils/EntityBuilderUtils"; + +/** + * This generates formulas like `JSON_OBJECT(property, table.column[, property2, table2.column2, ...])` + * but can configure it just by using the table name and entity field array. + */ +export class MySqlEntityJsonObjectBuilder implements QueryBuilder { + + private readonly _jsonBuilder : MySqlJsonObjectQueryBuilder; + + public constructor ( + ) { + this._jsonBuilder = new MySqlJsonObjectQueryBuilder(); + } + + + public setEntityFieldsFromTable ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) { + EntityBuilderUtils.includeFields( + tableName, + fields, + temporalProperties, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._jsonBuilder.setPropertyFromColumn( columnName, tableName, columnName ); + }, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._jsonBuilder.setPropertyFromColumn( columnName, tableName, columnName ); + }, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._jsonBuilder.setPropertyFromColumn( columnName, tableName, columnName ); + }, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._jsonBuilder.setPropertyFromColumnAsChar( columnName, tableName, columnName ); + }, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._jsonBuilder.setPropertyFromColumn( columnName, tableName, columnName ); + }, + ); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return this._jsonBuilder.toString(); + } + + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + public buildQueryString (): string { + return this._jsonBuilder.buildQueryString(); + } + + public buildQueryValues () : readonly any[] { + return this._jsonBuilder.buildQueryValues(); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._jsonBuilder.getQueryValueFactories(); + } + +} diff --git a/data/query/mysql/formulas/MySqlJsonArrayAggBuilder.ts b/data/query/mysql/formulas/MySqlJsonArrayAggBuilder.ts new file mode 100644 index 0000000..91d2b45 --- /dev/null +++ b/data/query/mysql/formulas/MySqlJsonArrayAggBuilder.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder } from "../../types/QueryBuilder"; +import { FunctionQueryBuilder } from "../../types/FunctionQueryBuilder"; + +/** + * This generates formulas like `JSON_ARRAYAGG(formula)` + * + * If distinct option is enabled, this implementation will use workaround and + * implement it using `CONCAT('[', GROUP_CONCAT(DISTINCT formula))` because + * MySQL does not support DISTINCT on like `JSON_ARRAYAGG(DISTINCT formula)` + * where formula is `JSON_OBJECT(...)`. + * + * @see https://stackoverflow.com/questions/70993552/how-to-return-distinct-values-in-a-json-arrayagg-when-using-json-object + */ +export class MySqlJsonArrayAggBuilder extends FunctionQueryBuilder { + + public constructor (distinct: boolean) { + super( + distinct, + 'JSON_ARRAYAGG' + ); + } + + + /////////////////// FunctionQueryBuilder /////////////////// + + + public setFormulaFromQueryBuilder (builder : QueryBuilder) { + super.setFormulaFromQueryBuilder(builder); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public build (): readonly [ string, readonly any[] ] { + return super.build(); + } + + public buildQueryString (): string { + if (this._distinct) { + if (!this._builder) throw new TypeError(`Could not build JSON_ARRAYAGG() query: Query builder not initialized`); + return `CONCAT('[', GROUP_CONCAT(DISTINCT ${this._builder.buildQueryString()}), ']')`; + } + return super.buildQueryString(); + } + + public buildQueryValues (): readonly any[] { + return super.buildQueryValues(); + } + + public getQueryValueFactories (): readonly (() => any)[] { + return super.getQueryValueFactories(); + } + + +} diff --git a/data/query/mysql/formulas/MySqlJsonObjectQueryBuilder.ts b/data/query/mysql/formulas/MySqlJsonObjectQueryBuilder.ts new file mode 100644 index 0000000..e8ec436 --- /dev/null +++ b/data/query/mysql/formulas/MySqlJsonObjectQueryBuilder.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { map } from "../../../../functions/map"; +import { MY_PH_TABLE_COLUMN, MY_PH_TABLE_COLUMN_AS_TEXT, MY_PH_VALUE } from "../constants/mysql-queries"; + +/** + * This generates formulas like `JSON_OBJECT(property, table.column[, property2, table2.column2, ...])` + */ +export class MySqlJsonObjectQueryBuilder implements QueryBuilder { + + private readonly _keyValueQueries : (() => string)[]; + private readonly _keyValueValues : QueryValueFactory[]; + + private readonly _whenNullQueries : (() => string)[]; + private readonly _whenNullValues : QueryValueFactory[]; + + public constructor () { + this._keyValueQueries = []; + this._keyValueValues = []; + this._whenNullQueries = []; + this._whenNullValues = []; + } + + + /** + * + * @param propertyName The property name in the JSON object + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + public setPropertyFromColumn ( + propertyName: string, + tableName: string, + columnName: string + ) { + + this._whenNullQueries.push(() => `${MY_PH_TABLE_COLUMN} IS NULL`); + this._whenNullValues.push(() => tableName); + this._whenNullValues.push(() => columnName); + + this._keyValueQueries.push(() => `${MY_PH_VALUE}, ${MY_PH_TABLE_COLUMN}`); + this._keyValueValues.push(() => propertyName); + this._keyValueValues.push(() => tableName); + this._keyValueValues.push(() => columnName); + + } + + /** + * + * @param propertyName The property name in the JSON object + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + public setPropertyFromColumnAsChar ( + propertyName: string, + tableName: string, + columnName: string + ) { + + this._whenNullQueries.push(() => `${MY_PH_TABLE_COLUMN} IS NULL`); + this._whenNullValues.push(() => tableName); + this._whenNullValues.push(() => columnName); + + this._keyValueQueries.push(() => `${MY_PH_VALUE}, ${MY_PH_TABLE_COLUMN_AS_TEXT}`); + this._keyValueValues.push(() => propertyName); + this._keyValueValues.push(() => tableName); + this._keyValueValues.push(() => columnName); + + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `JSON_OBJECT "${this._keyValueQueries.map(item => item.toString()).join('')}" with ${this._keyValueValues.map(item=>item()).join(' ')}`; + } + + public build () : QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + public buildQueryString () : string { + const whenNullQueries = map(this._whenNullQueries, (f) => f()); + const keyValueQueries = map(this._keyValueQueries, (f) => f()); + return `CASE WHEN ${whenNullQueries.join(' AND ')} THEN NULL ELSE JSON_OBJECT(${keyValueQueries.join(', ')}) END`; + } + + public buildQueryValues () : readonly any[] { + return map(this.getQueryValueFactories(), (f) => f()); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return [ + ...this._whenNullValues, + ...this._keyValueValues + ]; + } + +} diff --git a/data/query/mysql/formulas/MySqlOrChainBuilder.ts b/data/query/mysql/formulas/MySqlOrChainBuilder.ts new file mode 100644 index 0000000..500fcc7 --- /dev/null +++ b/data/query/mysql/formulas/MySqlOrChainBuilder.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../../functions/map"; +import { ChainQueryBuilder } from "../../types/ChainQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { MY_PH_TABLE_COLUMN_AFTER, MY_PH_TABLE_COLUMN_AFTER_AS_TIME, MY_PH_TABLE_COLUMN_BEFORE, MY_PH_TABLE_COLUMN_BEFORE_AS_TIME, MY_PH_TABLE_COLUMN_BETWEEN_RANGE, MY_PH_TABLE_COLUMN_BETWEEN_RANGE_AS_TIME, MY_PH_TABLE_COLUMN_EQUAL, MY_PH_TABLE_COLUMN_EQUAL_AS_JSON, MY_PH_TABLE_COLUMN_EQUAL_AS_TIME, MY_PH_TABLE_COLUMN_EQUALS_LAST_INSERT_ID, MY_PH_TABLE_COLUMN_IN, MY_PH_TABLE_COLUMN_IN_AS_TIME, MY_PH_TABLE_COLUMN_IS_NULL } from "../constants/mysql-queries"; + +export class MySqlOrChainBuilder implements ChainQueryBuilder { + + private readonly _formulaQuery : (() => string)[]; + private readonly _formulaValues : QueryValueFactory[]; + + protected constructor () { + this._formulaQuery = []; + this._formulaValues = []; + } + + public static create () { + return new MySqlOrChainBuilder(); + } + + + + + public setColumnInList ( + tableName : string, + columnName : string, + values : readonly any[] + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_IN ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => values); + } + + public setColumnEquals ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_EQUAL ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + public setColumnEqualsAsJson ( + tableName : string, + columnName : string, + value : any + ) { + // FIXME: This requires imolementation for https://github.com/heusalagroup/fi.hg.core/issues/67 + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_EQUAL_AS_JSON ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => JSON.stringify(value)); + } + + public setColumnIsNull ( + tableName : string, + columnName : string + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_IS_NULL ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + } + + public setColumnBefore ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_BEFORE ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + public setColumnAfter ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_AFTER ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + public setColumnBetween ( + tableName : string, + columnName : string, + start : any, + end : any, + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_BETWEEN_RANGE ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => start); + this._formulaValues.push(() => end); + } + + public setColumnInListAsTime ( + tableName : string, + columnName : string, + values : readonly any[] + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_IN_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => values); + } + + public setColumnEqualsAsTime ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_EQUAL_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + public setColumnBeforeAsTime ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_BEFORE_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + public setColumnAfterAsTime ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_AFTER_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => value); + } + + public setColumnBetweenAsTime ( + tableName : string, + columnName : string, + start : any, + end : any, + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_BETWEEN_RANGE_AS_TIME ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + this._formulaValues.push(() => start); + this._formulaValues.push(() => end); + } + + public setColumnEqualsByLastInsertId ( + tableName : string, + columnName : string + ) { + this._formulaQuery.push( () => MY_PH_TABLE_COLUMN_EQUALS_LAST_INSERT_ID ); + this._formulaValues.push(() => tableName); + this._formulaValues.push(() => columnName); + } + + + + + /////////////////////// QueryBuilder /////////////////////// + + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `"${this._formulaQuery.map(item => item.toString()).join(' OR ')}" with ${this._formulaValues.map(item=>item()).join(' ')}`; + } + + + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + public buildQueryString (): string { + const formulaQuery = map(this._formulaQuery, (f) => f()); + return `(${formulaQuery.join(') OR (')})`; + } + + public buildQueryValues () : readonly any[] { + return map(this._formulaValues, (f) => f()); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._formulaValues; + } + + public setFromQueryBuilder (builder: QueryBuilder): void { + this._formulaQuery.push( () => builder.buildQueryString() ); + builder.getQueryValueFactories().forEach((factory)=> { + this._formulaValues.push(factory); + }); + } + +} diff --git a/data/query/mysql/insert/MySqlEntityInsertQueryBuilder.test.ts b/data/query/mysql/insert/MySqlEntityInsertQueryBuilder.test.ts new file mode 100644 index 0000000..850d861 --- /dev/null +++ b/data/query/mysql/insert/MySqlEntityInsertQueryBuilder.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MySqlEntityInsertQueryBuilder } from "./MySqlEntityInsertQueryBuilder"; +import { createEntityMetadata, EntityMetadata } from "../../../types/EntityMetadata"; +import { Entity } from "../../../Entity"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { createEntityField, EntityField } from "../../../types/EntityField"; +import { EntityRelationOneToMany } from "../../../types/EntityRelationOneToMany"; +import { EntityRelationManyToOne } from "../../../types/EntityRelationManyToOne"; +import { Table } from "../../../Table"; +import { Id } from "../../../Id"; +import { Column } from "../../../Column"; +import { EntityFieldType } from "../../../types/EntityFieldType"; +import { LogLevel } from "../../../../types/LogLevel"; + +describe('MySqlEntityInsertQueryBuilder', () => { + + const tablePrefix = 'db1_'; + const tableName = 'cars'; + const idColumn = 'car_id'; + const idPropertyName = 'carId'; + const nameColumn = 'car_name'; + const ageColumn = 'car_age'; + const termColumn = 'car_term'; + const idProperty = 'carId'; + const nameProperty = 'carName'; + const ageProperty = 'carAge'; + + @Table(tableName) + class CarEntity extends Entity { + + constructor (id ?: string | CarEntity, name ?: string, age?: number, term?: boolean) { + super(); + if ( id && id instanceof CarEntity ) { + this.carId = id.carId; + this.carName = id.carName; + this.carAge = id.carAge; + this.carTerm = id.carTerm; + } else { + this.carId = id; + this.carName = name; + this.carAge = age; + this.carTerm = term; + } + } + + @Id() + @Column(idColumn) + public carId ?: string | CarEntity; + + @Column(nameColumn) + public carName ?: string; + + @Column(ageColumn) + public carAge ?: number; + + @Column(termColumn, 'BOOL') + public carTerm ?: boolean; + + } + + let fields: EntityField[]; + let oneToManyRelations : EntityRelationOneToMany[]; + let manyToOneRelations : EntityRelationManyToOne[]; + let temporalProperties : TemporalProperty[]; + let createEntity : () => Entity; + + let metadata : EntityMetadata; + let carEntity1 : CarEntity; + let carEntity2 : CarEntity; + // let carEntity3 : CarEntity; + // let entities : readonly Entity[]; + + beforeEach( () => { + + MySqlEntityInsertQueryBuilder.setLogLevel(LogLevel.NONE); + + createEntity = () : Entity => new CarEntity(); + + fields = [ + createEntityField( + idProperty, + idColumn, + undefined, + undefined, + EntityFieldType.UNKNOWN, + undefined + ), + createEntityField( + nameProperty, + nameColumn, + undefined, + undefined, + EntityFieldType.UNKNOWN, + undefined + ), + createEntityField( + ageProperty, + ageColumn, + undefined, + undefined, + EntityFieldType.UNKNOWN, + undefined + ), + ]; + + oneToManyRelations = []; + manyToOneRelations = []; + temporalProperties = []; + + metadata = createEntityMetadata( + tableName, + idPropertyName, + fields, + oneToManyRelations, + manyToOneRelations, + temporalProperties, + createEntity, + [], + [], + [] + ); + + carEntity1 = new CarEntity('1', 'Car A', 13, true); + carEntity2 = new CarEntity('2', 'Car B', 99, false); + // carEntity3 = new CarEntity('3', 'Car C', 3, true); + + // entities = [ + // carEntity1, + // carEntity2, + // carEntity3, + // ]; + + }) + + describe('create', () => { + + it('can build insert query builder', () => { + const builder = MySqlEntityInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + builder.appendEntity( + carEntity1, + metadata.fields, + metadata.temporalProperties, + [idPropertyName] + ); + + builder.appendEntity( + carEntity2, + metadata.fields, + metadata.temporalProperties, + [idPropertyName] + ); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??, ??) VALUES (?, ?), (?, ?)`); + + expect( values ).toHaveLength(7); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe(ageColumn); + expect( values[3] ).toBe('Car A'); + expect( values[4] ).toBe(13); + expect( values[5] ).toBe('Car B'); + expect( values[6] ).toBe(99); + + }); + + }); + +}); diff --git a/data/query/mysql/insert/MySqlEntityInsertQueryBuilder.ts b/data/query/mysql/insert/MySqlEntityInsertQueryBuilder.ts new file mode 100644 index 0000000..9c9cd45 --- /dev/null +++ b/data/query/mysql/insert/MySqlEntityInsertQueryBuilder.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityField } from "../../../types/EntityField"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { MySqlInsertQueryBuilder } from "./MySqlInsertQueryBuilder"; +import { EntityInsertQueryBuilder } from "../../sql/insert/EntityInsertQueryBuilder"; +import { Entity } from "../../../Entity"; +import { forEach } from "../../../../functions/forEach"; +import { find } from "../../../../functions/find"; +import { MySqlListQueryBuilder } from "../types/MySqlListQueryBuilder"; +import { filter } from "../../../../functions/filter"; +import { QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { LogService } from "../../../../LogService"; +import { EntityFieldType } from "../../../types/EntityFieldType"; +import { LogLevel } from "../../../../types/LogLevel"; +import { EntityUtils } from "../../../utils/EntityUtils"; +import { isJsonColumnDefinition, isTimeColumnDefinition } from "../../../types/ColumnDefinition"; + +const LOG = LogService.createLogger( 'MySqlEntityInsertQueryBuilder' ); + +/** + * Defines an interface for a builder of MySQL database read query from + * entity types. + */ +export class MySqlEntityInsertQueryBuilder implements EntityInsertQueryBuilder { + + public static setLogLevel (level: LogLevel) : void { + LOG.setLogLevel(level); + } + + private readonly _builder : MySqlInsertQueryBuilder; + + protected constructor () { + this._builder = MySqlInsertQueryBuilder.create(); + } + + /** + * Create select query builder for MySQL + */ + public static create () : MySqlEntityInsertQueryBuilder { + return new MySqlEntityInsertQueryBuilder(); + } + + + + /** + * + * @param fields + * @param temporalProperties + * @param ignoreProperties + * @param entity + */ + public appendEntity ( + entity : T, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + ignoreProperties : readonly string[], + ) : void { + + const filteredFields : EntityField[] = filter( + fields, + (field: EntityField) : boolean => { + const { propertyName, fieldType, insertable } = field; + return insertable && !ignoreProperties.includes( propertyName ) && fieldType !== EntityFieldType.JOINED_ENTITY; + } + ); + + const itemBuilder = MySqlListQueryBuilder.create(); + forEach( + filteredFields, + (field: EntityField) => { + + const { propertyName, columnName } = field; + + // FIXME: This code is almost identical to the MySqlEntityInsertQueryBuilder. Consider moving it as a utility function. + + LOG.debug(`appendEntity: field: `, field); + + const { columnDefinition } = field; + + const temporalProperty : TemporalProperty | undefined = find( + temporalProperties, + (item: TemporalProperty) : boolean => item.propertyName === propertyName + ); + const temporalType = temporalProperty?.temporalType; + + const value : any = EntityUtils.getPropertyFromEntity(entity, propertyName) ?? null; + + const isTime : boolean = !!temporalType || !!(isTimeColumnDefinition(columnDefinition)); + LOG.debug(`appendEntity: isTime: `, isTime); + if ( isTime ) { + this._builder.addColumnName(columnName); + itemBuilder.setParamFromTimestampString(value); + return; + } + + const isJson : boolean = !isTime ? isJsonColumnDefinition(columnDefinition) : false; + if (isJson) { + this._builder.addColumnName(columnName); + itemBuilder.setParamFromJson(value); + return; + } + + this._builder.addColumnName(columnName); + itemBuilder.setParam(value); + + } + ); + + this._builder.appendValueListUsingQueryBuilder(itemBuilder); + + } + + public appendEntityList ( + list : readonly T[], + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + ignoreProperties : readonly string[], + ) : void { + forEach( + list, + (item) => this.appendEntity(item, fields, temporalProperties, ignoreProperties) + ); + } + + /////////////////////// InsertQueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return this._builder.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string): void { + return this._builder.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getCompleteFromTable} + */ + public getTableName (): string { + return this._builder.getTableName(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getCompleteFromTable} + */ + public setTableName (tableName: string): void { + return this._builder.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getCompleteFromTable} + */ + public getFullTableName (): string { + return this._builder.getFullTableName(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getCompleteFromTable} + */ + public getTableNameWithPrefix (tableName : string): string { + return this._builder.getTableNameWithPrefix(tableName); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.valueOf} + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.toString} + */ + public toString () : string { + return this._builder.toString(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.build} + */ + public build (): QueryBuildResult { + return this._builder.build(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.buildQueryString} + */ + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return this._builder.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + +} diff --git a/data/query/mysql/insert/MySqlInsertQueryBuilder.test.ts b/data/query/mysql/insert/MySqlInsertQueryBuilder.test.ts new file mode 100644 index 0000000..c6790d1 --- /dev/null +++ b/data/query/mysql/insert/MySqlInsertQueryBuilder.test.ts @@ -0,0 +1,335 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { MySqlInsertQueryBuilder } from "./MySqlInsertQueryBuilder"; +import { QueryBuilder } from "../../types/QueryBuilder"; +import { map } from "../../../../functions/map"; + +/** + * Note: This mock can be modified by changing return values of `buildQueryString` and + * `getQueryValueFactories`. The default implementations for `.buildQueryValues()` + * and `.build()` use these two. + */ +export const mockQueryBuilderFactory = (): QueryBuilder => { + let me : QueryBuilder = { + valueOf: jest.fn().mockReturnValue(''), + toString: jest.fn().mockReturnValue(''), + build: jest.fn().mockImplementation(() => [me.buildQueryString(), me.buildQueryValues()]), + buildQueryString: jest.fn().mockReturnValue('query'), + buildQueryValues: jest.fn().mockImplementation(() => map(me.getQueryValueFactories(), item => item())), + getQueryValueFactories: jest.fn().mockReturnValue([() => 'value of query']), + }; + return me; +}; + +describe('MySqlInsertQueryBuilder', () => { + + const tablePrefix = 'db1_'; + const tableName = 'cars'; + // const idColumn = 'car_id'; + const nameColumn = 'car_name'; + const ageColumn = 'car_age'; + const dateColumn = 'car_date'; + // const idProperty = 'carId'; + // const nameProperty = 'carName'; + // const ageProperty = 'carAge'; + // const dateProperty = 'carDate'; + let dateOnlyListBuilder : QueryBuilder; + let nameAndDateListBuilder : QueryBuilder; + + beforeAll( () => { + + dateOnlyListBuilder = mockQueryBuilderFactory(); + (dateOnlyListBuilder.buildQueryString as any).mockReturnValue('date query'); + (dateOnlyListBuilder.getQueryValueFactories as any).mockReturnValue([() => 'value of date query']); + + nameAndDateListBuilder = mockQueryBuilderFactory(); + (nameAndDateListBuilder.buildQueryString as any).mockReturnValue('name query, date query'); + (nameAndDateListBuilder.getQueryValueFactories as any).mockReturnValue([() => 'value of name query', () => 'value of date query']); + + }); + + beforeEach( () => { + jest.clearAllMocks(); + }); + + describe('#create', () => { + + it('can build insert query builder', () => { + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + }); + + }); + + + + describe('#setTablePrefix', () => { + + it('can set table prefix', () => { + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + expect(builder.getTablePrefix()).toBe(tablePrefix); + }); + + }); + + + describe('#setIntoTable', () => { + + it('can set table name where to insert into', () => { + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + expect(builder.getTableName()).toBe(tableName); + }); + + }); + + + describe('#addColumnName', () => { + + it('can add column name', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + builder.addColumnName(nameColumn); + builder.appendValueList(['hello']); + const [ , values ] = builder.build(); + expect( values ).toHaveLength(3); + expect( values[1] ).toBe(nameColumn); + + }); + + }); + + + describe('#appendValueList', () => { + + it('can append value to insert', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + builder.addColumnName(nameColumn); + builder.appendValueList(['hello']); + + const [ , values ] = builder.build(); + expect( values ).toHaveLength(3); + expect( values[2] ).toBe('hello'); + + }); + + }); + + + + describe('#build', () => { + + it('can build insert query for one row with single column', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + builder.addColumnName(nameColumn); + builder.appendValueList(['hello']); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??) VALUES (?)`); + + expect( values ).toHaveLength(3); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe('hello'); + + }); + + it('can build insert query for one row with single date column', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + builder.addColumnName(dateColumn); + + builder.appendValueListUsingQueryBuilder(dateOnlyListBuilder); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??) VALUES (date query)`); + + expect( values ).toHaveLength(3); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(dateColumn); + expect( values[2] ).toBe('value of date query'); + + }); + + it('can build insert query for one row with name and date columns', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + builder.addColumnName(nameColumn); + builder.addColumnName(dateColumn); + + builder.appendValueListUsingQueryBuilder(nameAndDateListBuilder); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??, ??) VALUES (name query, date query)`); + + expect( values ).toHaveLength(5); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe(dateColumn); + expect( values[3] ).toBe('value of name query'); + expect( values[4] ).toBe('value of date query'); + + }); + + it('can build insert query for two rows with name and date columns', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + builder.addColumnName(nameColumn); + builder.addColumnName(dateColumn); + + builder.appendValueListUsingQueryBuilder(nameAndDateListBuilder); + + nameAndDateListBuilder = mockQueryBuilderFactory(); + (nameAndDateListBuilder.buildQueryString as any).mockReturnValue('name query 2, date query 2'); + (nameAndDateListBuilder.getQueryValueFactories as any).mockReturnValue([() => 'value of name query 2', () => 'value of date query 2']); + + builder.appendValueListUsingQueryBuilder(nameAndDateListBuilder); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??, ??) VALUES (name query, date query), (name query 2, date query 2)`); + + expect( values ).toHaveLength(7); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe(dateColumn); + expect( values[3] ).toBe('value of name query'); + expect( values[4] ).toBe('value of date query'); + expect( values[5] ).toBe('value of name query 2'); + expect( values[6] ).toBe('value of date query 2'); + + }); + + it('can build insert query with two columns', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + + + builder.addColumnName(nameColumn); + builder.addColumnName(ageColumn); + builder.appendValueList(['hello', 13]); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??, ??) VALUES (?, ?)`); + + expect( values ).toHaveLength(5); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe(ageColumn); + expect( values[3] ).toBe('hello'); + expect( values[4] ).toBe(13); + + }); + + it('can build insert query with two columns and two rows', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + builder.addColumnName(nameColumn); + builder.addColumnName(ageColumn); + builder.appendValueList(['hello', 13]); + builder.appendValueList(['world', 99]); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??, ??) VALUES (?, ?), (?, ?)`); + + expect( values ).toHaveLength(7); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe(ageColumn); + expect( values[3] ).toBe('hello'); + expect( values[4] ).toBe(13); + expect( values[5] ).toBe('world'); + expect( values[6] ).toBe(99); + + }); + + it('can build insert query from an object', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + builder.addColumnName(nameColumn); + builder.addColumnName(ageColumn); + builder.appendValueObject(['car_name', 'car_age'], {car_name: 'hello', car_age: 13}); + builder.appendValueObject(['car_name', 'car_age'], {car_name: 'world', car_age: 99}); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??, ??) VALUES (?, ?), (?, ?)`); + + expect( values ).toHaveLength(7); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe(ageColumn); + expect( values[3] ).toBe('hello'); + expect( values[4] ).toBe(13); + expect( values[5] ).toBe('world'); + expect( values[6] ).toBe(99); + + }); + + it('can build insert query from an object with extra values', () => { + + const builder = MySqlInsertQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + builder.addColumnName(nameColumn); + builder.addColumnName(ageColumn); + builder.appendValueObject(['car_name', 'car_age'],{car_name: 'hello', car_age: 13, car_term: false}); + builder.appendValueObject(['car_name', 'car_age'],{car_term: true, car_age: 99, car_name: 'world'}); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`INSERT INTO ?? (??, ??) VALUES (?, ?), (?, ?)`); + + expect( values ).toHaveLength(7); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe(ageColumn); + expect( values[3] ).toBe('hello'); + expect( values[4] ).toBe(13); + expect( values[5] ).toBe('world'); + expect( values[6] ).toBe(99); + + }); + + }); + +}); diff --git a/data/query/mysql/insert/MySqlInsertQueryBuilder.ts b/data/query/mysql/insert/MySqlInsertQueryBuilder.ts new file mode 100644 index 0000000..c14fc0b --- /dev/null +++ b/data/query/mysql/insert/MySqlInsertQueryBuilder.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { InsertQueryBuilder } from "../../sql/insert/InsertQueryBuilder"; +import { + MY_PH_COLUMN, + MY_PH_INTO_TABLE, + MY_PH_VALUE +} from "../constants/mysql-queries"; +import { BaseInsertQueryBuilder } from "../../sql/insert/BaseInsertQueryBuilder"; +import { map } from "../../../../functions/map"; +import { reduce } from "../../../../functions/reduce"; +import { has } from "../../../../functions/has"; +import { QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; + +export class MySqlInsertQueryBuilder extends BaseInsertQueryBuilder implements InsertQueryBuilder { + + private readonly _columnNames : string[]; + + protected constructor () { + super(', '); + this._columnNames = []; + this.addPrefixFactory( + () => `INSERT ${MY_PH_INTO_TABLE}`, + () => this.getFullTableName() + ); + } + + public static create () : MySqlInsertQueryBuilder { + return new MySqlInsertQueryBuilder(); + } + + public addPrefixFactory ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + super.addPrefixFactory(queryFactory, ...valueFactories); + } + + public addValueFactory ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + super.addValueFactory(queryFactory, ...valueFactories); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.includeColumn} + */ + public appendValueList ( + list: readonly any[] + ) : void { + const queryString = `(${map(list, () => MY_PH_VALUE).join(', ')})`; + const valueFactories = map(list, (item) => () => item); + this.addValueFactory( + () : string => queryString, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.includeColumn} + */ + public appendValueObject ( + columnNames: readonly string[], + obj: {readonly [key: string] : any} + ) : void { + if (!columnNames?.length) throw new TypeError(`There must be at least one column name`); + this.appendValueList( + reduce( + columnNames, + (list: any[], columnName: string) : any[] => { + if (has(obj, columnName)) { + list.push(obj[columnName]); + } else { + list.push(null); + } + return list; + }, + [] + ) + ); + } + + + public addColumnName ( + name: string + ) : void { + if (this._columnNames.includes(name)) return; + this._columnNames.push(name); + this.addColumnFactory( + () => MY_PH_COLUMN, + () => name + ); + } + + + /////////////////////// InsertQueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return super.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) : void { + super.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + public getTableName (): string { + return super.getTableName(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) : void { + super.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getCompleteFromTable} + */ + public getFullTableName (): string { + return super.getFullTableName(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName : string) : string { + return super.getTableNameWithPrefix(tableName); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.valueOf} + */ + public valueOf () { + return super.valueOf(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.toString} + */ + public toString () : string { + return super.toString(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.build} + */ + public build () : QueryBuildResult { + return super.build(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.buildQueryString} + */ + public buildQueryString () : string { + return super.buildQueryString(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return super.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories () : readonly QueryValueFactory[] { + return super.getQueryValueFactories(); + } + +} diff --git a/data/query/mysql/select/MySqlEntitySelectQueryBuilder.ts b/data/query/mysql/select/MySqlEntitySelectQueryBuilder.ts new file mode 100644 index 0000000..411bfd0 --- /dev/null +++ b/data/query/mysql/select/MySqlEntitySelectQueryBuilder.ts @@ -0,0 +1,531 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MySqlSelectQueryBuilder } from "./MySqlSelectQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { MySqlEntityJsonObjectBuilder } from "../formulas/MySqlEntityJsonObjectBuilder"; +import { MySqlJsonArrayAggBuilder } from "../formulas/MySqlJsonArrayAggBuilder"; +import { EntityField } from "../../../types/EntityField"; +import { EntityRelationOneToMany } from "../../../types/EntityRelationOneToMany"; +import { forEach } from "../../../../functions/forEach"; +import { EntityRelationManyToOne } from "../../../types/EntityRelationManyToOne"; +import { find } from "../../../../functions/find"; +import { EntityFieldType } from "../../../types/EntityFieldType"; +import { Sort } from "../../../Sort"; +import { Where } from "../../../Where"; +import { MySqlAndChainBuilder } from "../formulas/MySqlAndChainBuilder"; +import { ChainQueryBuilderUtils } from "../../utils/ChainQueryBuilderUtils"; +import { MySqlOrChainBuilder } from "../formulas/MySqlOrChainBuilder"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { EntitySelectQueryUtils } from "../../utils/EntitySelectQueryUtils"; +import { EntitySelectQueryBuilder, TableFieldInfoCallback } from "../../sql/select/EntitySelectQueryBuilder"; + +/** + * Defines an interface for a builder of MySQL database read query from + * entity types. + */ +export class MySqlEntitySelectQueryBuilder implements EntitySelectQueryBuilder { + + private readonly _builder : MySqlSelectQueryBuilder; + + protected constructor () { + this._builder = MySqlSelectQueryBuilder.create(); + } + + /** + * Create select query builder for MySQL + */ + public static create () : MySqlEntitySelectQueryBuilder { + return new MySqlEntitySelectQueryBuilder(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setOneToMany} + */ + public setOneToMany ( + propertyName: string, + fields: readonly EntityField[], + temporalProperties: readonly TemporalProperty[], + targetTableName : string, + targetColumnName : string, + sourceTableName : string, + sourceColumnName : string + ) { + const jsonObjectQueryBuilder = new MySqlEntityJsonObjectBuilder(); + jsonObjectQueryBuilder.setEntityFieldsFromTable( this.getTableNameWithPrefix(targetTableName), fields, temporalProperties ); + const jsonArrayAggBuilder = new MySqlJsonArrayAggBuilder(true); + jsonArrayAggBuilder.setFormulaFromQueryBuilder(jsonObjectQueryBuilder); + this._builder.includeColumnFromQueryBuilder(jsonArrayAggBuilder, propertyName); + this._builder.leftJoinTable( + targetTableName, targetColumnName, + sourceTableName, sourceColumnName + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setManyToOne} + */ + public setManyToOne ( + propertyName: string, + fields: readonly EntityField[], + temporalProperties: readonly TemporalProperty[], + targetTableName : string, + targetColumnName : string, + sourceTableName : string, + sourceColumnName : string + ) { + const jsonObjectQueryBuilder = new MySqlEntityJsonObjectBuilder(); + jsonObjectQueryBuilder.setEntityFieldsFromTable(this.getTableNameWithPrefix(targetTableName), fields, temporalProperties); + this._builder.includeColumnFromQueryBuilder(jsonObjectQueryBuilder, propertyName); + this._builder.leftJoinTable( + targetTableName, targetColumnName, + sourceTableName, sourceColumnName + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setOneToManyRelations} + */ + public setOneToManyRelations ( + relations: readonly EntityRelationOneToMany[], + resolveMappedFieldInfo: TableFieldInfoCallback, + ) { + const groupByColumn = this.getGroupByColumn(); + if (!groupByColumn) { + throw new TypeError(`Group by is required for one to many relations`); + } + forEach( + relations, + (relation: EntityRelationOneToMany) : void => { + const { propertyName } = relation; + const mappedTable = relation?.mappedTable; + if (!mappedTable) throw new TypeError(`The relation "${propertyName}" did not have table defined`); + const [mappedFields, mappedTemporalProperties] = resolveMappedFieldInfo(mappedTable); + this.setOneToMany( + propertyName, + mappedFields, + mappedTemporalProperties, + mappedTable, groupByColumn, + this.getTableName(), groupByColumn + ); + } + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setManyToOneRelations} + */ + public setManyToOneRelations ( + relations: readonly EntityRelationManyToOne[], + resolveMappedFieldInfo: TableFieldInfoCallback, + fields: readonly EntityField[] + ) { + forEach( + relations, + (relation: EntityRelationManyToOne) : void => { + const { propertyName } = relation; + const mappedTable = relation?.mappedTable; + if (!mappedTable) throw new TypeError(`The relation "${propertyName}" did not have table defined`); + const [mappedFields, mappedTemporalProperties] = resolveMappedFieldInfo(mappedTable); + const mappedField : EntityField | undefined = find(fields, (field) => field?.propertyName === propertyName && field?.fieldType === EntityFieldType.JOINED_ENTITY); + if (!mappedField) throw new TypeError(`Could not find field definition for property "${propertyName}"`); + const mappedColumnName : string = mappedField.columnName; + if (!mappedColumnName) throw new TypeError(`Could not find column name for property "${propertyName}"`); + this.setManyToOne( + propertyName, + mappedFields, + mappedTemporalProperties, + mappedTable, mappedColumnName, + this.getTableName(), mappedColumnName + ); + } + ); + } + + + /////////////////////// QueryEntityResultable /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeEntityFields} + */ + public includeEntityFields ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ): void { + EntitySelectQueryUtils.includeEntityFields( + this._builder, + tableName, + fields, + temporalProperties + ); + } + + + /////////////////////// QueryResultable /////////////////////// + + buildResultQueryString () : string { + return this._builder.buildResultQueryString(); + } + + getResultValueFactories () : readonly QueryValueFactory[] { + return this._builder.getResultValueFactories(); + } + + appendResultExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryValueFactory[] + ) : void { + return this._builder.appendResultExpression( + queryFactory, + ...valueFactories + ); + } + + appendResultExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + return this._builder.appendResultExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeColumn} + */ + public includeColumn (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumn(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeColumnAsText} + */ + public includeColumnAsText (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumnAsText(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeColumnAsTime} + */ + public includeColumnAsTime (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumnAsTime(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeColumnAsDate} + */ + public includeColumnAsDate (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumnAsDate(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeColumnAsTimestamp} + */ + public includeColumnAsTimestamp (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumnAsTimestamp(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeAllColumnsFromTable} + * @deprecated Use MySqlEntitySelectQueryBuilder.includeEntityFields + */ + public includeAllColumnsFromTable (tableName: string): void { + return this._builder.includeAllColumnsFromTable(tableName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeColumnFromQueryBuilder} + */ + public includeColumnFromQueryBuilder (builder: QueryBuilder, asColumnName: string): void { + return this._builder.includeColumnFromQueryBuilder(builder, asColumnName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeFormulaByString} + */ + public includeFormulaByString (formula: string, asColumnName: string): void { + return this._builder.includeFormulaByString(formula, asColumnName); + } + + + /////////////////////// QueryEntityWhereable /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.buildAnd} + */ + public buildAnd ( + where : Where, + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) { + const completeTableName = this.getTableNameWithPrefix(tableName); + const andBuilder = MySqlAndChainBuilder.create(); + ChainQueryBuilderUtils.buildChain( + andBuilder, + where, + completeTableName, + fields, + temporalProperties, + () => MySqlAndChainBuilder.create(), + () => MySqlOrChainBuilder.create() + ); + return andBuilder; + } + + + /////////////////////// QueryWhereable /////////////////////// + + buildWhereQueryString () : string { + return this._builder.buildWhereQueryString(); + } + + getWhereValueFactories () : readonly QueryValueFactory[] { + return this._builder.getWhereValueFactories(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setWhereFromQueryBuilder} + */ + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + return this._builder.setWhereFromQueryBuilder(builder); + } + + + /////////////////////// QueryLeftJoinable /////////////////////// + + + buildLeftJoinQueryString () : string { + return this._builder.buildLeftJoinQueryString(); + } + + getLeftJoinValueFactories () : readonly QueryValueFactory[] { + return this._builder.getLeftJoinValueFactories(); + } + + appendLeftJoinExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryValueFactory[] + ) : void { + return this._builder.appendLeftJoinExpression( + queryFactory, + ...valueFactories + ); + } + + appendLeftJoinExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + return this._builder.appendLeftJoinExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.leftJoinTable} + */ + public leftJoinTable (fromTableName: string, fromColumnName: string, sourceTableName: string, sourceColumnName: string): void { + return this._builder.leftJoinTable(fromTableName, fromColumnName, sourceTableName, sourceColumnName); + } + + + /////////////////////// QueryOrderable /////////////////////// + + + buildOrderQueryString () : string { + return this._builder.buildOrderQueryString(); + } + + getOrderValueFactories () : readonly QueryStringFactory[] { + return this._builder.getOrderValueFactories(); + } + + appendOrderExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryStringFactory[] + ) : void { + return this._builder.appendOrderExpression( + queryFactory, + ...valueFactories + ); + } + + appendOrderExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryStringFactory[] + ) : void { + return this._builder.appendOrderExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setOrderBy} + */ + public setOrderByTableFields ( + sort : Sort, + tableName : string, + fields : readonly EntityField[] + ): void { + return this._builder.setOrderByTableFields(sort, tableName, fields); + } + + + /////////////////////// QueryGroupable /////////////////////// + + + buildGroupByQueryString () : string{ + return this._builder.buildGroupByQueryString(); + } + + getGroupByValueFactories () : readonly QueryStringFactory[]{ + return this._builder.getGroupByValueFactories(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setGroupByColumn} + */ + public setGroupByColumn (columnName: string): void { + return this._builder.setGroupByColumn(columnName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.getGroupByColumn} + */ + public getGroupByColumn (): string | undefined { + return this._builder.getGroupByColumn(); + } + + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string): void { + return this._builder.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return this._builder.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName: string): string { + return this._builder.getTableNameWithPrefix(tableName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setFromTable} + */ + public setTableName (tableName: string): void { + return this._builder.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.getShortFromTable} + */ + public getTableName (): string { + return this._builder.getTableName(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.getCompleteFromTable} + */ + public getCompleteTableName (): string { + return this._builder.getCompleteTableName(); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.valueOf} + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.toString} + */ + public toString () : string { + return this._builder.toString(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.build} + */ + public build (): QueryBuildResult { + return this._builder.build(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.buildQueryString} + */ + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.buildQueryValues} + */ + public buildQueryValues (): readonly any[] { + return this._builder.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories (): readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + + +} diff --git a/data/query/mysql/select/MySqlSelectQueryBuilder.ts b/data/query/mysql/select/MySqlSelectQueryBuilder.ts new file mode 100644 index 0000000..cc3ff02 --- /dev/null +++ b/data/query/mysql/select/MySqlSelectQueryBuilder.ts @@ -0,0 +1,472 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { SelectQueryBuilder } from "../../sql/select/SelectQueryBuilder"; +import { forEach } from "../../../../functions/forEach"; +import { map } from "../../../../functions/map"; +import { Sort } from "../../../Sort"; +import { EntityField } from "../../../types/EntityField"; +import { EntityUtils } from "../../../utils/EntityUtils"; +import { + MY_PH_AS, MY_PH_FROM_TABLE, + MY_PH_GROUP_BY_TABLE_COLUMN, + MY_PH_LEFT_JOIN, + MY_PH_TABLE_ALL_COLUMNS, + MY_PH_TABLE_COLUMN_AS, + MY_PH_TABLE_COLUMN_AS_DATE_AS, + MY_PH_TABLE_COLUMN_AS_TEXT_AS, + MY_PH_TABLE_COLUMN_AS_TIME_AS, + MY_PH_TABLE_COLUMN_AS_TIMESTAMP_AS, + MY_PH_TABLE_COLUMN_WITH_SORT_ORDER +} from "../constants/mysql-queries"; +import { BaseSelectQueryBuilder } from "../../sql/select/BaseSelectQueryBuilder"; + +export class MySqlSelectQueryBuilder extends BaseSelectQueryBuilder implements SelectQueryBuilder { + + public static create () : MySqlSelectQueryBuilder { + return new MySqlSelectQueryBuilder( + ', ', + ' ', + ', ', + ); + } + + + /////////////////////// QueryResultable /////////////////////// + + + /** + * @inheritDoc + */ + public appendResultExpression ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + super.appendResultExpression( + queryFactory, + ...valueFactories + ); + } + + /** + * @inheritDoc + */ + public appendResultExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : QueryValueFactory[] + ) : void { + super.appendResultExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumn} + */ + public includeColumn ( + tableName: string, + columnName: string, + asColumnName: string + ) : void { + this.appendResultExpression( + () => MY_PH_TABLE_COLUMN_AS, + () => this.getTableNameWithPrefix(tableName), + () => columnName, + () => asColumnName ?? columnName, + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsText} + */ + public includeColumnAsText ( + tableName: string, + columnName: string, + asColumnName: string + ) : void { + this.appendResultExpression( + () => MY_PH_TABLE_COLUMN_AS_TEXT_AS, + () => this.getTableNameWithPrefix(tableName), + () => columnName, + () => asColumnName ?? columnName + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTime} + */ + public includeColumnAsTime ( + tableName: string, + columnName: string, + asColumnName: string + ) : void { + this.appendResultExpression( + () => MY_PH_TABLE_COLUMN_AS_TIME_AS, + () => this.getTableNameWithPrefix(tableName), + () => columnName, + () => asColumnName ?? columnName, + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsDate} + */ + public includeColumnAsDate ( + tableName: string, + columnName: string, + asColumnName: string + ) : void { + this.appendResultExpression( + () => MY_PH_TABLE_COLUMN_AS_DATE_AS, + () => this.getTableNameWithPrefix(tableName), + () => columnName, + () => asColumnName ?? columnName, + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTimestamp} + */ + public includeColumnAsTimestamp ( + tableName: string, + columnName: string, + asColumnName: string + ) : void { + if (!tableName) { + throw new TypeError(`includeColumnAsTimestamp: table name is required`); + } + if (!columnName) { + throw new TypeError(`includeColumnAsTimestamp: columnName is required`); + } + if (!asColumnName) { + throw new TypeError(`includeColumnAsTimestamp: asColumnName is required`); + } + this.appendResultExpression( + () => MY_PH_TABLE_COLUMN_AS_TIMESTAMP_AS, + () => this.getTableNameWithPrefix(tableName), + () => columnName, + () => asColumnName ?? columnName, + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeAllColumnsFromTable} + */ + public includeAllColumnsFromTable (tableName: string) : void { + if (!tableName) { + throw new TypeError(`includeAllColumnsFromTable: table name is required`); + } + this.appendResultExpression( + () => MY_PH_TABLE_ALL_COLUMNS, + () => this.getTableNameWithPrefix(tableName), + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnFromQueryBuilder} + */ + public includeColumnFromQueryBuilder ( + builder: QueryBuilder, + asColumnName: string + ) : void { + if (!asColumnName) { + throw new TypeError(`includeColumnFromQueryBuilder: column name is required`); + } + this.appendResultExpression( + () => MY_PH_AS(builder.buildQueryString()), + ...builder.getQueryValueFactories(), + () => asColumnName + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeFormulaByString} + */ + public includeFormulaByString ( + formula: string, + asColumnName: string + ): void { + if (!formula) { + throw new TypeError(`includeFormulaByString: formula is required`); + } + if (!asColumnName) { + throw new TypeError(`includeFormulaByString: column name is required`); + } + this.appendResultExpression( + () => MY_PH_AS(formula), + () => asColumnName + ); + } + + /////////////////////// QueryLeftJoinable /////////////////////// + + + /** + * @inheritDoc + */ + appendLeftJoinExpression ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + super.appendLeftJoinExpression( + queryFactory, + ...valueFactories + ); + } + + /** + * @inheritDoc + */ + appendLeftJoinExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : QueryValueFactory[] + ) : void { + super.appendLeftJoinExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.leftJoinTable} + */ + public leftJoinTable ( + fromTableName : string, + fromColumnName : string, + sourceTableName : string, + sourceColumnName : string + ) { + this.appendLeftJoinExpression( + () => MY_PH_LEFT_JOIN, + () => this.getTableNameWithPrefix(fromTableName), + () => this.getTableNameWithPrefix(sourceTableName), + () => sourceColumnName, + () => this.getTableNameWithPrefix(fromTableName), + () => fromColumnName, + ) + } + + + /////////////////////// QueryOrderable /////////////////////// + + /** + * @inheritDoc + */ + appendOrderExpression ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + super.appendOrderExpression( + queryFactory, + ...valueFactories + ); + } + + /** + * @inheritDoc + */ + appendOrderExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : QueryValueFactory[] + ) : void { + super.appendOrderExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setOrderByTableFields} + */ + public setOrderByTableFields ( + sort : Sort, + tableName : string, + fields : readonly EntityField[] + ) : void { + forEach( + sort.getSortOrders(), + (item) => { + this.appendOrderExpression( + () => MY_PH_TABLE_COLUMN_WITH_SORT_ORDER(item.getDirection()), + () => this.getTableNameWithPrefix(tableName), + () => EntityUtils.getColumnName(item.getProperty(), fields) + ); + } + ); + } + + + + /////////////////////// QueryGroupable /////////////////////// + + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setGroupByColumn} + */ + public setGroupByColumn (columnName: string) { + super.setGroupByColumn(columnName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getGroupByColumn} + */ + public getGroupByColumn (): string | undefined { + return super.getGroupByColumn(); + } + + + + /////////////////////// TablePrefixable /////////////////////// + + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setTablePrefix} + * @see {@link EntitySelectQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) { + super.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getTablePrefix} + * @see {@link EntitySelectQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return super.getTablePrefix(); + } + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) { + super.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getCompleteFromTable} + */ + public getCompleteTableName (): string { + return super.getCompleteTableName(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getShortFromTable} + */ + public getTableName (): string { + return super.getTableName(); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.valueOf} + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.toString} + */ + public toString () : string { + return `"${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.build} + */ + public build () : QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.buildQueryString} + */ + public buildQueryString () : string { + let query = `SELECT ${this.buildResultQueryString()}`; + + const fromTableName = this.getTableName(); + if (fromTableName) { + query += ` ${MY_PH_FROM_TABLE}`; + } + + const leftJoinQuery = this.buildLeftJoinQueryString(); + if (leftJoinQuery) { + query += ` ${leftJoinQuery}`; + } + + const whereQuery = this.buildWhereQueryString(); + if (whereQuery) { + query += ` WHERE ${whereQuery}`; + } + + const groupByColumn = this.getGroupByColumn(); + if ( groupByColumn ) { + query += ` ${MY_PH_GROUP_BY_TABLE_COLUMN}`; + } + + const orderBys = this.buildOrderQueryString(); + if ( orderBys ) { + query += ` ORDER BY ${ orderBys }`; + } + + return query; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return map(this.getQueryValueFactories(), (f) => f()); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories (): readonly QueryValueFactory[] { + const fromTableName = this.getTableName(); + const groupByColumn = this.getGroupByColumn(); + return [ + ...this.getResultValueFactories(), + ...( fromTableName ? [() => fromTableName ? this.getTableNameWithPrefix(fromTableName) : fromTableName] : []), + ...this.getLeftJoinValueFactories(), + ...this.getWhereValueFactories(), + ...( fromTableName && groupByColumn ? [ + () => fromTableName ? this.getTableNameWithPrefix(fromTableName) : fromTableName, + () => groupByColumn + ] : []), + ...this.getOrderValueFactories() + ] + } + + +} diff --git a/data/query/mysql/types/MySqlListQueryBuilder.ts b/data/query/mysql/types/MySqlListQueryBuilder.ts new file mode 100644 index 0000000..2f32858 --- /dev/null +++ b/data/query/mysql/types/MySqlListQueryBuilder.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { BaseListQueryBuilder } from "../../types/BaseListQueryBuilder"; +import { MY_PH_TABLE_COLUMN, MY_PH_TABLE_COLUMN_AS_TEXT, MY_PH_FROM_TIMESTAMP_TABLE_COLUMN_AS_TIMESTAMP, MY_PH_VALUE, MY_PH_VALUE_AS_TEXT, MY_PH_VALUE_TO_ISO_STRING, MY_PH_ASSIGN_VALUE, MY_PH_ASSIGN_TIMESTAMP_VALUE } from "../constants/mysql-queries"; +import { EntityUtils } from "../../../utils/EntityUtils"; + +export class MySqlListQueryBuilder extends BaseListQueryBuilder { + + protected constructor (separator : string) { + super(separator); + } + + public static create ( + separator ?: string + ) : MySqlListQueryBuilder { + return new MySqlListQueryBuilder( separator ?? ', ' ); + } + + + + + /////////////////// ListQueryBuilder /////////////////// + + + + /** + * @inheritDoc + */ + public setTableColumn (tableName: string, columnName: string): void { + this.appendExpression( + () => MY_PH_TABLE_COLUMN, + () => tableName, + () => columnName + ); + } + + /** + * @inheritDoc + */ + public setTableColumnAsText (tableName: string, columnName: string): void { + this.appendExpression( + () => MY_PH_TABLE_COLUMN_AS_TEXT, + () => tableName, + () => columnName + ); + } + + /** + * @inheritDoc + */ + public setTableColumnAsTimestampString (tableName: string, columnName: string): void { + this.appendExpression( + () => MY_PH_FROM_TIMESTAMP_TABLE_COLUMN_AS_TIMESTAMP, + () => tableName, + () => columnName + ); + } + + /** + * @inheritDoc + */ + public setParam (value: any): void { + this.appendExpression( + () => MY_PH_VALUE, + () => value + ); + } + + /** + * @inheritDoc + */ + public setParamFactory ( + factory: () => any + ): void { + this.appendExpression( + () => MY_PH_VALUE, + factory + ); + } + + /** + * @inheritDoc + */ + public setParamAsText (value: any): void { + this.appendExpression( + () => MY_PH_VALUE_AS_TEXT, + () => value + ); + } + + /** + * @inheritDoc + */ + public setParamFromTimestampString (value: any): void { + this.appendExpression( + () => MY_PH_VALUE, + () => EntityUtils.parseIsoStringAsMySQLDateString(value) + ); + } + + /** + * @inheritDoc + */ + public setParamFromJson (value: any): void { + this.appendExpression( + () => MY_PH_VALUE, + () => EntityUtils.toJsonString(value) + ); + } + + /** + * @inheritDoc + */ + public setParamAsTimestampValue (value: any): void { + this.appendExpression( + () => MY_PH_VALUE_TO_ISO_STRING, + () => value + ); + } + + /** + * @inheritDoc + */ + public setAssignmentWithParam ( + columnName: string, + value: any + ) : void { + this.appendExpression( + () => MY_PH_ASSIGN_VALUE, + () => columnName, + () => value + ); + } + + /** + * @inheritDoc + */ + public setAssignmentWithParamAsTimestamp ( + columnName: string, + value: any + ) : void { + this.appendExpression( + () => MY_PH_ASSIGN_TIMESTAMP_VALUE, + () => columnName, + () => EntityUtils.parseIsoStringAsMySQLDateString(value) + ); + } + + /** + * @inheritDoc + */ + public setAssignmentWithParamAsJson ( + columnName: string, + value: any + ) : void { + this.appendExpression( + () => MY_PH_ASSIGN_VALUE, + () => columnName, + () => EntityUtils.toJsonString(value) + ); + } + +} diff --git a/data/query/mysql/update/MySqlEntityUpdateQueryBuilder.test.ts b/data/query/mysql/update/MySqlEntityUpdateQueryBuilder.test.ts new file mode 100644 index 0000000..400e8c5 --- /dev/null +++ b/data/query/mysql/update/MySqlEntityUpdateQueryBuilder.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MySqlEntityUpdateQueryBuilder } from "./MySqlEntityUpdateQueryBuilder"; +import { createEntityMetadata, EntityMetadata } from "../../../types/EntityMetadata"; +import { Entity } from "../../../Entity"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { createEntityField, EntityField } from "../../../types/EntityField"; +import { EntityRelationOneToMany } from "../../../types/EntityRelationOneToMany"; +import { EntityRelationManyToOne } from "../../../types/EntityRelationManyToOne"; +import { Table } from "../../../Table"; +import { Id } from "../../../Id"; +import { Column } from "../../../Column"; +import { EntityFieldType } from "../../../types/EntityFieldType"; +import { MySqlAndChainBuilder } from "../formulas/MySqlAndChainBuilder"; + +describe('MySqlEntityUpdateQueryBuilder', () => { + + const tablePrefix = 'db1_'; + const tableName = 'cars'; + const idColumn = 'car_id'; + const idPropertyName = 'carId'; + const nameColumn = 'car_name'; + const ageColumn = 'car_age'; + const termColumn = 'car_term'; + const idProperty = 'carId'; + const nameProperty = 'carName'; + const ageProperty = 'carAge'; + // const termProperty = 'carTerm'; + + @Table(tableName) + class CarEntity extends Entity { + + constructor (id ?: string | CarEntity, name ?: string, age?: number, term?: boolean) { + super(); + if ( id && id instanceof CarEntity ) { + this.carId = id.carId; + this.carName = id.carName; + this.carAge = id.carAge; + this.carTerm = id.carTerm; + } else { + this.carId = id; + this.carName = name; + this.carAge = age; + this.carTerm = term; + } + } + + @Id() + @Column(idColumn) + public carId ?: string | CarEntity; + + @Column(nameColumn) + public carName ?: string; + + @Column(ageColumn) + public carAge ?: number; + + @Column(termColumn, 'BOOL') + public carTerm ?: boolean; + + } + + let fields: EntityField[]; + let oneToManyRelations : EntityRelationOneToMany[]; + let manyToOneRelations : EntityRelationManyToOne[]; + let temporalProperties : TemporalProperty[]; + let createEntity : () => Entity; + + let metadata : EntityMetadata; + let carEntity1 : CarEntity; + // let carEntity2 : CarEntity; + // let carEntity3 : CarEntity; + // let entities : readonly Entity[]; + + beforeEach( () => { + + createEntity = () : Entity => new CarEntity(); + + fields = [ + createEntityField( + idProperty, + idColumn, + undefined, + undefined, + EntityFieldType.UNKNOWN, + undefined + ), + createEntityField( + nameProperty, + nameColumn, + undefined, + undefined, + EntityFieldType.UNKNOWN, + undefined + ), + createEntityField( + ageProperty, + ageColumn, + undefined, + undefined, + EntityFieldType.UNKNOWN, + undefined + ), + ]; + + oneToManyRelations = []; + manyToOneRelations = []; + temporalProperties = []; + + metadata = createEntityMetadata( + tableName, + idPropertyName, + fields, + oneToManyRelations, + manyToOneRelations, + temporalProperties, + createEntity, + [], + [], + [] + ); + + carEntity1 = new CarEntity('1', 'Car A', 13, true); + // carEntity2 = new CarEntity('2', 'Car B', 99, false); + // carEntity3 = new CarEntity('3', 'Car C', 3, true); + + // entities = [ + // carEntity1, + // carEntity2, + // carEntity3, + // ]; + + }) + + describe('create', () => { + + it('can build update query builder', () => { + const builder = MySqlEntityUpdateQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + builder.appendEntity( + carEntity1, + metadata.fields, + metadata.temporalProperties, + [idPropertyName] + ); + + const where = MySqlAndChainBuilder.create(); + where.setColumnEquals(tablePrefix+tableName, idColumn, "1"); + builder.setWhereFromQueryBuilder(where); + + const [ queryString, values ] = builder.build(); + + expect( queryString ).toBe(`UPDATE ?? SET ?? = ?, ?? = ? WHERE (??.?? = ?)`); + expect( values ).toHaveLength(8); + + // Table name + expect( values[0] ).toBe(tablePrefix+tableName); + + // 1st column in set + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe('Car A'); + + // 2nd column in set + expect( values[3] ).toBe(ageColumn); + expect( values[4] ).toBe(13); + + // Table in WHERE + expect( values[5] ).toBe(tablePrefix+tableName); + expect( values[6] ).toBe(idColumn); + expect( values[7] ).toBe("1"); + + }); + + }); + +}); diff --git a/data/query/mysql/update/MySqlEntityUpdateQueryBuilder.ts b/data/query/mysql/update/MySqlEntityUpdateQueryBuilder.ts new file mode 100644 index 0000000..df6580b --- /dev/null +++ b/data/query/mysql/update/MySqlEntityUpdateQueryBuilder.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityField } from "../../../types/EntityField"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { MySqlUpdateQueryBuilder } from "./MySqlUpdateQueryBuilder"; +import { EntityUpdateQueryBuilder } from "../../sql/update/EntityUpdateQueryBuilder"; +import { Entity } from "../../../Entity"; +import { forEach } from "../../../../functions/forEach"; +import { has } from "../../../../functions/has"; +import { find } from "../../../../functions/find"; +import { MySqlListQueryBuilder } from "../types/MySqlListQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { isJsonColumnDefinition, isTimeColumnDefinition } from "../../../types/ColumnDefinition"; + +/** + * Defines an interface for a builder of MySQL database read query from + * entity types. + */ +export class MySqlEntityUpdateQueryBuilder implements EntityUpdateQueryBuilder { + + private readonly _builder : MySqlUpdateQueryBuilder; + + protected constructor () { + this._builder = MySqlUpdateQueryBuilder.create(); + } + + /** + * Create select query builder for MySQL + */ + public static create () : MySqlEntityUpdateQueryBuilder { + return new MySqlEntityUpdateQueryBuilder(); + } + + + /////////////////////// EntityUpdateQueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public appendEntity ( + entity : T, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + ignoreProperties : readonly string[], + ) : void { + const setAssigmentBuilder = MySqlListQueryBuilder.create(); + forEach( + fields, + (field: EntityField) => { + + // FIXME: This code is almost identical to the MySqlEntityInsertQueryBuilder. Consider moving it as a utility function. + + const { propertyName, columnName, columnDefinition, updatable } = field; + if (ignoreProperties.includes(propertyName)) return; + if (!updatable) return; + + const temporalProperty : TemporalProperty | undefined = find( + temporalProperties, + (item: TemporalProperty) : boolean => item.propertyName === propertyName + ); + const temporalType = temporalProperty?.temporalType; + const value : any = has(entity, propertyName) ? (entity as any)[propertyName] : null; + + const isTime : boolean = !!temporalType || isTimeColumnDefinition(columnDefinition); + + if ( isTime ) { + setAssigmentBuilder.setAssignmentWithParamAsTimestamp(columnName, value); + return; + } + + const isJson : boolean = !isTime ? isJsonColumnDefinition(columnDefinition) : false; + if (isJson) { + setAssigmentBuilder.setAssignmentWithParamAsJson(columnName, value); + return; + } + + setAssigmentBuilder.setAssignmentWithParam(columnName, value); + + } + ); + this._builder.appendSetListUsingQueryBuilder(setAssigmentBuilder); + } + + + + /////////////////////// UpdateQueryBuilder /////////////////////// + + + public addPrefixFactory (queryFactory: QueryStringFactory, ...valueFactories: readonly QueryValueFactory[]): void { + this._builder.addPrefixFactory(queryFactory, ...valueFactories); + } + + public addSetFactory (queryFactory: QueryStringFactory, ...valueFactories: readonly QueryValueFactory[]): void { + this._builder.addSetFactory(queryFactory, ...valueFactories); + } + + public appendSetListUsingQueryBuilder (builder: QueryBuilder): void { + this._builder.appendSetListUsingQueryBuilder(builder); + } + + + /////////////////////// TableWhereable /////////////////////// + + + buildWhereQueryString () : string { + return this._builder.buildWhereQueryString(); + } + + getWhereValueFactories () : readonly QueryValueFactory[] { + return this._builder.getWhereValueFactories(); + } + + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + this._builder.setWhereFromQueryBuilder(builder); + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string): void { + return this._builder.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return this._builder.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getCompleteFromTable} + */ + public getTableNameWithPrefix (tableName : string): string { + return this._builder.getTableNameWithPrefix(tableName); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getCompleteFromTable} + */ + public setTableName (tableName: string): void { + return this._builder.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getCompleteFromTable} + */ + public getTableName (): string { + return this._builder.getTableName(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getCompleteFromTable} + */ + public getCompleteTableName (): string { + return this._builder.getCompleteTableName(); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.valueOf} + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.toString} + */ + public toString () : string { + return this._builder.toString(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.build} + */ + public build (): QueryBuildResult { + return this._builder.build(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.buildQueryString} + */ + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return this._builder.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories (): readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + + +} diff --git a/data/query/mysql/update/MySqlUpdateQueryBuilder.test.ts b/data/query/mysql/update/MySqlUpdateQueryBuilder.test.ts new file mode 100644 index 0000000..3b270c3 --- /dev/null +++ b/data/query/mysql/update/MySqlUpdateQueryBuilder.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { MySqlUpdateQueryBuilder } from "./MySqlUpdateQueryBuilder"; +import { QueryBuilder } from "../../types/QueryBuilder"; +import { map } from "../../../../functions/map"; +import { MySqlListQueryBuilder } from "../types/MySqlListQueryBuilder"; +import { MySqlAndChainBuilder } from "../formulas/MySqlAndChainBuilder"; + +/** + * Note: This mock can be modified by changing return values of `buildQueryString` and + * `getQueryValueFactories`. The default implementations for `.buildQueryValues()` + * and `.build()` use these two. + */ +export const mockQueryBuilderFactory = (): QueryBuilder => { + let me : QueryBuilder = { + valueOf: jest.fn().mockReturnValue(''), + toString: jest.fn().mockReturnValue(''), + build: jest.fn().mockImplementation(() => [me.buildQueryString(), me.buildQueryValues()]), + buildQueryString: jest.fn().mockReturnValue('query'), + buildQueryValues: jest.fn().mockImplementation(() => map(me.getQueryValueFactories(), item => item())), + getQueryValueFactories: jest.fn().mockReturnValue([() => 'value of query']), + }; + return me; +}; + +describe('MySqlUpdateQueryBuilder', () => { + + const tablePrefix = 'db1_'; + const tableName = 'cars'; + const idColumn = 'car_id'; + const nameColumn = 'car_name'; + // const ageColumn = 'car_age'; + const dateColumn = 'car_date'; + // const idProperty = 'carId'; + // const nameProperty = 'carName'; + // const ageProperty = 'carAge'; + // const dateProperty = 'carDate'; + const dateValue = '2023-04-04T14:58:59Z'; + const dateValueInDb = '2023-04-04 14:58:59'; + let dateOnlyListBuilder : QueryBuilder; + let nameAndDateListBuilder : QueryBuilder; + + beforeAll( () => { + + dateOnlyListBuilder = mockQueryBuilderFactory(); + (dateOnlyListBuilder.buildQueryString as any).mockReturnValue('date query'); + (dateOnlyListBuilder.getQueryValueFactories as any).mockReturnValue([() => 'value of date query']); + + nameAndDateListBuilder = mockQueryBuilderFactory(); + (nameAndDateListBuilder.buildQueryString as any).mockReturnValue('name query, date query'); + (nameAndDateListBuilder.getQueryValueFactories as any).mockReturnValue([() => 'value of name query', () => 'value of date query']); + + }); + + beforeEach( () => { + jest.clearAllMocks(); + }); + + describe('#create', () => { + + it('can build update query builder', () => { + const builder = MySqlUpdateQueryBuilder.create(); + expect( builder ).toBeDefined(); + }); + + }); + + describe('#setTablePrefix', () => { + + it('can set table prefix', () => { + const builder = MySqlUpdateQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + expect(builder.getTablePrefix()).toBe(tablePrefix); + }); + + }); + + describe('#setTableName', () => { + + it('can set table name which to update', () => { + const builder = MySqlUpdateQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + expect(builder.getTableName()).toBe(tableName); + }); + + }); + + describe('#getCompleteTableName', () => { + + it('can get full table name which to update', () => { + const builder = MySqlUpdateQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + expect(builder.getCompleteTableName()).toBe(tablePrefix+tableName); + }); + + }); + + describe('#build', () => { + + it('can build update query with single column', () => { + + const builder = MySqlUpdateQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + const setList = MySqlListQueryBuilder.create(); + setList.setAssignmentWithParam(nameColumn, 'hello'); + builder.appendSetListUsingQueryBuilder(setList); + + const where = MySqlAndChainBuilder.create(); + where.setColumnEquals(tablePrefix+tableName, idColumn, "1"); + builder.setWhereFromQueryBuilder(where); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`UPDATE ?? SET ?? = ? WHERE (??.?? = ?)`); + + expect( values ).toHaveLength(6); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe('hello'); + expect( values[3] ).toBe(tablePrefix+tableName); + expect( values[4] ).toBe(idColumn); + expect( values[5] ).toBe("1"); + + }); + + it('can build update query with single date column', () => { + + const builder = MySqlUpdateQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + const setList = MySqlListQueryBuilder.create(); + setList.setAssignmentWithParamAsTimestamp(dateColumn, dateValue); + builder.appendSetListUsingQueryBuilder(setList); + + const where = MySqlAndChainBuilder.create(); + where.setColumnEquals(tablePrefix+tableName, idColumn, "1"); + builder.setWhereFromQueryBuilder(where); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`UPDATE ?? SET ?? = DATE_FORMAT(?, '%Y-%m-%d %H:%i:%s') WHERE (??.?? = ?)`); + + expect( values ).toHaveLength(6); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(dateColumn); + expect( values[2] ).toBe(dateValueInDb); + expect( values[3] ).toBe(tablePrefix+tableName); + expect( values[4] ).toBe(idColumn); + expect( values[5] ).toBe("1"); + + }); + + it('can build update query for one row with name and date columns', () => { + + const builder = MySqlUpdateQueryBuilder.create(); + expect( builder ).toBeDefined(); + builder.setTablePrefix(tablePrefix); + builder.setTableName(tableName); + + const setList = MySqlListQueryBuilder.create(); + setList.setAssignmentWithParam(nameColumn, 'hello'); + setList.setAssignmentWithParamAsTimestamp(dateColumn, dateValue); + builder.appendSetListUsingQueryBuilder(setList); + + const where = MySqlAndChainBuilder.create(); + where.setColumnEquals(tablePrefix+tableName, idColumn, "1"); + builder.setWhereFromQueryBuilder(where); + + const [ queryString, values ] = builder.build(); + expect( queryString ).toBe(`UPDATE ?? SET ?? = ?, ?? = DATE_FORMAT(?, '%Y-%m-%d %H:%i:%s') WHERE (??.?? = ?)`); + + expect( values ).toHaveLength(8); + expect( values[0] ).toBe(tablePrefix+tableName); + expect( values[1] ).toBe(nameColumn); + expect( values[2] ).toBe('hello'); + expect( values[3] ).toBe(dateColumn); + expect( values[4] ).toBe(dateValueInDb); + expect( values[5] ).toBe(tablePrefix+tableName); + expect( values[6] ).toBe(idColumn); + expect( values[7] ).toBe("1"); + + }); + + }); + +}); diff --git a/data/query/mysql/update/MySqlUpdateQueryBuilder.ts b/data/query/mysql/update/MySqlUpdateQueryBuilder.ts new file mode 100644 index 0000000..66913c2 --- /dev/null +++ b/data/query/mysql/update/MySqlUpdateQueryBuilder.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { UpdateQueryBuilder } from "../../sql/update/UpdateQueryBuilder"; +import { + MY_PH_TABLE_NAME +} from "../constants/mysql-queries"; +import { BaseUpdateQueryBuilder } from "../../sql/update/BaseUpdateQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; + +export class MySqlUpdateQueryBuilder extends BaseUpdateQueryBuilder { + + protected constructor () { + super(); + this.addPrefixFactory( + () => `UPDATE ${MY_PH_TABLE_NAME}`, + () => this.getCompleteTableName() + ); + } + + public static create () : MySqlUpdateQueryBuilder { + return new MySqlUpdateQueryBuilder(); + } + + + /////////////////////// BaseUpdateQueryBuilder /////////////////////// + + + + + + /////////////////////// UpdateQueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public addPrefixFactory ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + super.addPrefixFactory(queryFactory, ...valueFactories); + } + + /** + * @inheritDoc + */ + public addSetFactory ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + super.addSetFactory(queryFactory, ...valueFactories); + } + + /** + * @inheritDoc + */ + public appendSetListUsingQueryBuilder (builder: QueryBuilder) : void { + super.appendSetListUsingQueryBuilder(builder); + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return super.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) : void { + super.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getTablePrefix} + */ + public getTableName (): string { + return super.getTableName(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) : void { + super.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getCompleteFromTable} + */ + public getCompleteTableName (): string { + return super.getCompleteTableName(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName : string) : string { + return super.getTableNameWithPrefix(tableName); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.valueOf} + */ + public valueOf () { + return super.valueOf(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.toString} + */ + public toString () : string { + return super.toString(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.build} + */ + public build () : QueryBuildResult { + return super.build(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.buildQueryString} + */ + public buildQueryString () : string { + return super.buildQueryString(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return super.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories (): readonly QueryValueFactory[] { + return super.getQueryValueFactories(); + } + +} diff --git a/data/query/pg/delete/PgDeleteQueryBuilder.ts b/data/query/pg/delete/PgDeleteQueryBuilder.ts new file mode 100644 index 0000000..85fdea6 --- /dev/null +++ b/data/query/pg/delete/PgDeleteQueryBuilder.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { DeleteQueryBuilder } from "../../sql/delete/DeleteQueryBuilder"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; + +export class PgDeleteQueryBuilder implements DeleteQueryBuilder { + + private _tableName : string | undefined; + private _tablePrefix : string = ''; + private _where : QueryBuilder | undefined; + + public constructor () { + this._tableName = undefined; + this._where = undefined; + this._tablePrefix = ''; + } + + + + /////////////////////// QueryWhereable /////////////////////// + + + buildWhereQueryString () : string { + return this._where ? this._where.buildQueryString() : ''; + } + + getWhereValueFactories () : readonly QueryValueFactory[] { + return this._where ? this._where.getQueryValueFactories() : []; + } + + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + this._where = builder; + } + + + + /////////////////////// TablePrefixable /////////////////////// + + + + public setTablePrefix (prefix: string) { + this._tablePrefix = prefix; + } + + public getTablePrefix (): string { + return this._tablePrefix; + } + + public getTableNameWithPrefix (tableName : string) : string { + return `${this._tablePrefix}${tableName}`; + } + + public setTableName (tableName: string) { + this._tableName = tableName; + } + + public getTableName (): string { + if (!this._tableName) throw new TypeError(`From table has not been initialized yet`); + return this._tableName; + } + + public getCompleteTableName (): string { + if (!this._tableName) throw new TypeError(`From table has not been initialized yet`); + return this.getTableNameWithPrefix(this._tableName); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + */ + public toString () : string { + return `PgDeleteQueryBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + /** + * @inheritDoc + */ + public build () : QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + /** + * @inheritDoc + */ + public buildQueryString () : string { + let query = `DELETE FROM ${PgQueryUtils.quoteTableName(this.getCompleteTableName())}`; + if (this._where) { + query += ` WHERE ${this._where.buildQueryString()}`; + } + return query; + } + + /** + * @inheritDoc + */ + public buildQueryValues () : readonly any[] { + return [ + ...( this._where ? this._where.buildQueryValues() : []) + ]; + } + + /** + * @inheritDoc + */ + public getQueryValueFactories (): readonly QueryValueFactory[] { + return [ + ...( this._where ? this._where.getQueryValueFactories() : []) + ]; + } + + +} diff --git a/data/query/pg/delete/PgEntityDeleteQueryBuilder.ts b/data/query/pg/delete/PgEntityDeleteQueryBuilder.ts new file mode 100644 index 0000000..9693373 --- /dev/null +++ b/data/query/pg/delete/PgEntityDeleteQueryBuilder.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { PgDeleteQueryBuilder } from "./PgDeleteQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { EntityField } from "../../../types/EntityField"; +import { PgAndChainBuilder } from "../formulas/PgAndChainBuilder"; +import { Where } from "../../../Where"; +import { ChainQueryBuilderUtils } from "../../utils/ChainQueryBuilderUtils"; +import { PgOrChainBuilder } from "../formulas/PgOrChainBuilder"; +import { EntityDeleteQueryBuilder } from "../../sql/delete/EntityDeleteQueryBuilder"; +import { TemporalProperty } from "../../../types/TemporalProperty"; + +export class PgEntityDeleteQueryBuilder implements EntityDeleteQueryBuilder { + + private _builder : PgDeleteQueryBuilder; + + public constructor () { + this._builder = new PgDeleteQueryBuilder(); + } + + + /////////////////////// EntityDeleteQueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public buildAnd ( + where : Where, + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + ) : PgAndChainBuilder { + const completeTableName = this.getTableNameWithPrefix(tableName); + const andBuilder = PgAndChainBuilder.create(); + ChainQueryBuilderUtils.buildChain(andBuilder, where, completeTableName, fields, temporalProperties, () => PgAndChainBuilder.create(), () => PgOrChainBuilder.create()); + return andBuilder; + } + + + /////////////////////// QueryWhereable /////////////////////// + + + buildWhereQueryString () : string { + return this._builder.buildWhereQueryString(); + } + + getWhereValueFactories () : readonly QueryValueFactory[] { + return this._builder.getWhereValueFactories(); + } + + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + return this._builder.setWhereFromQueryBuilder(builder); + } + + + /////////////////////// TablePrefixable /////////////////////// + + + public setTablePrefix (prefix: string): void { + return this._builder.setTablePrefix(prefix); + } + + public getTablePrefix (): string { + return this._builder.getTablePrefix(); + } + + public getTableNameWithPrefix (tableName: string): string { + return this._builder.getTableNameWithPrefix(tableName); + } + + public setTableName (tableName: string): void { + return this._builder.setTableName(tableName); + } + + public getTableName (): string { + return this._builder.getTableName(); + } + + public getCompleteTableName (): string { + return this._builder.getCompleteTableName(); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgEntityDeleteQueryBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build (): QueryBuildResult { + return this._builder.build(); + } + + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + public buildQueryValues (): readonly any[] { + return this._builder.buildQueryValues(); + } + + public getQueryValueFactories (): readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + + +} diff --git a/data/query/pg/formulas/PgAndChainBuilder.ts b/data/query/pg/formulas/PgAndChainBuilder.ts new file mode 100644 index 0000000..0cb40bf --- /dev/null +++ b/data/query/pg/formulas/PgAndChainBuilder.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../../functions/map"; +import { forEach } from "../../../../functions/forEach"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; +import { PgParameterListBuilder } from "./PgParameterListBuilder"; +import { ChainQueryBuilder } from "../../types/ChainQueryBuilder"; + +/** + * Generates formulas like `entity1[ AND entity2` + */ +export class PgAndChainBuilder implements ChainQueryBuilder { + + private readonly _formulaQuery : (() => string)[]; + private readonly _formulaValues : QueryValueFactory[]; + + protected constructor () { + this._formulaQuery = []; + this._formulaValues = []; + } + + public static create () : PgAndChainBuilder { + return new PgAndChainBuilder(); + } + + + + public setColumnInList ( + tableName : string, + columnName : string, + values : readonly any[] + ) { + const builder = new PgParameterListBuilder(); + builder.setParams(values); + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} IN (${builder.buildQueryString()})` ); + forEach( + builder.getQueryValueFactories(), + (f) => this._formulaValues.push(f) + ); + } + + public setColumnEquals ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} = ${PgQueryUtils.getValuePlaceholder()}` ); + this._formulaValues.push(() => value); + } + + public setColumnEqualsAsJson ( + tableName : string, + columnName : string, + value : any + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumnAsJsonB(tableName, columnName)} = ${PgQueryUtils.getValuePlaceholderAsJsonB()}` ); + this._formulaValues.push(() => value); + } + + public setColumnIsNull ( + tableName : string, + columnName : string + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} IS NULL` ); + } + + public setColumnBetween ( + tableName : string, + columnName : string, + start : any, + end : any, + ) { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} BETWEEN ${PgQueryUtils.getValuePlaceholder()} AND ${PgQueryUtils.getValuePlaceholder()}` ); + this._formulaValues.push(() => start); + this._formulaValues.push(() => end); + } + + public setColumnBefore ( + tableName : string, + columnName : string, + value : any, + ) { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} < ${PgQueryUtils.getValuePlaceholder()}` ); + this._formulaValues.push(() => value); + } + + public setColumnAfter ( + tableName : string, + columnName : string, + value : any, + ) { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} > ${PgQueryUtils.getValuePlaceholder()}` ); + this._formulaValues.push(() => value); + } + + + + public setColumnInListAsTime ( + tableName : string, + columnName : string, + values : readonly any[] + ) { + const builder = new PgParameterListBuilder(); + builder.setParams(values); + // FIXME: This does not support .getValuePlaceholderAsTimestamp + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} IN (${builder.buildQueryString()})` ); + forEach( + builder.getQueryValueFactories(), + (f) => this._formulaValues.push(f) + ); + } + + public setColumnEqualsAsTime ( + tableName : string, + columnName : string, + value : any + ) { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} = ${PgQueryUtils.getValuePlaceholderAsTimestamp()}` ); + this._formulaValues.push(() => value); + } + + public setColumnBetweenAsTime ( + tableName : string, + columnName : string, + start : any, + end : any, + ) { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} BETWEEN ${PgQueryUtils.getValuePlaceholderAsTimestamp()} AND ${PgQueryUtils.getValuePlaceholderAsTimestamp()}` ); + this._formulaValues.push(() => start); + this._formulaValues.push(() => end); + } + + public setColumnBeforeAsTime ( + tableName : string, + columnName : string, + value : any, + ) { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} < ${PgQueryUtils.getValuePlaceholderAsTimestamp()}` ); + this._formulaValues.push(() => value); + } + + public setColumnAfterAsTime ( + tableName : string, + columnName : string, + value : any, + ) { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} > ${PgQueryUtils.getValuePlaceholderAsTimestamp()}` ); + this._formulaValues.push(() => value); + } + + + + + public setFromQueryBuilder ( + builder: QueryBuilder + ) { + this._formulaQuery.push( () => builder.buildQueryString() ); + builder.getQueryValueFactories().forEach((factory)=> { + this._formulaValues.push(factory); + }); + } + + + + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgAndChainBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + public buildQueryString (): string { + const formulaQuery = map(this._formulaQuery, (f) => f()); + return `(${formulaQuery.join(' AND ')})`; + } + + public buildQueryValues () : readonly any[] { + return map(this._formulaValues, (f) => f()); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._formulaValues; + } + +} diff --git a/data/query/pg/formulas/PgArgumentList.ts b/data/query/pg/formulas/PgArgumentList.ts new file mode 100644 index 0000000..8b8233e --- /dev/null +++ b/data/query/pg/formulas/PgArgumentList.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { map } from "../../../../functions/map"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; + +/** + * This generates formulas like `table.column[, table2.column2, ...]` + */ +export class PgArgumentListBuilder implements QueryBuilder { + + private readonly _queryList : (() => string)[]; + private readonly _valueList : QueryValueFactory[]; + + public constructor () { + this._queryList = []; + this._valueList = []; + } + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgArgumentListBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + /** + * Builds formula like `"table"."column"`. + * + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + public setTableColumn ( + tableName: string, + columnName: string + ) { + this._queryList.push( () => PgQueryUtils.quoteTableAndColumn(tableName, columnName) ); + } + + /** + * Builds formula like `"table"."column"::text`. + * + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + public setTableColumnAsText ( + tableName: string, + columnName: string + ) { + this._queryList.push( () => PgQueryUtils.quoteTableAndColumnAsText(tableName, columnName) ); + } + + /** + * Builds formula like `"table"."column"::text`. + * + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + public setTableColumnAsTimestamp ( + tableName: string, + columnName: string + ) { + this._queryList.push( () => PgQueryUtils.quoteTableAndColumnAsTimestampString(tableName, columnName) ); + } + + /** + * Builds query like `$1` + * + * @param value + */ + public setParam ( + value: any + ) { + this._queryList.push( () => PgQueryUtils.getValuePlaceholder() ); + this._valueList.push( () => value ); + } + + public setParamFactory ( + value: () => any + ) { + this._queryList.push( () => PgQueryUtils.getValuePlaceholder() ); + this._valueList.push( value ); + } + + /** + * Builds query like `$1::text` + * + * @param value + */ + public setParamAsText ( + value: any + ) { + this._queryList.push( () => PgQueryUtils.getValuePlaceholderAsText() ); + this._valueList.push( () => value ); + } + + /** + * Builds query for timestamp string + * + * @param value + */ + public setParamAsTimestamp ( + value: any + ) { + this._queryList.push( () => PgQueryUtils.getValuePlaceholderAsTimestampString() ); + this._valueList.push( () => value ); + } + + + public build () : QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + public buildQueryString () : string { + return map(this._queryList, (f) => f()).join(', '); + } + + public buildQueryValues () : readonly any[] { + return map(this._valueList, (f) => f()); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._valueList; + } + +} diff --git a/data/query/pg/formulas/PgArrayAggBuilder.ts b/data/query/pg/formulas/PgArrayAggBuilder.ts new file mode 100644 index 0000000..d97049c --- /dev/null +++ b/data/query/pg/formulas/PgArrayAggBuilder.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { FunctionQueryBuilder } from "../../types/FunctionQueryBuilder"; +import { QueryBuilder } from "../../types/QueryBuilder"; + +/** + * This generates formulas like `array_agg([DISTINCT] formula)` + */ +export class PgArrayAggBuilder extends FunctionQueryBuilder { + + protected constructor ( + distinct : boolean, + name : string + ) { + super(distinct, name); + } + + public static create ( + builder: QueryBuilder, + distinct: boolean + ) : PgArrayAggBuilder { + const f = new PgArrayAggBuilder(distinct, 'array_agg'); + f.setFormulaFromQueryBuilder(builder); + return f; + } + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgArrayAggBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + +} diff --git a/data/query/pg/formulas/PgJsonAggBuilder.ts b/data/query/pg/formulas/PgJsonAggBuilder.ts new file mode 100644 index 0000000..f5e370e --- /dev/null +++ b/data/query/pg/formulas/PgJsonAggBuilder.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { FunctionQueryBuilder } from "../../types/FunctionQueryBuilder"; +import { QueryBuilder } from "../../types/QueryBuilder"; + +/** + * This generates formulas like `array_agg([DISTINCT] formula)` + */ +export class PgJsonAggBuilder extends FunctionQueryBuilder { + + protected constructor ( + distinct : boolean, + name : string + ) { + super(distinct, name); + } + + public static create ( + builder: QueryBuilder, + distinct: boolean + ) : PgJsonAggBuilder { + const f = new PgJsonAggBuilder(distinct, 'jsonb_agg'); + f.setFormulaFromQueryBuilder(builder); + return f; + } + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgJsonAggBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + +} diff --git a/data/query/pg/formulas/PgJsonBuildObjectBuilder.ts b/data/query/pg/formulas/PgJsonBuildObjectBuilder.ts new file mode 100644 index 0000000..790a9c8 --- /dev/null +++ b/data/query/pg/formulas/PgJsonBuildObjectBuilder.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { FunctionQueryBuilder } from "../../types/FunctionQueryBuilder"; +import { PgArgumentListBuilder } from "./PgArgumentList"; +import { QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { PgAndChainBuilder } from "./PgAndChainBuilder"; +import { map } from "../../../../functions/map"; + +/** + * This generates formulas like `json_build_object('property1', "table1"."column2"[, ...])` + */ +export class PgJsonBuildObjectBuilder extends FunctionQueryBuilder { + + protected _ifNullChain : PgAndChainBuilder; + protected _arguments : PgArgumentListBuilder; + + public constructor () { + super(false,'jsonb_build_object'); + this._ifNullChain = PgAndChainBuilder.create(); + this._arguments = new PgArgumentListBuilder(); + + this.setFormulaFromQueryBuilder(this._arguments); + } + + public static create () : PgJsonBuildObjectBuilder { + return new PgJsonBuildObjectBuilder(); + } + + + public setProperty ( + propertyName: string, + tableName: string, + columnName: string + ) : void { + this._ifNullChain.setColumnIsNull(tableName, columnName); + this._arguments.setParamAsText(propertyName); + this._arguments.setTableColumn(tableName, columnName); + } + + public setPropertyAsText ( + propertyName: string, + tableName: string, + columnName: string + ) : void { + this._arguments.setParamAsText(propertyName); + this._arguments.setTableColumnAsText(tableName, columnName); + } + + public setPropertyAsTimestamp ( + propertyName: string, + tableName: string, + columnName: string + ) : void { + this._arguments.setParamAsText(propertyName); + this._arguments.setTableColumnAsTimestamp(tableName, columnName); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `"${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build () : QueryBuildResult { + return super.build(); + } + + public buildQueryString () : string { + return `CASE WHEN ${this._ifNullChain.buildQueryString()} THEN NULL ELSE ${super.buildQueryString()} END`; + } + + public buildQueryValues () : readonly any[] { + return map(this.getQueryValueFactories(), (f) => f()); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return [ + ...this._ifNullChain.getQueryValueFactories(), + ...super.getQueryValueFactories() + ]; + } + +} diff --git a/data/query/pg/formulas/PgJsonBuildObjectEntityBuilder.ts b/data/query/pg/formulas/PgJsonBuildObjectEntityBuilder.ts new file mode 100644 index 0000000..cf89b63 --- /dev/null +++ b/data/query/pg/formulas/PgJsonBuildObjectEntityBuilder.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityField } from "../../../types/EntityField"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { PgJsonBuildObjectBuilder } from "./PgJsonBuildObjectBuilder"; +import { EntityBuilderUtils } from "../../../utils/EntityBuilderUtils"; +import { TemporalProperty } from "../../../types/TemporalProperty"; + +/** + * This generates formulas like `json_build_object(property, table.column[, property2, table2.column2[, ...]])` + * but can configure it just by using the table name and entity field array. + */ +export class PgJsonBuildObjectEntityBuilder implements QueryBuilder { + + private readonly _builder : PgJsonBuildObjectBuilder; + + public constructor ( + ) { + this._builder = new PgJsonBuildObjectBuilder(); + } + + public static create ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) : PgJsonBuildObjectEntityBuilder { + const f = new PgJsonBuildObjectEntityBuilder(); + f.setEntityFieldsFromTable(tableName, fields, temporalProperties); + return f; + } + + public setEntityFieldsFromTable ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) : void { + EntityBuilderUtils.includeFields( + tableName, + fields, + temporalProperties, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._builder.setPropertyAsTimestamp(columnName, tableName, columnName); + }, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._builder.setPropertyAsTimestamp(columnName, tableName, columnName); + }, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._builder.setPropertyAsTimestamp(columnName, tableName, columnName); + }, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._builder.setPropertyAsText(columnName, tableName, columnName); + }, + (tableName: string, columnName: string/*, propertyName: string*/) => { + this._builder.setProperty(columnName, tableName, columnName); + }, + ); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgJsonBuildObjectEntityBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + public buildQueryValues (): readonly any[] { + return this._builder.buildQueryValues(); + } + + public getQueryValueFactories (): readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + +} diff --git a/data/query/pg/formulas/PgJsonIndexBuilder.ts b/data/query/pg/formulas/PgJsonIndexBuilder.ts new file mode 100644 index 0000000..4e22333 --- /dev/null +++ b/data/query/pg/formulas/PgJsonIndexBuilder.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; + +/** + * This generates formulas like `formula->index` which return json array value by + * index number. + */ +export class PgJsonIndexBuilder implements QueryBuilder { + + protected readonly _index : number; + protected _builder : QueryBuilder | undefined; + + public constructor (index : number) { + this._index = index; + this._builder = undefined; + } + + public static create ( + builder: QueryBuilder, + index: number + ) : PgJsonIndexBuilder { + const f = new PgJsonIndexBuilder(index); + f.setFormulaFromQueryBuilder(builder); + return f; + } + + public setFormulaFromQueryBuilder (builder : QueryBuilder) { + this._builder = builder; + } + + /////////////////////// QueryBuilder /////////////////////// + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgJsonIndexBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + public buildQueryString (): string { + if (!this._builder) throw new TypeError(`Query builder not initialized`); + return `${this._builder.buildQueryString()}->${this._index}`; + } + + public buildQueryValues (): readonly any[] { + if (!this._builder) throw new TypeError(`Query builder not initialized`); + return this._builder.buildQueryValues(); + } + + public getQueryValueFactories (): readonly QueryValueFactory[] { + if (!this._builder) throw new TypeError(`Query builder not initialized`); + return this._builder.getQueryValueFactories(); + } + +} diff --git a/data/query/pg/formulas/PgOrChainBuilder.ts b/data/query/pg/formulas/PgOrChainBuilder.ts new file mode 100644 index 0000000..f1514a9 --- /dev/null +++ b/data/query/pg/formulas/PgOrChainBuilder.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { map } from "../../../../functions/map"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; +import { PgParameterListBuilder } from "./PgParameterListBuilder"; +import { forEach } from "../../../../functions/forEach"; +import { ChainQueryBuilder } from "../../types/ChainQueryBuilder"; + +/** + * Generates formulas like `(expression OR expression2)` + */ +export class PgOrChainBuilder implements ChainQueryBuilder { + + private readonly _formulaQuery : QueryStringFactory[]; + private readonly _formulaValues : QueryValueFactory[]; + + protected constructor () { + this._formulaQuery = []; + this._formulaValues = []; + } + + public static create () { + return new PgOrChainBuilder(); + } + + + /** + * @inheritDoc + */ + public setColumnInList ( + tableName : string, + columnName : string, + values : readonly any[] + ) : void { + const builder = new PgParameterListBuilder(); + builder.setParams(values); + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} IN (${builder.buildQueryString()})` ); + forEach( + builder.getQueryValueFactories(), + (f) => this._formulaValues.push(f) + ); + } + + /** + * @inheritDoc + */ + public setColumnEquals ( + tableName : string, + columnName : string, + value : any + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} = ${PgQueryUtils.getValuePlaceholder()}` ); + this._formulaValues.push(() => value); + } + + /** + * @inheritDoc + */ + public setColumnEqualsAsJson ( + tableName : string, + columnName : string, + value : any + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumnAsJsonB(tableName, columnName)} = ${PgQueryUtils.getValuePlaceholderAsJsonB()}` ); + this._formulaValues.push(() => value); + } + + public setColumnIsNull ( + tableName : string, + columnName : string + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} IS NULL` ); + } + + /** + * @inheritDoc + */ + public setColumnBefore ( + tableName : string, + columnName : string, + value : any + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} < ${PgQueryUtils.getValuePlaceholder()}` ); + this._formulaValues.push(() => value); + } + + /** + * @inheritDoc + */ + public setColumnAfter ( + tableName : string, + columnName : string, + value : any + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} > ${PgQueryUtils.getValuePlaceholder()}` ); + this._formulaValues.push(() => value); + } + + /** + * @inheritDoc + */ + public setColumnBetween ( + tableName : string, + columnName : string, + start : any, + end : any, + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} BETWEEN ${PgQueryUtils.getValuePlaceholder()} AND ${PgQueryUtils.getValuePlaceholder()}` ); + this._formulaValues.push(() => start); + this._formulaValues.push(() => end); + } + + + /** + * @inheritDoc + */ + public setColumnInListAsTime ( + tableName : string, + columnName : string, + values : readonly any[] + ) : void { + const builder = new PgParameterListBuilder(); + builder.setParams(values); + // FIXME: This does not support times + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} IN (${builder.buildQueryString()})` ); + forEach( + builder.getQueryValueFactories(), + (f) => this._formulaValues.push(f) + ); + } + + /** + * @inheritDoc + */ + public setColumnEqualsAsTime ( + tableName : string, + columnName : string, + value : any + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} = ${PgQueryUtils.getValuePlaceholderAsTimestamp()}` ); + this._formulaValues.push(() => value); + } + + /** + * @inheritDoc + */ + public setColumnBeforeAsTime ( + tableName : string, + columnName : string, + value : any + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} < ${PgQueryUtils.getValuePlaceholderAsTimestamp()}` ); + this._formulaValues.push(() => value); + } + + /** + * @inheritDoc + */ + public setColumnAfterAsTime ( + tableName : string, + columnName : string, + value : any + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} > ${PgQueryUtils.getValuePlaceholderAsTimestamp()}` ); + this._formulaValues.push(() => value); + } + + /** + * @inheritDoc + */ + public setColumnBetweenAsTime ( + tableName : string, + columnName : string, + start : any, + end : any, + ) : void { + this._formulaQuery.push( () => `${PgQueryUtils.quoteTableAndColumn(tableName, columnName)} BETWEEN ${PgQueryUtils.getValuePlaceholderAsTimestamp()} AND ${PgQueryUtils.getValuePlaceholderAsTimestamp()}` ); + this._formulaValues.push(() => start); + this._formulaValues.push(() => end); + } + + + + + + /** + * @inheritDoc + */ + public setFromQueryBuilder ( + builder: QueryBuilder + ) : void { + this._formulaQuery.push( () => builder.buildQueryString() ); + builder.getQueryValueFactories().forEach((factory)=> { + this._formulaValues.push(factory); + }); + } + + + + + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgOrChainBuilder ${this.buildQueryString()} with [${this.buildQueryValues().join(', ')}]`; + } + + /** + * @inheritDoc + */ + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + /** + * @inheritDoc + */ + public buildQueryString (): string { + const formulaQuery = map(this._formulaQuery, (f) => f()); + return `(${formulaQuery.join(' OR ')})`; + } + + /** + * @inheritDoc + */ + public buildQueryValues () : readonly any[] { + return map(this._formulaValues, (f) => f()); + } + + /** + * @inheritDoc + */ + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._formulaValues; + } + +} diff --git a/data/query/pg/formulas/PgParameterListBuilder.ts b/data/query/pg/formulas/PgParameterListBuilder.ts new file mode 100644 index 0000000..1e76009 --- /dev/null +++ b/data/query/pg/formulas/PgParameterListBuilder.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; +import { map } from "../../../../functions/map"; + +/** + * This generates formulas like `$#, $#, $#` for parameter lists if the input was + * an array of three parameters. + * + * @see {@link PgQueryUtils.getValuePlaceholder} + */ +export class PgParameterListBuilder implements QueryBuilder { + + private _value : readonly any[] | undefined; + + public constructor () { + this._value = undefined; + } + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgParameterListBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build () : QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + public setParams (value : readonly any[]) { + this._value = value; + } + + public buildQueryString () : string { + if (!this._value) throw new TypeError(`Array was not initialized`); + const placeholder = PgQueryUtils.getValuePlaceholder(); + return map(this._value, () => placeholder).join(', '); + } + + public buildQueryValues () : readonly any[] { + return this._value ? map(this._value, (item) => item) : []; + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return map(this._value, (item) => () => item); + } + +} diff --git a/data/query/pg/formulas/PgRowBuilder.ts b/data/query/pg/formulas/PgRowBuilder.ts new file mode 100644 index 0000000..ee49c80 --- /dev/null +++ b/data/query/pg/formulas/PgRowBuilder.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { FunctionQueryBuilder } from "../../types/FunctionQueryBuilder"; +import { PgArgumentListBuilder } from "./PgArgumentList"; + +/** + * This generates formulas like `ROW(table.column[, table2.column2, ...])` + */ +export class PgRowBuilder extends FunctionQueryBuilder { + + private readonly _arguments : PgArgumentListBuilder; + + public constructor () { + super(false, 'ROW'); + this._arguments = new PgArgumentListBuilder(); + this.setFormulaFromQueryBuilder(this._arguments); + } + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgRowBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + /** + * + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + public setTableColumn ( + tableName: string, + columnName: string + ) { + this._arguments.setTableColumn(tableName, columnName); + } + + public static create ( + ) : PgRowBuilder { + return new PgRowBuilder(); + } + +} diff --git a/data/query/pg/formulas/PgRowEntityBuilder.ts b/data/query/pg/formulas/PgRowEntityBuilder.ts new file mode 100644 index 0000000..5546bc7 --- /dev/null +++ b/data/query/pg/formulas/PgRowEntityBuilder.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityField } from "../../../types/EntityField"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { PgRowBuilder } from "./PgRowBuilder"; +import { EntityFieldType } from "../../../types/EntityFieldType"; + +/** + * This generates formulas like `ROW(table.column[, table2.column2[, ...]])` + * but can configure it just by using the table name and entity field array. + */ +export class PgRowEntityBuilder implements QueryBuilder { + + private readonly _jsonBuilder : PgRowBuilder; + + public constructor ( + ) { + this._jsonBuilder = new PgRowBuilder(); + } + + public static create ( + tableName : string, + fields : readonly EntityField[] + ) : PgRowEntityBuilder { + const f = new PgRowEntityBuilder(); + f.setEntityFieldsFromTable(tableName, fields); + return f; + } + + public setEntityFieldsFromTable ( + tableName : string, + fields : readonly EntityField[] + ) { + fields.forEach( + (field: EntityField) => { + const {columnName, fieldType} = field; + if (fieldType !== EntityFieldType.JOINED_ENTITY) { + this._jsonBuilder.setTableColumn(tableName, columnName); + } + } + ); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgRowEntityBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + public buildQueryString (): string { + return this._jsonBuilder.buildQueryString(); + } + + public buildQueryValues (): readonly any[] { + return this._jsonBuilder.buildQueryValues(); + } + + public getQueryValueFactories (): readonly QueryValueFactory[] { + return this._jsonBuilder.getQueryValueFactories(); + } + +} diff --git a/data/query/pg/formulas/PgUnnestBuilder.ts b/data/query/pg/formulas/PgUnnestBuilder.ts new file mode 100644 index 0000000..807e2b5 --- /dev/null +++ b/data/query/pg/formulas/PgUnnestBuilder.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { FunctionQueryBuilder } from "../../types/FunctionQueryBuilder"; +import { QueryBuilder } from "../../types/QueryBuilder"; + +/** + * This generates formulas like `unnest(formula)` + */ +export class PgUnnestBuilder extends FunctionQueryBuilder { + + public constructor () { + super(false,'unnest'); + } + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgUnnestBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public static create (builder: QueryBuilder) : PgUnnestBuilder { + const f = new PgUnnestBuilder(); + f.setFormulaFromQueryBuilder(builder); + return f; + } + +} diff --git a/data/query/pg/insert/PgEntityInsertQueryBuilder.ts b/data/query/pg/insert/PgEntityInsertQueryBuilder.ts new file mode 100644 index 0000000..634ba6f --- /dev/null +++ b/data/query/pg/insert/PgEntityInsertQueryBuilder.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { forEach } from "../../../../functions/forEach"; +import { has } from "../../../../functions/has"; +import { find } from "../../../../functions/find"; +import { filter } from "../../../../functions/filter"; +import { EntityField } from "../../../types/EntityField"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { PgInsertQueryBuilder } from "./PgInsertQueryBuilder"; +import { EntityInsertQueryBuilder } from "../../sql/insert/EntityInsertQueryBuilder"; +import { Entity } from "../../../Entity"; +import { PgListQueryBuilder } from "../types/PgListQueryBuilder"; +import { QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { LogService } from "../../../../LogService"; +import { EntityFieldType } from "../../../types/EntityFieldType"; +import { LogLevel } from "../../../../types/LogLevel"; +import { isTimeColumnDefinition } from "../../../types/ColumnDefinition"; + +const LOG = LogService.createLogger( 'PgEntityInsertQueryBuilder' ); + +/** + * Defines an interface for a builder of PostgreSQL database read query from + * entity types. + */ +export class PgEntityInsertQueryBuilder implements EntityInsertQueryBuilder { + + public static setLogLevel (level: LogLevel) : void { + LOG.setLogLevel(level); + } + + private readonly _builder : PgInsertQueryBuilder; + + protected constructor () { + this._builder = PgInsertQueryBuilder.create(); + } + + /** + * Create select query builder for PostgreSQL + */ + public static create () : PgEntityInsertQueryBuilder { + return new PgEntityInsertQueryBuilder(); + } + + + + /** + * + * @param fields + * @param temporalProperties + * @param ignoreProperties + * @param entity + */ + public appendEntity ( + entity : T, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + ignoreProperties : readonly string[], + ) : void { + + const filteredFields : EntityField[] = filter( + fields, + (field: EntityField) : boolean => { + const { propertyName, fieldType, insertable } = field; + return insertable && !ignoreProperties.includes( propertyName ) && fieldType !== EntityFieldType.JOINED_ENTITY; + } + ); + + const itemBuilder = PgListQueryBuilder.create(); + forEach( + filteredFields, + (field: EntityField) => { + + const {propertyName, columnName} = field; + + LOG.debug(`appendEntity: field: `, field); + + const { columnDefinition } = field; + + const temporalProperty : TemporalProperty | undefined = find( + temporalProperties, + (item: TemporalProperty) : boolean => item.propertyName === propertyName + ); + const temporalType = temporalProperty?.temporalType; + + const isTime : boolean = !!temporalType || !!(isTimeColumnDefinition(columnDefinition)); + LOG.debug(`appendEntity: isTime: `, isTime, propertyName); + + const value : any = has(entity, propertyName) ? (entity as any)[propertyName] : undefined; + if (value !== undefined) { + if ( isTime ) { + this._builder.addColumnName(columnName); + itemBuilder.setParamFromTimestampString(value); + } else { + this._builder.addColumnName(columnName); + itemBuilder.setParam(value); + } + } + + } + ); + + this._builder.appendValueListUsingQueryBuilder(itemBuilder); + + } + + public appendEntityList ( + list : readonly T[], + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + ignoreProperties : readonly string[], + ) : void { + forEach( + list, + (item) => this.appendEntity(item, fields, temporalProperties, ignoreProperties) + ); + } + + /////////////////////// InsertQueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return this._builder.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string): void { + return this._builder.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getCompleteFromTable} + */ + public getTableName (): string { + return this._builder.getTableName(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getCompleteFromTable} + */ + public setTableName (tableName: string): void { + return this._builder.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getCompleteFromTable} + */ + public getFullTableName (): string { + return this._builder.getFullTableName(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getCompleteFromTable} + */ + public getTableNameWithPrefix (tableName : string): string { + return this._builder.getTableNameWithPrefix(tableName); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.valueOf} + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.toString} + */ + public toString () : string { + return this._builder.toString(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.build} + */ + public build (): QueryBuildResult { + return this._builder.build(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.buildQueryString} + */ + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return this._builder.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link EntityInsertQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + +} diff --git a/data/query/pg/insert/PgInsertQueryBuilder.ts b/data/query/pg/insert/PgInsertQueryBuilder.ts new file mode 100644 index 0000000..0ae517d --- /dev/null +++ b/data/query/pg/insert/PgInsertQueryBuilder.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../../functions/map"; +import { reduce } from "../../../../functions/reduce"; +import { has } from "../../../../functions/has"; +import { InsertQueryBuilder } from "../../sql/insert/InsertQueryBuilder"; +import { BaseInsertQueryBuilder } from "../../sql/insert/BaseInsertQueryBuilder"; +import { QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; + +export class PgInsertQueryBuilder extends BaseInsertQueryBuilder implements InsertQueryBuilder { + + protected constructor () { + super(', '); + this.addPrefixFactory( + () => `INSERT INTO ${PgQueryUtils.quoteTableName(this.getFullTableName())}`, + ); + } + + public static create () : PgInsertQueryBuilder { + return new PgInsertQueryBuilder(); + } + + public addPrefixFactory ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + super.addPrefixFactory(queryFactory, ...valueFactories); + } + + public addValueFactory ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + super.addValueFactory(queryFactory, ...valueFactories); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.includeColumn} + */ + public appendValueList ( + list: readonly any[] + ) : void { + const queryString = `(${map(list, () => PgQueryUtils.getValuePlaceholder()).join(', ')})`; + const valueFactories = map(list, (item) => () => item); + this.addValueFactory( + () : string => queryString, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.includeColumn} + */ + public appendValueObject ( + columnNames : readonly string[], + obj : {readonly [key: string] : any} + ) : void { + if (!columnNames?.length) throw new TypeError(`There must be at least one column name`); + this.appendValueList( + reduce( + columnNames, + (list: any[], columnName: string) : any[] => { + if (has(obj, columnName)) { + list.push(obj[columnName]); + } else { + list.push(null); + } + return list; + }, + [] + ) + ); + } + + + public addColumnName ( + name: string + ) : void { + this.addColumnFactory( + () => PgQueryUtils.quoteColumnName(name) + ); + } + + + + /////////////////////// InsertQueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return super.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) : void { + super.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + public getTableName (): string { + return super.getTableName(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) : void { + super.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getCompleteFromTable} + */ + public getFullTableName (): string { + return super.getFullTableName(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName : string) : string { + return super.getTableNameWithPrefix(tableName); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.valueOf} + */ + public valueOf () { + return super.valueOf(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.toString} + */ + public toString () : string { + return super.toString(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.build} + */ + public build () : QueryBuildResult { + return super.build(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.buildQueryString} + */ + public buildQueryString () : string { + return `${super.buildQueryString()} RETURNING *`; + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return super.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories () : readonly QueryValueFactory[] { + return super.getQueryValueFactories(); + } + +} diff --git a/data/query/pg/select/PgEntitySelectQueryBuilder.ts b/data/query/pg/select/PgEntitySelectQueryBuilder.ts new file mode 100644 index 0000000..8a17f06 --- /dev/null +++ b/data/query/pg/select/PgEntitySelectQueryBuilder.ts @@ -0,0 +1,586 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { forEach } from "../../../../functions/forEach"; +import { find } from "../../../../functions/find"; +import { PgSelectQueryBuilder } from "./PgSelectQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { EntityField } from "../../../types/EntityField"; +import { EntityRelationOneToMany } from "../../../types/EntityRelationOneToMany"; +import { EntityRelationManyToOne } from "../../../types/EntityRelationManyToOne"; +import { EntityFieldType } from "../../../types/EntityFieldType"; +import { PgJsonAggBuilder } from "../formulas/PgJsonAggBuilder"; +import { PgJsonBuildObjectEntityBuilder } from "../formulas/PgJsonBuildObjectEntityBuilder"; +import { PgJsonIndexBuilder } from "../formulas/PgJsonIndexBuilder"; +import { Sort } from "../../../Sort"; +import { PgAndChainBuilder } from "../formulas/PgAndChainBuilder"; +import { Where } from "../../../Where"; +import { ChainQueryBuilderUtils } from "../../utils/ChainQueryBuilderUtils"; +import { PgOrChainBuilder } from "../formulas/PgOrChainBuilder"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { EntitySelectQueryUtils } from "../../utils/EntitySelectQueryUtils"; +import { EntitySelectQueryBuilder, TableFieldInfoCallback } from "../../sql/select/EntitySelectQueryBuilder"; +import { map } from "../../../../functions/map"; +import { SortOrder } from "../../../types/SortOrder"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; +import { EntityUtils } from "../../../utils/EntityUtils"; +import { SortDirection } from "../../../types/SortDirection"; + +/** + * Defines an interface for a builder of PostgreSQL database read query from + * entity types. + */ +export class PgEntitySelectQueryBuilder + implements + EntitySelectQueryBuilder +{ + + private readonly _builder : PgSelectQueryBuilder; + + protected constructor () { + this._builder = PgSelectQueryBuilder.create(); + } + + public static create () { + return new PgEntitySelectQueryBuilder(); + } + + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeEntityFields} + */ + public includeEntityFields ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ): void { + EntitySelectQueryUtils.includeEntityFields( + this._builder, + tableName, + fields, + temporalProperties + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setOneToMany} + */ + public setOneToMany ( + propertyName: string, + fields: readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + targetTableName : string, + targetColumnName : string, + sourceTableName : string, + sourceColumnName : string + ): void { + this._builder.includeColumnFromQueryBuilder( + PgJsonAggBuilder.create( + PgJsonBuildObjectEntityBuilder.create( + this.getTableNameWithPrefix(targetTableName), + fields, + temporalProperties + ), + true + ), + propertyName + ); + this._builder.leftJoinTable( + targetTableName, targetColumnName, + sourceTableName, sourceColumnName + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setManyToOne} + */ + public setManyToOne ( + propertyName: string, + fields: readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + targetTableName : string, + targetColumnName : string, + sourceTableName : string, + sourceColumnName : string + ): void { + this._builder.includeColumnFromQueryBuilder( + PgJsonIndexBuilder.create( + PgJsonAggBuilder.create( + PgJsonBuildObjectEntityBuilder.create( + this.getTableNameWithPrefix(targetTableName), + fields, + temporalProperties + ), + true + ), + 0 + ), + propertyName + ); + this._builder.leftJoinTable( + targetTableName, targetColumnName, + sourceTableName, sourceColumnName + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setOneToManyRelations} + */ + public setOneToManyRelations ( + relations: readonly EntityRelationOneToMany[], + resolveMappedFieldInfo: TableFieldInfoCallback, + ): void { + forEach( + relations, + (oneToManyRelation: EntityRelationOneToMany) : void => { + const { propertyName } = oneToManyRelation; + const mappedTable = oneToManyRelation?.mappedTable; + if (!mappedTable) throw new TypeError(`The relation "${propertyName}" did not have table defined`); + + const [mappedFields, mappedTemporalProperties] = resolveMappedFieldInfo(mappedTable); + + const groupByColumn = this.getGroupByColumn(); + if (!groupByColumn) throw new TypeError(`Could not find column to group by for property "${propertyName}"`); + + this.setOneToMany( + propertyName, + mappedFields, + mappedTemporalProperties, + mappedTable, groupByColumn, + this.getTableName(), groupByColumn + ); + } + ); + } + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setManyToOneRelations} + */ + public setManyToOneRelations ( + relations: readonly EntityRelationManyToOne[], + resolveMappedFieldInfo: TableFieldInfoCallback, + fields: readonly EntityField[], + ): void { + forEach( + relations, + (relation: EntityRelationManyToOne) : void => { + const { propertyName } = relation; + const mappedTable = relation?.mappedTable; + if (!mappedTable) throw new TypeError(`The relation "${propertyName}" did not have table defined`); + + const [mappedFields, mappedTemporalProperties] = resolveMappedFieldInfo(mappedTable); + + const mappedField : EntityField | undefined = find(fields, (field) => field?.propertyName === propertyName && field?.fieldType === EntityFieldType.JOINED_ENTITY); + if (!mappedField) throw new TypeError(`Could not find field definition for property "${propertyName}"`); + const mappedColumnName : string = mappedField.columnName; + if (!mappedColumnName) throw new TypeError(`Could not find column name for property "${propertyName}"`); + this.setManyToOne( + propertyName, + mappedFields, + mappedTemporalProperties, + mappedTable, + mappedColumnName, + this.getTableName(), mappedColumnName + ); + } + ); + } + + + /////////////////////// QueryEntityWhereable /////////////////////// + + + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.buildAnd} + */ + public buildAnd ( + where : Where, + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) : PgAndChainBuilder { + const completeTableName = this.getTableNameWithPrefix(tableName); + const andBuilder = PgAndChainBuilder.create(); + ChainQueryBuilderUtils.buildChain( + andBuilder, + where, + completeTableName, + fields, + temporalProperties, + () => PgAndChainBuilder.create(), + () => PgOrChainBuilder.create() + ); + return andBuilder; + } + + + /////////////////////// QueryEntityOrderable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setOrderByTableFields} + */ + public setOrderByTableFields ( + sort : Sort, + tableName : string, + fields : readonly EntityField[] + ) { + const orders = sort.getSortOrders(); + if (orders?.length) { + this.appendOrderExpression( + () => map( + orders, + (item: SortOrder) => `${ + PgQueryUtils.quoteTableAndColumn( + this.getTableNameWithPrefix( tableName ), + EntityUtils.getColumnName( item.getProperty(), fields ) + ) + }${item.getDirection() === SortDirection.ASC ? ' ASC' : ' DESC'}` + ).join( ', ' ) + ); + } + } + + + /////////////////////// TableResultable /////////////////////// + + + public buildResultQueryString () : string { + return this._builder.buildResultQueryString(); + } + + public getResultValueFactories () : readonly QueryValueFactory[] { + return this._builder.getResultValueFactories(); + } + + + public appendResultExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryValueFactory[] + ) : void { + this._builder.appendResultExpression( + queryFactory, + ...valueFactories + ); + } + + + public appendResultExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + this._builder.appendResultExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumn} + */ + public includeColumn (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumn(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsText} + */ + public includeColumnAsText (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumnAsText(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTime} + */ + public includeColumnAsTime (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumnAsTime(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsDate} + */ + public includeColumnAsDate (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumnAsDate(tableName, columnName, asColumnName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTimestamp} + */ + public includeColumnAsTimestamp (tableName: string, columnName: string, asColumnName: string): void { + this._builder.includeColumnAsTimestamp(tableName, columnName, asColumnName); + } + + /** + * @deprecated Use EntitySelectQueryBuilder.includeEntityFields instead of + * this method. + * + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeEntityFields} + * @see {@link SelectQueryBuilder.includeAllColumnsFromTable} + * @see {@link EntitySelectQueryBuilder.includeAllColumnsFromTable} + */ + public includeAllColumnsFromTable (tableName: string): void { + return this._builder.includeAllColumnsFromTable(tableName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnFromQueryBuilder} + */ + public includeColumnFromQueryBuilder (builder: QueryBuilder, asColumnName: string): void { + return this._builder.includeColumnFromQueryBuilder(builder, asColumnName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeFormulaByString} + */ + public includeFormulaByString (formula: string, asColumnName: string): void { + return this._builder.includeFormulaByString(formula, asColumnName); + } + + + + /////////////////////// QueryWhereable /////////////////////// + + + public buildWhereQueryString () : string { + return this._builder.buildWhereQueryString(); + } + + + public getWhereValueFactories () : readonly QueryValueFactory[] { + return this._builder.getWhereValueFactories(); + } + + + /** + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.setWhereFromQueryBuilder} + */ + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + this._builder.setWhereFromQueryBuilder(builder); + } + + + /////////////////////// QueryOrderable /////////////////////// + + + public buildOrderQueryString () : string { + return this._builder.buildOrderQueryString(); + } + + + public getOrderValueFactories () : readonly QueryStringFactory[] { + return this._builder.getOrderValueFactories(); + } + + public appendOrderExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryStringFactory[] + ) : void { + return this._builder.appendOrderExpression( + queryFactory, + ...valueFactories + ); + } + + public appendOrderExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryStringFactory[] + ) : void { + return this._builder.appendOrderExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + + /////////////////////// QueryGroupable /////////////////////// + + + public buildGroupByQueryString () : string { + return this._builder.buildGroupByQueryString(); + } + + public getGroupByValueFactories () : readonly QueryStringFactory[] { + return this._builder.getGroupByValueFactories(); + } + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setGroupByColumn} + */ + public setGroupByColumn (columnName: string): void { + return this._builder.setGroupByColumn(columnName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getGroupByColumn} + */ + public getGroupByColumn (): string | undefined { + return this._builder.getGroupByColumn(); + } + + + /////////////////////// QueryLeftjoinable /////////////////////// + + + public buildLeftJoinQueryString () : string { + return this._builder.buildLeftJoinQueryString(); + } + + public getLeftJoinValueFactories () : readonly QueryValueFactory[] { + return this._builder.getLeftJoinValueFactories(); + } + + public appendLeftJoinExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryValueFactory[] + ) : void { + this._builder.appendLeftJoinExpression( + queryFactory, + ...valueFactories + ); + } + + public appendLeftJoinExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + this._builder.appendLeftJoinExpressionUsingQueryBuilder( + builder, + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.leftJoinTable} + */ + public leftJoinTable (fromTableName: string, fromColumnName: string, sourceTableName: string, sourceColumnName: string): void { + return this._builder.leftJoinTable(fromTableName, fromColumnName, sourceTableName, sourceColumnName); + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link TablePrefixable.setTablePrefix} + */ + public setTablePrefix (prefix: string): void { + return this._builder.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link TablePrefixable.getTablePrefix} + */ + public getTablePrefix (): string { + return this._builder.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link TablePrefixable.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName: string): string { + return this._builder.getTableNameWithPrefix(tableName); + } + + /** + * @inheritDoc + * @see {@link TablePrefixable.setTableName} + */ + public setTableName (tableName: string): void { + return this._builder.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link TablePrefixable.getTableName} + */ + public getTableName (): string { + return this._builder.getTableName(); + } + + /** + * @inheritDoc + * @see {@link TablePrefixable.getCompleteTableName} + */ + public getCompleteTableName (): string { + return this._builder.getCompleteTableName(); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.valueOf} + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.toString} + */ + public toString () : string { + return `PgEntitySelectQueryBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.build} + */ + public build (): QueryBuildResult { + return this._builder.build(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.buildQueryString} + */ + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return this._builder.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + + + +} diff --git a/data/query/pg/select/PgSelectQueryBuilder.ts b/data/query/pg/select/PgSelectQueryBuilder.ts new file mode 100644 index 0000000..d7edcfe --- /dev/null +++ b/data/query/pg/select/PgSelectQueryBuilder.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { SelectQueryBuilder } from "../../sql/select/SelectQueryBuilder"; +import { PgQueryUtils, PG_PH_AS, PG_PH_LEFT_JOIN } from "../utils/PgQueryUtils"; +import { BaseSelectQueryBuilder } from "../../sql/select/BaseSelectQueryBuilder"; + +export class PgSelectQueryBuilder extends BaseSelectQueryBuilder implements SelectQueryBuilder { + + private constructor () { + super( + ', ', + ' ', + ', ', + ); + } + + public static create () : PgSelectQueryBuilder { + return new PgSelectQueryBuilder(); + } + + + /////////////////////// QueryResultable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeAllColumnsFromTable} + */ + public includeAllColumnsFromTable (tableName: string) { + this.appendResultExpression( + () => `${PgQueryUtils.quoteTableName(this.getTableNameWithPrefix(tableName))}.*` + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnFromQueryBuilder} + */ + public includeColumnFromQueryBuilder ( + builder: QueryBuilder, + asColumnName: string + ) { + + if (!asColumnName) { + throw new TypeError(`includeColumnFromQueryBuilder: column name is required`); + } + + this.appendResultExpression( + () => PG_PH_AS(builder.buildQueryString(), asColumnName), + ...builder.getQueryValueFactories(), + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeFormulaByString} + */ + public includeFormulaByString ( + formula: string, + asColumnName: string + ): void { + if (!formula) { + throw new TypeError(`includeFormulaByString: formula is required`); + } + if (!asColumnName) { + throw new TypeError(`includeFormulaByString: column name is required`); + } + this.appendResultExpression( + () => PG_PH_AS(formula, asColumnName) + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumn} + */ + public includeColumn (tableName: string, columnName: string, asColumnName: string): void { + this.appendResultExpression( + () => PgQueryUtils.quoteTableAndColumnAsColumnName(this.getTableNameWithPrefix(tableName), columnName, asColumnName) + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsText} + */ + public includeColumnAsText (tableName: string, columnName: string, asColumnName: string): void { + this.appendResultExpression( + () => PgQueryUtils.quoteTableAndColumnAsTextAsColumnName(this.getTableNameWithPrefix(tableName), columnName, asColumnName) + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsDate} + */ + public includeColumnAsDate (tableName: string, columnName: string, asColumnName: string): void { + this.appendResultExpression( +() => PgQueryUtils.quoteTableAndColumnAsTimestampStringAsColumnName(this.getTableNameWithPrefix(tableName), columnName, asColumnName) + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTime} + */ + public includeColumnAsTime (tableName: string, columnName: string, asColumnName: string): void { + this.appendResultExpression( +() => PgQueryUtils.quoteTableAndColumnAsTimestampStringAsColumnName(this.getTableNameWithPrefix(tableName), columnName, asColumnName) + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTimestamp} + */ + public includeColumnAsTimestamp (tableName: string, columnName: string, asColumnName: string): void { + this.appendResultExpression( +() => PgQueryUtils.quoteTableAndColumnAsTimestampStringAsColumnName(this.getTableNameWithPrefix(tableName), columnName, asColumnName) + ); + } + + + /////////////////////// QueryLeftJoinable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.leftJoinTable} + */ + public leftJoinTable ( + fromTableName : string, + fromColumnName : string, + sourceTableName : string, + sourceColumnName : string + ) : void { + this.appendLeftJoinExpression( + () => PG_PH_LEFT_JOIN( + this.getTableNameWithPrefix(fromTableName), + fromColumnName, + this.getTableNameWithPrefix(sourceTableName), + sourceColumnName + ) + ); + } + + + /////////////////////// QueryGroupable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setGroupByColumn} + */ + public setGroupByColumn (columnName: string) { + super.setGroupByColumn(columnName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getGroupByColumn} + */ + public getGroupByColumn (): string | undefined { + return super.getGroupByColumn(); + } + + + + /////////////////////// QueryWhereable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setWhereFromQueryBuilder} + */ + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + super.setWhereFromQueryBuilder(builder); + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) { + super.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return super.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) { + super.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getCompleteFromTable} + */ + public getCompleteTableName (): string { + return super.getCompleteTableName(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getShortFromTable} + */ + public getTableName (): string { + return super.getTableName(); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link SelectQueryBuilder.valueOf} + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + * @see {@link QueryBuilder.toString} + * @see {@link SelectQueryBuilder.toString} + */ + public toString () : string { + return `PgSelectQueryBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.build} + */ + public build () : QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + * @see {@link SelectQueryBuilder.buildQueryString} + */ + public buildQueryString () : string { + + const columns = this.buildResultQueryString(); + const tableName = this.getCompleteTableName(); + const leftJoinQueries = this.buildLeftJoinQueryString(); + const where = this.buildWhereQueryString(); + const groupBy = this.buildGroupByQueryString(); + const orderBys = this.buildOrderQueryString(); + + let query = `SELECT ${columns}`; + if (tableName) { + query += ` FROM ${PgQueryUtils.quoteTableName(tableName)}`; + } + if (leftJoinQueries) { + query += ` ${leftJoinQueries}`; + } + if (where) { + query += ` WHERE ${where}`; + } + if ( groupBy ) { + query += ` GROUP BY ${groupBy}`; + } + if ( orderBys ) { + query += ` ORDER BY ${ orderBys }`; + } + return query; + + } + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + * @see {@link SelectQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return super.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + * @see {@link SelectQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories () : readonly QueryValueFactory[] { + return [ + ...this.getResultValueFactories(), + ...this.getLeftJoinValueFactories(), + ...this.getWhereValueFactories(), + ...this.getGroupByValueFactories(), + ...this.getOrderValueFactories(), + ] + } + + +} diff --git a/data/query/pg/types/PgListQueryBuilder.ts b/data/query/pg/types/PgListQueryBuilder.ts new file mode 100644 index 0000000..d5c9aa8 --- /dev/null +++ b/data/query/pg/types/PgListQueryBuilder.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { BaseListQueryBuilder } from "../../types/BaseListQueryBuilder"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; + +export class PgListQueryBuilder extends BaseListQueryBuilder { + + protected constructor (separator : string) { + super(separator); + } + + public static create ( + separator ?: string + ) : PgListQueryBuilder { + return new PgListQueryBuilder( separator ?? ', ' ); + } + + /** + * @inheritDoc + */ + public setParam (value: any): void { + this.appendExpression( + () => PgQueryUtils.getValuePlaceholder(), + () => value + ); + } + + /** + * @inheritDoc + */ + public setParamAsText (value: any): void { + this.appendExpression( + () => PgQueryUtils.getValuePlaceholderAsText(), + () => value + ); + } + + /** + * @inheritDoc + */ + public setParamFromJson (value: any): void { + this.appendExpression( + () => PgQueryUtils.getValuePlaceholder(), + () => value + ); + } + + /** + * @inheritDoc + */ + public setParamFromTimestampString (value: any): void { + this.appendExpression( + () => PgQueryUtils.getValuePlaceholderAsTimestamp(), + () => value + ); + } + + /** + * @inheritDoc + */ + public setParamAsTimestampValue (value: any): void { + this.appendExpression( + () => PgQueryUtils.getValuePlaceholderAsTimestampString(), + () => value + ); + } + + /** + * @inheritDoc + */ + public setParamFactory ( + factory: () => any + ): void { + this.appendExpression( + () => PgQueryUtils.getValuePlaceholder(), + factory + ); + } + + /** + * @inheritDoc + */ + public setTableColumn (tableName: string, columnName: string): void { + this.appendExpression( + () => PgQueryUtils.quoteTableAndColumn(tableName, columnName) + ); + } + + /** + * @inheritDoc + */ + public setTableColumnAsText (tableName: string, columnName: string): void { + this.appendExpression( + () => PgQueryUtils.quoteTableAndColumnAsText(tableName, columnName), + ); + } + + /** + * @inheritDoc + */ + public setTableColumnAsTimestampString (tableName: string, columnName: string): void { + this.appendExpression( + () => PgQueryUtils.quoteTableAndColumnAsTimestampString(tableName, columnName), + ); + } + + /** + * @inheritDoc + */ + public setAssignmentWithParam ( + columnName: string, + value: any + ) : void { + this.appendExpression( + () => `${PgQueryUtils.quoteColumnName(columnName)} = ${PgQueryUtils.getValuePlaceholder()}`, + () => value + ); + } + + /** + * @inheritDoc + */ + public setAssignmentWithParamAsTimestamp ( + columnName: string, + value: any + ) : void { + this.appendExpression( + () => `${PgQueryUtils.quoteColumnName(columnName)} = ${PgQueryUtils.getValuePlaceholderAsTimestampString()}`, + () => value + ); + } + + /** + * @inheritDoc + */ + public setAssignmentWithParamAsJson ( + columnName: string, + value: any + ) : void { + this.appendExpression( + () => `${PgQueryUtils.quoteColumnName(columnName)} = ${PgQueryUtils.getValuePlaceholder()}`, + () => value + ); + } + +} diff --git a/data/query/pg/update/PgEntityUpdateQueryBuilder.ts b/data/query/pg/update/PgEntityUpdateQueryBuilder.ts new file mode 100644 index 0000000..1cce494 --- /dev/null +++ b/data/query/pg/update/PgEntityUpdateQueryBuilder.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityField } from "../../../types/EntityField"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { PgUpdateQueryBuilder } from "./PgUpdateQueryBuilder"; +import { EntityUpdateQueryBuilder } from "../../sql/update/EntityUpdateQueryBuilder"; +import { Entity } from "../../../Entity"; +import { forEach } from "../../../../functions/forEach"; +import { has } from "../../../../functions/has"; +import { find } from "../../../../functions/find"; +import { PgListQueryBuilder } from "../types/PgListQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { isTimeColumnDefinition } from "../../../types/ColumnDefinition"; + +/** + * Defines an interface for a builder of MySQL database read query from + * entity types. + */ +export class PgEntityUpdateQueryBuilder implements EntityUpdateQueryBuilder { + + private readonly _builder : PgUpdateQueryBuilder; + + protected constructor () { + this._builder = PgUpdateQueryBuilder.create(); + } + + /** + * Create select query builder for MySQL + */ + public static create () : PgEntityUpdateQueryBuilder { + return new PgEntityUpdateQueryBuilder(); + } + + + /////////////////////// EntityUpdateQueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public appendEntity ( + entity : T, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + ignoreProperties : readonly string[], + ) : void { + const setAssigmentBuilder = PgListQueryBuilder.create(); + forEach( + fields, + (field: EntityField) => { + const { propertyName, columnName, columnDefinition, updatable } = field; + if (ignoreProperties.includes(propertyName)) return; + if (!updatable) return; + + const temporalProperty : TemporalProperty | undefined = find( + temporalProperties, + (item: TemporalProperty) : boolean => item.propertyName === propertyName + ); + const temporalType = temporalProperty?.temporalType; + const value : any = has(entity, propertyName) ? (entity as any)[propertyName] : null; + if ( temporalType || isTimeColumnDefinition(columnDefinition) ) { + setAssigmentBuilder.setAssignmentWithParamAsTimestamp(columnName, value); + } else { + setAssigmentBuilder.setAssignmentWithParam(columnName, value); + } + } + ); + this._builder.appendSetListUsingQueryBuilder(setAssigmentBuilder); + } + + + + /////////////////////// UpdateQueryBuilder /////////////////////// + + + public addPrefixFactory (queryFactory: QueryStringFactory, ...valueFactories: readonly QueryValueFactory[]): void { + this._builder.addPrefixFactory(queryFactory, ...valueFactories); + } + + public addSetFactory (queryFactory: QueryStringFactory, ...valueFactories: readonly QueryValueFactory[]): void { + this._builder.addSetFactory(queryFactory, ...valueFactories); + } + + public appendSetListUsingQueryBuilder (builder: QueryBuilder): void { + this._builder.appendSetListUsingQueryBuilder(builder); + } + + + /////////////////////// TableWhereable /////////////////////// + + + buildWhereQueryString () : string { + return this._builder.buildWhereQueryString(); + } + + getWhereValueFactories () : readonly QueryValueFactory[] { + return this._builder.getWhereValueFactories(); + } + + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + this._builder.setWhereFromQueryBuilder(builder); + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string): void { + return this._builder.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return this._builder.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getCompleteFromTable} + */ + public getTableNameWithPrefix (tableName : string): string { + return this._builder.getTableNameWithPrefix(tableName); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getCompleteFromTable} + */ + public setTableName (tableName: string): void { + return this._builder.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getCompleteFromTable} + */ + public getTableName (): string { + return this._builder.getTableName(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getCompleteFromTable} + */ + public getCompleteTableName (): string { + return this._builder.getCompleteTableName(); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.valueOf} + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.toString} + */ + public toString () : string { + return this._builder.toString(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.build} + */ + public build (): QueryBuildResult { + return this._builder.build(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.buildQueryString} + */ + public buildQueryString (): string { + return this._builder.buildQueryString(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return this._builder.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link EntityUpdateQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories (): readonly QueryValueFactory[] { + return this._builder.getQueryValueFactories(); + } + + +} diff --git a/data/query/pg/update/PgUpdateQueryBuilder.ts b/data/query/pg/update/PgUpdateQueryBuilder.ts new file mode 100644 index 0000000..91f8258 --- /dev/null +++ b/data/query/pg/update/PgUpdateQueryBuilder.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { BaseUpdateQueryBuilder } from "../../sql/update/BaseUpdateQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { PgQueryUtils } from "../utils/PgQueryUtils"; + +export class PgUpdateQueryBuilder extends BaseUpdateQueryBuilder { + + protected constructor () { + super(); + this.addPrefixFactory( + () => `UPDATE ${PgQueryUtils.quoteTableName(this.getCompleteTableName())}` + ); + } + + public static create () : PgUpdateQueryBuilder { + return new PgUpdateQueryBuilder(); + } + + + /////////////////////// BaseUpdateQueryBuilder /////////////////////// + + + + + + /////////////////////// UpdateQueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public addPrefixFactory ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + super.addPrefixFactory(queryFactory, ...valueFactories); + } + + /** + * @inheritDoc + */ + public addSetFactory ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + super.addSetFactory(queryFactory, ...valueFactories); + } + + /** + * @inheritDoc + */ + public appendSetListUsingQueryBuilder (builder: QueryBuilder) : void { + super.appendSetListUsingQueryBuilder(builder); + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return super.getTablePrefix(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) : void { + super.setTablePrefix(prefix); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getTablePrefix} + */ + public getTableName (): string { + return super.getTableName(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) : void { + super.setTableName(tableName); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getCompleteFromTable} + */ + public getCompleteTableName (): string { + return super.getCompleteTableName(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName : string) : string { + return super.getTableNameWithPrefix(tableName); + } + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.valueOf} + */ + public valueOf () { + return super.valueOf(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.toString} + */ + public toString () : string { + return super.toString(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.build} + */ + public build () : QueryBuildResult { + return super.build(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.buildQueryString} + */ + public buildQueryString () : string { + return `${super.buildQueryString()} RETURNING *`; + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return super.buildQueryValues(); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getQueryValueFactories} + */ + public getQueryValueFactories (): readonly QueryValueFactory[] { + return super.getQueryValueFactories(); + } + +} diff --git a/data/query/pg/utils/PgQueryUtils.ts b/data/query/pg/utils/PgQueryUtils.ts new file mode 100644 index 0000000..a957f88 --- /dev/null +++ b/data/query/pg/utils/PgQueryUtils.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +function assertExists (value: string, message: string) : string { + if (!value) throw new TypeError(message); + return value; +} + +const qc = (column: string) => PgQueryUtils.quoteColumnName(column); +const qt = (table: string) => PgQueryUtils.quoteTableName(table); + +const qtc = (table: string, column: string) => PgQueryUtils.quoteTableAndColumn(table, column); + +export const PG_PH_AS = ( + expression: string, + columnName: string +) => `${assertExists( + expression, + `Expression required for column "${columnName}"` +)} AS ${qc( + assertExists(columnName, `Column name required for expression: "${expression}"`) +)}`; + + +export const PG_PH_LEFT_JOIN = ( + fromTable: string, fromColumn: string, + sourceTable: string, sourceColumn: string, +) => `LEFT JOIN ${qt(fromTable)} ON ${qtc(sourceTable, sourceColumn)} = ${qtc(fromTable, fromColumn)}`; + +// export const TO_CHAR_TIMESTAMP = (value: string) => `to_json(${value})#>>'{}'`; +export const PG_TO_CHAR_TIMESTAMP = (value: string) => `${value}`; + +export const PG_TO_TIMESTAMP = (value: string) => `${value}`; + +export const PG_AS_COLUMN_NAME = (value: string, asColumnName: string) => `${value} AS ${qc( asColumnName )}`; + +export const PG_TO_TEXT = (value: string) => `${value}::text`; +export const PG_TO_JSONB = (value: string) => `${value}::jsonb`; + +export const PG_ESCAPE_TABLE_OR_COLUMN = (value: string): string => { + const doubleQuote = '"'; + const escapedIdentifier = value.split( doubleQuote ).join( doubleQuote + doubleQuote ); + return doubleQuote + escapedIdentifier + doubleQuote; +}; + +export class PgQueryUtils { + + public static quoteTableName (value: string) : string { + return PG_ESCAPE_TABLE_OR_COLUMN(value); + } + + public static quoteColumnName (value: string) : string { + return PG_ESCAPE_TABLE_OR_COLUMN(value); + } + + + public static quoteTableAndColumn (tableName: string, columnName: string) : string { + return `${PgQueryUtils.quoteTableName(tableName)}.${PgQueryUtils.quoteColumnName(columnName)}`; + } + + public static quoteTableAndColumnAsJsonB (tableName: string, columnName: string) : string { + return `${PgQueryUtils.quoteTableName(tableName)}.${PgQueryUtils.quoteColumnName(columnName)}::jsonb`; + } + + public static quoteTableAndColumnAsText (tableName: string, columnName: string) : string { + return PG_TO_TEXT(PgQueryUtils.quoteTableAndColumn(tableName, columnName)); + } + + public static quoteTableAndColumnAsTimestampString (tableName: string, columnName: string) : string { + return PG_TO_CHAR_TIMESTAMP(PgQueryUtils.quoteTableAndColumn(tableName, columnName)); + } + + public static quoteTableAndColumnAsColumnName (tableName: string, columnName: string, asColumnName: string) : string { + return PG_AS_COLUMN_NAME(PgQueryUtils.quoteTableAndColumnAsText(tableName, columnName), asColumnName); + } + + public static quoteTableAndColumnAsTextAsColumnName (tableName: string, columnName: string, asColumnName: string) : string { + return PG_AS_COLUMN_NAME(PG_TO_TEXT(PgQueryUtils.quoteTableAndColumn(tableName, columnName)), asColumnName); + } + + public static quoteTableAndColumnAsTimestampStringAsColumnName (tableName: string, columnName: string, asColumnName: string) : string { + return PG_AS_COLUMN_NAME(PG_TO_CHAR_TIMESTAMP(PgQueryUtils.quoteTableAndColumn(tableName, columnName)), asColumnName); + } + + + /** + * Returns the placeholder for values + * + * This will be `$#` which will be later changed to `$1`, `$2` etc. + */ + public static getValuePlaceholder () : string { + return '$#'; + } + + public static getValuePlaceholderAsText () : string { + return PG_TO_TEXT(this.getValuePlaceholder()); + } + + public static getValuePlaceholderAsJsonB () : string { + return PG_TO_JSONB(this.getValuePlaceholder()); + } + + public static getValuePlaceholderAsTimestamp () : string { + return PG_TO_TIMESTAMP(this.getValuePlaceholder()); + } + + public static getValuePlaceholderAsTimestampString () : string { + return PG_TO_CHAR_TIMESTAMP(this.getValuePlaceholder()); + } + + + /** + * Converts any parameter placeholder in a string to `$N` where `N` is 1, 2, 3, etc. + * + * E.g. a string like `"SELECT * FROM table WHERE foo = $# AND bar = $#"` + * will be changed to: `"SELECT * FROM table WHERE foo = $1 AND bar = $2"` + * + * @param query + * @see {@link PgQueryUtils.getValuePlaceholder} + */ + public static parametizeQuery (query: string) : string { + const placeholder = this.getValuePlaceholder(); + let i = 1; + while ( query.indexOf(placeholder) >= 0 ) { + query = query.replace(placeholder, () => `$${i++}`); + } + return query; + } + +} diff --git a/data/query/sql/delete/DeleteQueryBuilder.ts b/data/query/sql/delete/DeleteQueryBuilder.ts new file mode 100644 index 0000000..4a6af5c --- /dev/null +++ b/data/query/sql/delete/DeleteQueryBuilder.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { QueryWhereable } from "../../types/QueryWhereable"; +import { TablePrefixable } from "../../types/TablePrefixable"; + +export interface DeleteQueryBuilder extends QueryWhereable, TablePrefixable, QueryBuilder { + + + + + /////////////////////// QueryWhereable /////////////////////// + + buildWhereQueryString () : string; + + getWhereValueFactories () : readonly QueryValueFactory[]; + + setWhereFromQueryBuilder (builder: QueryBuilder): void; + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + */ + setTablePrefix (prefix: string) : void; + + /** + * @inheritDoc + */ + getTablePrefix (): string; + + /** + * @inheritDoc + */ + getTableNameWithPrefix (tableName : string) : string; + + /** + * @inheritDoc + */ + setTableName (tableName: string): void; + + /** + * @inheritDoc + */ + getTableName (): string; + + /** + * Get the complete table name where to insert rows including the prefix + */ + getCompleteTableName (): string; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + + +} diff --git a/data/query/sql/delete/EntityDeleteQueryBuilder.ts b/data/query/sql/delete/EntityDeleteQueryBuilder.ts new file mode 100644 index 0000000..032e4e0 --- /dev/null +++ b/data/query/sql/delete/EntityDeleteQueryBuilder.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { DeleteQueryBuilder } from "./DeleteQueryBuilder"; +import { Where } from "../../../Where"; +import { EntityField } from "../../../types/EntityField"; +import { ChainQueryBuilder } from "../../types/ChainQueryBuilder"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { QueryEntityWhereable } from "../../types/QueryEntityWhereable"; + +export interface EntityDeleteQueryBuilder extends DeleteQueryBuilder, QueryEntityWhereable { + + + + /////////////////////// DeleteQueryBuilder /////////////////////// + + + + /////////////////////// QueryEntityWhereable /////////////////////// + + + /** + * @inheritDoc + */ + buildAnd ( + where : Where, + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) : ChainQueryBuilder; + + + /////////////////////// QueryWhereable /////////////////////// + + + /** + * @inheritDoc + */ + setWhereFromQueryBuilder (builder: QueryBuilder): void; + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + */ + getTablePrefix (): string; + + /** + * @inheritDoc + */ + setTablePrefix (prefix: string) : void; + + /** + * @inheritDoc + */ + getTableNameWithPrefix (tableName : string) : string; + + /** + * @inheritDoc + */ + getTableName (): string; + + /** + * @inheritDoc + */ + setTableName (tableName: string): void; + + /** + * @inheritDoc + */ + getCompleteTableName (): string; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + + +} diff --git a/data/query/sql/insert/BaseInsertQueryBuilder.ts b/data/query/sql/insert/BaseInsertQueryBuilder.ts new file mode 100644 index 0000000..44187d7 --- /dev/null +++ b/data/query/sql/insert/BaseInsertQueryBuilder.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../../functions/map"; +import { forEach } from "../../../../functions/forEach"; +import { InsertQueryBuilder } from "./InsertQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; + +/** + * Defines an abstract class for a builder of relational database create query. + */ +export abstract class BaseInsertQueryBuilder implements InsertQueryBuilder { + + + private readonly _prefixQueries : (() => string)[]; + private readonly _prefixValues : QueryValueFactory[]; + + private readonly _inputQueries : (() => string)[]; + private readonly _inputValues : QueryValueFactory[]; + + private readonly _columnNameSeparator : string; + private readonly _columnNameQueries : (() => string)[]; + private readonly _columnNameValues : QueryValueFactory[]; + + private _intoTableName : string | undefined; + private _tablePrefix : string = ''; + + /** + * Constructs the internal data values for SELECT queries. + * + * @protected + */ + protected constructor ( + columnNameSeparator : string + ) { + this._intoTableName = undefined; + this._tablePrefix = ''; + this._columnNameSeparator = columnNameSeparator; + this._prefixQueries = []; + this._prefixValues = []; + this._inputQueries = []; + this._inputValues = []; + this._columnNameQueries = []; + this._columnNameValues = []; + } + + public addColumnFactory ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + this._columnNameQueries.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._columnNameValues.push(factory); + } + ); + } + + public abstract addColumnName ( + name: string + ) : void; + + + public addPrefixFactory ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + this._prefixQueries.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._prefixValues.push(factory); + } + ); + } + + public addValueFactory ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + this._inputQueries.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._inputValues.push(factory); + } + ); + } + + public abstract appendValueList (list: any[]) : void; + + public abstract appendValueObject ( + columnNames: readonly string[], + list: {readonly [key: string] : any} + ) : void; + + public appendValueListUsingQueryBuilder (builder: QueryBuilder) : void { + this.addValueFactory( + () => `(${builder.buildQueryString()})`, + ...builder.getQueryValueFactories() + ); + } + + /////////////////////// InsertQueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return this._tablePrefix; + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) : void { + this._tablePrefix = prefix; + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + public getTableName (): string { + if (!this._intoTableName) throw new TypeError('The table name where to insert entities has not been configured in the query builder'); + return this._intoTableName; + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) { + this._intoTableName = tableName; + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getCompleteFromTable} + */ + public getFullTableName (): string { + if (!this._intoTableName) throw new TypeError(`The table where to insert rows has not been initialized yet`); + return this.getTableNameWithPrefix(this._intoTableName); + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName : string) : string { + return `${this._tablePrefix}${tableName}`; + } + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public valueOf() : string { + return this.toString(); + } + + /** + * @inheritDoc + */ + public toString() : string { + return `"${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + /** + * @inheritDoc + */ + public build (): QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + /** + * @inheritDoc + */ + public buildQueryString (): string { + const prefixes = map(this._prefixQueries, (f) => f()); + if (!prefixes.length) throw new TypeError('No prefix factories detected for insert query builder! This must be an error.'); + const columnNameValues = map(this._columnNameQueries, (f) => f()); + if (!columnNameValues.length) throw new TypeError('No value placeholders detected for insert query builder! This must be an error.'); + const inputValues = map(this._inputQueries, (f) => f()); + if (!inputValues.length) throw new TypeError('No value placeholders detected for insert query builder! This must be an error.'); + return `${prefixes.join(' ')} (${columnNameValues.join(this._columnNameSeparator)}) VALUES ${inputValues.join(', ')}`; + } + + /** + * @inheritDoc + */ + public buildQueryValues () : readonly any[] { + return map(this.getQueryValueFactories(), (f) => f()); + } + + /** + * @inheritDoc + */ + public getQueryValueFactories () : readonly QueryValueFactory[] { + return [ + ...this._prefixValues, + ...this._columnNameValues, + ...this._inputValues + ]; + } + + +} diff --git a/data/query/sql/insert/EntityInsertQueryBuilder.ts b/data/query/sql/insert/EntityInsertQueryBuilder.ts new file mode 100644 index 0000000..b848a46 --- /dev/null +++ b/data/query/sql/insert/EntityInsertQueryBuilder.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { InsertQueryBuilder } from "./InsertQueryBuilder"; +import { QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; + +/** + * Defines an interface for a builder of relational database create query. + */ +export interface EntityInsertQueryBuilder extends InsertQueryBuilder { + + + + + + /////////////////////// InsertQueryBuilder /////////////////////// + + + /** + * Get the table prefix + * + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + getTablePrefix (): string; + + /** + * Set the table prefix + * + * @see {@link InsertQueryBuilder.setTablePrefix} + */ + setTablePrefix (prefix: string) : void; + + /** + * Get the table name where to insert rows, without the prefix + * + * @param tableName + */ + getTableName (): string; + + /** + * Set the table name where to insert rows, without the prefix + * + * @param tableName + */ + setTableName (tableName: string): void; + + /** + * Get the complete table name where to insert rows including the prefix + */ + getFullTableName (): string; + + /** + * Get the complete table name with the prefix + * + * @param tableName The table name without the prefix + */ + getTableNameWithPrefix (tableName : string) : string; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + + +} diff --git a/data/query/sql/insert/InsertQueryBuilder.ts b/data/query/sql/insert/InsertQueryBuilder.ts new file mode 100644 index 0000000..16518c1 --- /dev/null +++ b/data/query/sql/insert/InsertQueryBuilder.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; + +/** + * Defines an interface for a builder of relational database create query. + */ +export interface InsertQueryBuilder extends QueryBuilder { + + + /** + * Get the table prefix + * + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + getTablePrefix (): string; + + /** + * Set the table prefix + * + * @see {@link InsertQueryBuilder.setTablePrefix} + */ + setTablePrefix (prefix: string) : void; + + /** + * Get the table name where to insert rows, without the prefix + */ + getTableName (): string; + + /** + * Set the table name where to insert rows, without the prefix + * + * @param tableName + */ + setTableName (tableName: string): void; + + /** + * Get the complete table name where to insert rows including the prefix + */ + getFullTableName (): string; + + /** + * Get the complete table name with the prefix + * + * @param tableName The table name without the prefix + */ + getTableNameWithPrefix (tableName : string) : string; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + +} diff --git a/data/query/sql/select/BaseSelectQueryBuilder.ts b/data/query/sql/select/BaseSelectQueryBuilder.ts new file mode 100644 index 0000000..e0b5ebf --- /dev/null +++ b/data/query/sql/select/BaseSelectQueryBuilder.ts @@ -0,0 +1,443 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SelectQueryBuilder } from "./SelectQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { forEach } from "../../../../functions/forEach"; +import { map } from "../../../../functions/map"; +import { PgQueryUtils } from "../../pg/utils/PgQueryUtils"; + +/** + * Base class for SQL select queries. + */ +export abstract class BaseSelectQueryBuilder implements SelectQueryBuilder { + + private readonly _resultSeparator : string; + private readonly _fieldQueries : QueryStringFactory[]; + private readonly _fieldValues : QueryValueFactory[]; + + private readonly _leftJoinSeparator : string; + private readonly _leftJoinQueries : QueryStringFactory[]; + private readonly _leftJoinValues : QueryValueFactory[]; + + private readonly _orderSeparator : string; + private readonly _orderByQueries : QueryStringFactory[]; + private readonly _orderByValues : QueryValueFactory[]; + + private _groupByColumnName : string | undefined; + private _mainTableName : string | undefined; + private _tablePrefix : string = ''; + private _where : QueryBuilder | undefined; + + /** + * Constructs the internal data values for SELECT queries. + * + * @protected + */ + protected constructor ( + resultSeparator: string, + leftJoinSeparator: string, + orderSeparator: string, + ) { + this._resultSeparator = resultSeparator; + this._leftJoinSeparator = leftJoinSeparator; + this._orderSeparator = orderSeparator; + this._groupByColumnName = undefined; + this._mainTableName = undefined; + this._where = undefined; + this._tablePrefix = ''; + this._fieldQueries = []; + this._fieldValues = []; + this._leftJoinQueries = []; + this._leftJoinValues = []; + this._orderByQueries = []; + this._orderByValues = []; + } + + + /////////////////////// SelectQueryBuilder /////////////////////// + + + + //////////////////// QueryResultable ///////////////////// + + + /** + * @inheritDoc + */ + buildResultQueryString () : string { + return map(this._fieldQueries, (f) => f()).join(this._resultSeparator); + } + + /** + * @inheritDoc + */ + getResultValueFactories () : readonly QueryValueFactory[] { + return map(this._fieldValues, (f) => f); + } + + /** + * @inheritDoc + */ + public appendResultExpression ( + queryFactory : QueryStringFactory, + ...valueFactories : QueryValueFactory[] + ) : void { + this._fieldQueries.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._fieldValues.push(factory); + } + ); + } + + /** + * @inheritDoc + */ + appendResultExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : QueryValueFactory[] + ) : void { + this.appendResultExpression( + () => builder.buildQueryString(), + ...builder.getQueryValueFactories(), + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumn} + */ + abstract includeColumn (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsText} + */ + abstract includeColumnAsText (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTime} + */ + abstract includeColumnAsTime (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsDate} + */ + abstract includeColumnAsDate (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTimestamp} + */ + abstract includeColumnAsTimestamp (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeAllColumnsFromTable} + */ + abstract includeAllColumnsFromTable (tableName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnFromQueryBuilder} + */ + abstract includeColumnFromQueryBuilder (builder: QueryBuilder, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeFormulaByString} + */ + abstract includeFormulaByString (formula: string, asColumnName: string): void; + + + //////////////////// QueryOrderable ///////////////////// + + + /** + * @inheritDoc + */ + buildOrderQueryString () : string { + return map(this._orderByQueries, (f) => f()).join(this._orderSeparator); + } + + /** + * @inheritDoc + */ + getOrderValueFactories () : readonly QueryValueFactory[] { + return map(this._orderByValues, (f) => f); + } + + /** + * @inheritDoc + */ + public appendOrderExpression ( + queryFactory : QueryStringFactory, + ...valueFactories : QueryValueFactory[] + ) : void { + this._orderByQueries.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._orderByValues.push(factory); + } + ); + } + + /** + * Append factories for the assignment list from another builder. + * + * @param builder + * @param valueFactories + * @see {@link ListQueryBuilder} + */ + appendOrderExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : QueryValueFactory[] + ) : void { + this.appendOrderExpression( + () => builder.buildQueryString(), + ...builder.getQueryValueFactories(), + ...valueFactories + ); + } + + + //////////////////// QueryGroupable ///////////////////// + + + /** + * Builds the group by query string + */ + public buildGroupByQueryString () : string { + if ( this._groupByColumnName ) { + if (!this._mainTableName) throw new TypeError(`No table initialized`); + return `${PgQueryUtils.quoteTableAndColumn(this.getTableNameWithPrefix(this._mainTableName), this._groupByColumnName)}`; + } + return ''; + } + + /** + * Builds the group by value factory array + */ + public getGroupByValueFactories () : readonly QueryStringFactory[] { + return []; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setGroupByColumn} + */ + public setGroupByColumn (columnName: string) { + this._groupByColumnName = columnName; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getGroupByColumn} + */ + public getGroupByColumn (): string | undefined { + if (!this._groupByColumnName) return undefined; + return this._groupByColumnName; + } + + + //////////////////// QueryLeftJoinable ///////////////////// + + + /** + * Builds the result query string + */ + buildLeftJoinQueryString () : string { + return map(this._leftJoinQueries, (f) => f()).join(this._leftJoinSeparator); + } + + /** + * Builds the result value factory array + */ + getLeftJoinValueFactories () : readonly QueryValueFactory[] { + return map(this._leftJoinValues, (f) => f); + } + + /** + * Append expression to column section using factory functions to the expression list. + * + * @param queryFactory + * @param valueFactories + */ + public appendLeftJoinExpression ( + queryFactory : QueryStringFactory, + ...valueFactories : QueryValueFactory[] + ) : void { + this._leftJoinQueries.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._leftJoinValues.push(factory); + } + ); + } + + /** + * Append factories for the assignment list from another builder. + * + * @param builder + * @param valueFactories + * @see {@link ListQueryBuilder} + */ + public appendLeftJoinExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : QueryValueFactory[] + ) : void { + this.appendLeftJoinExpression( + () => builder.buildQueryString(), + ...builder.getQueryValueFactories(), + ...valueFactories + ); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.leftJoinTable} + */ + abstract leftJoinTable ( + fromTableName : string, + fromColumnName : string, + sourceTableName : string, + sourceColumnName : string + ) : void; + + + /////////////////////// QueryWhereable /////////////////////// + + + /** + * Builds the result query string + */ + public buildWhereQueryString () : string { + if (!this._where) return ''; + return this._where.buildQueryString(); + } + + /** + * Builds the result value factory array + */ + public getWhereValueFactories () : readonly QueryValueFactory[] { + if (!this._where) return []; + return this._where.getQueryValueFactories(); + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setWhereFromQueryBuilder} + */ + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + this._where = builder; + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) { + this._tablePrefix = prefix; + } + + /** + * @inheritDoc + * @see {@link InsertQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return this._tablePrefix; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName : string) : string { + return `${this._tablePrefix}${tableName}`; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getShortFromTable} + */ + public getTableName (): string { + if (!this._mainTableName) throw new TypeError(`From table has not been initialized yet`); + return this._mainTableName; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) { + this._mainTableName = tableName; + } + + /** + * @inheritDoc + * @see {@link TablePrefixable.getCompleteTableName} + * @see {@link SelectQueryBuilder.getCompleteTableName} + */ + public getCompleteTableName (): string { + if (!this._mainTableName) throw new TypeError(`From table has not been initialized yet`); + return this.getTableNameWithPrefix(this._mainTableName); + } + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + */ + abstract valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.toString} + */ + abstract toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + abstract build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + abstract buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + public buildQueryValues () : readonly any[] { + return map(this.getQueryValueFactories(), (f) => f()); + } + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + abstract getQueryValueFactories () : readonly QueryValueFactory[]; + + +} + +export function isBaseSelectQueryBuilder (value: unknown): value is BaseSelectQueryBuilder { + return value instanceof BaseSelectQueryBuilder; +} diff --git a/data/query/sql/select/EntitySelectQueryBuilder.ts b/data/query/sql/select/EntitySelectQueryBuilder.ts new file mode 100644 index 0000000..8721a4d --- /dev/null +++ b/data/query/sql/select/EntitySelectQueryBuilder.ts @@ -0,0 +1,395 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SelectQueryBuilder } from "./SelectQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; +import { EntityField } from "../../../types/EntityField"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { EntityRelationOneToMany } from "../../../types/EntityRelationOneToMany"; +import { EntityRelationManyToOne } from "../../../types/EntityRelationManyToOne"; +import { Where } from "../../../Where"; +import { ChainQueryBuilder } from "../../types/ChainQueryBuilder"; +import { Sort } from "../../../Sort"; +import { QueryEntityWhereable } from "../../types/QueryEntityWhereable"; +import { QueryEntityResultable } from "../../types/QueryEntityResultable"; +import { QueryEntityOrderable } from "../../types/QueryEntityOrderable"; + +export type TableFieldInfoResponse = [EntityField[], TemporalProperty[]]; +export type TableFieldInfoCallback = (tableName: string) => TableFieldInfoResponse; + +/** + * Defines an interface for a builder of relational database read query from + * entity types. + */ +export interface EntitySelectQueryBuilder + extends + SelectQueryBuilder, + QueryEntityWhereable, + QueryEntityResultable, + QueryEntityOrderable +{ + + /** + * Append a relation from one entity to many, e.g. the property will be an + * array. + * + * The PostgreSQL query will look like this: + * + * ``` + * SELECT + * "carts".*, + * array_agg(ROW("cart_items"."cart_item_id", "cart_items"."cart_id", "cart_items"."cart_item_name")) AS "cartItems" + * FROM carts + * LEFT JOIN "cart_items" ON "carts"."cart_id" = "cart_items"."cart_id" + * GROUP BY "carts"."cart_id"; + * ``` + * + * MySQL persister will build a query like this: + * SELECT + * carts.*, + * JSON_ARRAYAGG(JSON_OBJECT("cart_item_id", cart_items.cart_item_id, "cart_id", cart_items.cart_id, "cart_item_name", cart_items.cart_item_name)) AS cartItems + * FROM carts + * LEFT JOIN cart_items ON carts.cart_id = cart_items.cart_id + * GROUP BY carts.cart_id; + * + * e.g.: + * + * SELECT + * ??.*, + * JSON_ARRAYAGG(JSON_OBJECT(?, ??.??, ?, ??.??, ?, ??.??)) AS ?? + * FROM ?? + * LEFT JOIN ?? ON ??.?? = ??.?? + * GROUP BY ??.??; + * + * @param propertyName + * @param fields + * @param temporalProperties + * @param targetTableName + * @param targetColumnName + * @param sourceTableName + * @param sourceColumnName + */ + setOneToMany ( + propertyName: string, + fields: readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + targetTableName : string, + targetColumnName : string, + sourceTableName : string, + sourceColumnName : string + ): void; + + /** + * Append a relation from many entities to one, e.g. the property will be + * single entity object. + * + * The PostgreSQL query will look like this: + * ``` + * SELECT + * "cart_items".*, + * json_agg(json_build_object('cart_id', "carts"."cart_id", 'cart_name', "carts"."cart_name"))->0 AS cart + * FROM "cart_items" + * LEFT JOIN "carts" ON "carts"."cart_id" = "cart_items"."cart_id" + * GROUP BY "cart_items"."cart_item_id"; + * ``` + * + * ***Note*** that this only works if the "carts"."carts_id" is unique! + * There will be multiple entities returned if not. + * + * + * MySQL persister will do query like this: + * + * SELECT + * cart_items.*, + * JSON_OBJECT("cart_id", carts.cart_id, "cart_name", carts.cart_name) AS cart + * FROM cart_items + * LEFT JOIN carts ON carts.cart_id = cart_items.cart_id + * GROUP BY cart_items.cart_item_id; + * + * SELECT + * ??.*, + * JSON_OBJECT(?, ??.??, ?, ??.??) AS ?? + * FROM ?? + * LEFT JOIN ?? ON ??.?? = ??.?? + * GROUP BY ??.??; + * + * @param propertyName + * @param fields + * @param temporalProperties + * @param targetTableName + * @param targetColumnName + * @param sourceTableName + * @param sourceColumnName + */ + setManyToOne ( + propertyName: string, + fields: readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + targetTableName : string, + targetColumnName : string, + sourceTableName : string, + sourceColumnName : string + ): void; + + /** + * + * @param relations + * @param resolveMappedFieldInfo + */ + setOneToManyRelations ( + relations: readonly EntityRelationOneToMany[], + resolveMappedFieldInfo: TableFieldInfoCallback, + ): void; + + /** + * + * @param relations + * @param resolveMappedFieldInfo + * @param fields + * @param temporalProperties + */ + setManyToOneRelations ( + relations: readonly EntityRelationManyToOne[], + resolveMappedFieldInfo: TableFieldInfoCallback, + fields: readonly EntityField[], + temporalProperties: readonly TemporalProperty[] + ): void; + + + /////////////////////// QueryEntityResultable /////////////////////// + + + includeEntityFields ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ): void; + + + /////////////////////// TableResultable /////////////////////// + + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumn} + */ + includeColumn (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsText} + */ + includeColumnAsText (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTime} + */ + includeColumnAsTime (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsDate} + */ + includeColumnAsDate (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnAsTimestamp} + */ + includeColumnAsTimestamp (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @deprecated Use EntitySelectQueryBuilder.includeEntityFields instead of + * this method. + * + * @inheritDoc + * @see {@link EntitySelectQueryBuilder.includeEntityFields} + * @see {@link SelectQueryBuilder.includeAllColumnsFromTable} + */ + includeAllColumnsFromTable (tableName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeColumnFromQueryBuilder} + */ + includeColumnFromQueryBuilder (builder: QueryBuilder, asColumnName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.includeFormulaByString} + */ + includeFormulaByString (formula: string, asColumnName: string): void; + + + /** + * Include fields from an entity in to the query. + * + * @param tableName The table name + * @param fields Entity field definitions + * @param temporalProperties Temporal property definitions + */ + includeEntityFields ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ): void; + + + /////////////////////// QueryGroupable /////////////////////// + + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setGroupByColumn} + */ + setGroupByColumn (columnName: string) : void; + + + /////////////////////// QueryOrderable /////////////////////// + + + /** + * Set order of fields. + * + * @param sort The sorting configuration + * @param tableName The table name + * @param fields Entity field definitions + */ + setOrderByTableFields ( + sort : Sort, + tableName : string, + fields : readonly EntityField[] + ): void; + + + /////////////////////// QueryLeftJoinable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.leftJoinTable} + */ + leftJoinTable ( + fromTableName : string, + fromColumnName : string, + sourceTableName : string, + sourceColumnName : string + ) : void; + + + /////////////////////// QueryEntityWhereable /////////////////////// + + + /** + * @inheritDoc + */ + buildAnd ( + where : Where, + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) : ChainQueryBuilder; + + + /////////////////////// QueryWhereable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setWhereFromQueryBuilder} + */ + setWhereFromQueryBuilder (builder: QueryBuilder): void; + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setTablePrefix} + */ + setTablePrefix (prefix: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getTablePrefix} + */ + getTablePrefix () : string; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getCompleteTableName} + */ + getTableNameWithPrefix (tableName : string) : string; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setFromTable} + */ + setTableName (tableName: string) : void; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getShortFromTable} + */ + getTableName () : string; + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.getCompleteFromTable} + */ + getCompleteTableName () : string; + + + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link SelectQueryBuilder.valueOf} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.toString} + * @see {@link SelectQueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + * @see {@link SelectQueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + * @see {@link SelectQueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + * @see {@link SelectQueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + * @see {@link SelectQueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + + + +} diff --git a/data/query/sql/select/SelectQueryBuilder.ts b/data/query/sql/select/SelectQueryBuilder.ts new file mode 100644 index 0000000..ca6679a --- /dev/null +++ b/data/query/sql/select/SelectQueryBuilder.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { TablePrefixable } from "../../types/TablePrefixable"; +import { QueryWhereable } from "../../types/QueryWhereable"; +import { QueryGroupable } from "../../types/QueryGroupable"; +import { QueryLeftJoinable } from "../../types/QueryLeftJoinable"; +import { QueryResultable } from "../../types/QueryResultable"; +import { QueryOrderable } from "../../types/QueryOrderable"; + +/** + * Defines an interface for a builder of relational database read query. + */ +export interface SelectQueryBuilder + extends + QueryBuilder, + TablePrefixable, + QueryWhereable, + QueryGroupable, + QueryLeftJoinable, + QueryResultable, + QueryOrderable +{ + + + + //////////////////// QueryResultable ///////////////////// + + + /** + * @inheritDoc + */ + appendResultExpression ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + /** + * @inheritDoc + */ + appendResultExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + /** + * @inheritDoc + */ + includeColumn (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + */ + includeColumnAsText (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + */ + includeColumnAsTime (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + */ + includeColumnAsDate (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + */ + includeColumnAsTimestamp (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * @inheritDoc + */ + includeAllColumnsFromTable (tableName: string) : void; + + /** + * @inheritDoc + */ + includeColumnFromQueryBuilder (builder: QueryBuilder, asColumnName: string) : void; + + /** + * @inheritDoc + */ + includeFormulaByString (formula: string, asColumnName: string): void; + + + //////////////////// QueryLeftJoinable ///////////////////// + + + /** + * @inheritDoc + */ + leftJoinTable ( + fromTableName : string, + fromColumnName : string, + sourceTableName : string, + sourceColumnName : string + ) : void; + + + ///////////////////// QueryOrderable /////////////////////// + + + buildOrderQueryString () : string; + + getOrderValueFactories () : readonly QueryStringFactory[]; + + appendOrderExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryStringFactory[] + ) : void; + + appendOrderExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryStringFactory[] + ) : void; + + + ///////////////////// QueryGroupable /////////////////////// + + + /** + * @inheritDoc + */ + setGroupByColumn (columnName: string) : void; + + + /////////////////////// QueryWhereable /////////////////////// + + + /** + * @inheritDoc + */ + setWhereFromQueryBuilder (builder: QueryBuilder): void; + + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + */ + setTablePrefix (prefix: string) : void; + + /** + * @inheritDoc + */ + getTablePrefix () : string; + + /** + * @inheritDoc + */ + getTableNameWithPrefix (tableName : string) : string; + + /** + * @inheritDoc + */ + setTableName (tableName: string) : void; + + /** + * @inheritDoc + */ + getTableName () : string; + + /** + * @inheritDoc + */ + getCompleteTableName () : string; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + + +} diff --git a/data/query/sql/update/BaseUpdateQueryBuilder.ts b/data/query/sql/update/BaseUpdateQueryBuilder.ts new file mode 100644 index 0000000..7bcb604 --- /dev/null +++ b/data/query/sql/update/BaseUpdateQueryBuilder.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../../functions/map"; +import { forEach } from "../../../../functions/forEach"; +import { UpdateQueryBuilder } from "./UpdateQueryBuilder"; +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; + +/** + * Defines an abstract class for a builder of relational database create query. + */ +export abstract class BaseUpdateQueryBuilder implements UpdateQueryBuilder { + + private readonly _prefixQueries : QueryStringFactory[]; + private readonly _prefixValues : QueryValueFactory[]; + private readonly _setQueries : QueryStringFactory[]; + private readonly _setValues : QueryValueFactory[]; + + private _tableName : string | undefined; + private _tablePrefix : string = ''; + private _where : QueryBuilder | undefined; + + /** + * Constructs the internal data values for SELECT queries. + * + * @protected + */ + protected constructor () { + this._tableName = undefined; + this._tablePrefix = ''; + this._prefixQueries = []; + this._prefixValues = []; + this._setQueries = []; + this._setValues = []; + } + + + /////////////////////// UpdateQueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public addPrefixFactory ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + this._prefixQueries.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._prefixValues.push(factory); + } + ); + } + + /** + * @inheritDoc + */ + public addSetFactory ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void { + this._setQueries.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._setValues.push(factory); + } + ); + } + + /** + * @inheritDoc + */ + public appendSetListUsingQueryBuilder (builder: QueryBuilder) : void { + this.addSetFactory( + () => builder.buildQueryString(), + ...builder.getQueryValueFactories() + ); + } + + + /////////////////////// QueryWhereable /////////////////////// + + + + buildWhereQueryString () : string { + return this._where ? this._where.buildQueryString() : ''; + } + + getWhereValueFactories () : readonly QueryValueFactory[] { + return this._where ? this._where.getQueryValueFactories() : []; + } + + /** + * @inheritDoc + * @see {@link SelectQueryBuilder.setWhereFromQueryBuilder} + */ + public setWhereFromQueryBuilder (builder: QueryBuilder): void { + this._where = builder; + } + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getTablePrefix} + */ + public getTablePrefix (): string { + return this._tablePrefix; + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.setTablePrefix} + */ + public setTablePrefix (prefix: string) : void { + this._tablePrefix = prefix; + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getTablePrefix} + */ + public getTableName (): string { + if (!this._tableName) throw new TypeError('The table name where to update entities has not been configured in the query builder'); + return this._tableName; + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.setFromTable} + */ + public setTableName (tableName: string) { + this._tableName = tableName; + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getCompleteFromTable} + */ + public getCompleteTableName (): string { + if (!this._tableName) throw new TypeError(`The table where to update rows has not been initialized yet`); + return this.getTableNameWithPrefix(this._tableName); + } + + /** + * @inheritDoc + * @see {@link UpdateQueryBuilder.getCompleteTableName} + */ + public getTableNameWithPrefix (tableName : string) : string { + return `${this._tablePrefix}${tableName}`; + } + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public valueOf() : string { + return this.toString(); + } + + /** + * @inheritDoc + */ + public toString() : string { + return `"${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + /** + * @inheritDoc + */ + public build (): QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + /** + * @inheritDoc + */ + public buildQueryString (): string { + const prefixes = map(this._prefixQueries, (f) => f()); + if (!prefixes.length) throw new TypeError('No prefix factories detected for update query builder! This must be an error.'); + const setQuery = map(this._setQueries, (f) => f()); + if (!setQuery.length) throw new TypeError('No value placeholders detected for update query builder! This must be an error.'); + const where = this._where ? this._where.buildQueryString() : ''; + return `${ + prefixes.join(' ') + } SET ${ + setQuery.join(', ') + }${ + where ? ` WHERE ${where}` : '' + }`; + } + + /** + * @inheritDoc + */ + public buildQueryValues () : readonly any[] { + return map(this.getQueryValueFactories(), (f) => f()); + } + + /** + * @inheritDoc + */ + public getQueryValueFactories (): readonly QueryValueFactory[] { + return [ + ...this._prefixValues, + ...this._setValues, + ...(this._where ? this._where.getQueryValueFactories() : []) + ]; + } + + +} diff --git a/data/query/sql/update/EntityUpdateQueryBuilder.ts b/data/query/sql/update/EntityUpdateQueryBuilder.ts new file mode 100644 index 0000000..bb7f6a7 --- /dev/null +++ b/data/query/sql/update/EntityUpdateQueryBuilder.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { UpdateQueryBuilder } from "./UpdateQueryBuilder"; +import { Entity } from "../../../Entity"; +import { EntityField } from "../../../types/EntityField"; +import { TemporalProperty } from "../../../types/TemporalProperty"; +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "../../types/QueryBuilder"; + +/** + * Defines an interface for a builder of relational database create query. + */ +export interface EntityUpdateQueryBuilder extends UpdateQueryBuilder { + + + /** + * Sets the assigment list on the update query based on this entity. + * + * @param entity + * @param fields + * @param temporalProperties + * @param ignoreProperties + */ + appendEntity ( + entity : T, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + ignoreProperties : readonly string[], + ) : void; + + + /////////////////////// UpdateQueryBuilder /////////////////////// + + + + /////////////////////// TableWhereable /////////////////////// + + + /** + * @inheritDoc + */ + setWhereFromQueryBuilder (builder: QueryBuilder): void; + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + */ + getTablePrefix (): string; + + /** + * @inheritDoc + */ + setTablePrefix (prefix: string) : void; + + /** + * @inheritDoc + */ + getTableNameWithPrefix (tableName : string) : string; + + /** + * @inheritDoc + */ + getTableName (): string; + + /** + * @inheritDoc + */ + setTableName (tableName: string): void; + + /** + * @inheritDoc + */ + getCompleteTableName (): string; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + + +} diff --git a/data/query/sql/update/UpdateQueryBuilder.ts b/data/query/sql/update/UpdateQueryBuilder.ts new file mode 100644 index 0000000..1246927 --- /dev/null +++ b/data/query/sql/update/UpdateQueryBuilder.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryStringFactory, QueryValueFactory } from "../../types/QueryBuilder"; +import { QueryWhereable } from "../../types/QueryWhereable"; +import { TablePrefixable } from "../../types/TablePrefixable"; + +/** + * Defines an interface for a builder of relational database update query. + */ +export interface UpdateQueryBuilder extends QueryBuilder, QueryWhereable, TablePrefixable { + + /** + * Append factories for prefix part of the update query. + * + * This configures the start of the query, which usually includes + * `UPDATE ?` with a table name. + * + * @param queryFactory + * @param valueFactories + */ + addPrefixFactory ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + /** + * Append factories for the assignment list part of the update query. + * + * This is the part of `SET ??=?, ??=?` without the SET keyword. + * + * @param queryFactory + * @param valueFactories + */ + addSetFactory ( + queryFactory : QueryStringFactory, + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + /** + * Append factories for the assignment list from another builder. + * + * @param builder + * @see {@link ListQueryBuilder} + */ + appendSetListUsingQueryBuilder (builder: QueryBuilder) : void; + + + /////////////////////// QueryWhereable /////////////////////// + + + /** + * @inheritDoc + */ + setWhereFromQueryBuilder (builder: QueryBuilder): void; + + + + /////////////////////// TablePrefixable /////////////////////// + + + /** + * @inheritDoc + */ + getTablePrefix (): string; + + /** + * @inheritDoc + */ + setTablePrefix (prefix: string) : void; + + /** + * @inheritDoc + */ + getTableNameWithPrefix (tableName : string) : string; + + /** + * @inheritDoc + */ + getTableName (): string; + + /** + * @inheritDoc + */ + setTableName (tableName: string): void; + + /** + * @inheritDoc + */ + getCompleteTableName (): string; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + + +} diff --git a/data/query/types/BaseListQueryBuilder.ts b/data/query/types/BaseListQueryBuilder.ts new file mode 100644 index 0000000..f61354b --- /dev/null +++ b/data/query/types/BaseListQueryBuilder.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { forEach } from "../../../functions/forEach"; +import { ListQueryBuilder } from "./ListQueryBuilder"; +import { QueryBuildResult, QueryValueFactory } from "./QueryBuilder"; + +/** + * This generates formulas like `(expression[, expression2, ...])` intended to + * be used inside the INSERT query values. + */ +export abstract class BaseListQueryBuilder implements ListQueryBuilder { + + private readonly _separator : string; + private readonly _queryList : (() => string)[]; + private readonly _valueList : QueryValueFactory[]; + + protected constructor ( + separator: string + ) { + this._separator = separator; + this._queryList = []; + this._valueList = []; + } + + + /////////////////// ListQueryBuilder /////////////////// + + + /** + * @inheritDoc + */ + public appendExpression ( + queryFactory : (() => string), + ...valueFactories : QueryValueFactory[] + ) : void { + this._queryList.push(queryFactory); + forEach( + valueFactories, + (factory) => { + this._valueList.push(factory); + } + ); + } + + /** + * @inheritDoc + */ + public abstract setTableColumn ( + tableName: string, + columnName: string + ): void; + + /** + * @inheritDoc + */ + public abstract setTableColumnAsText ( + tableName: string, + columnName: string + ): void; + + /** + * @inheritDoc + */ + public abstract setTableColumnAsTimestampString ( + tableName: string, + columnName: string + ) : void; + + /** + * @inheritDoc + */ + public abstract setParam ( + value: any + ) : void; + + /** + * @inheritDoc + */ + public abstract setParamFactory ( + value: () => any + ) : void; + + /** + * @inheritDoc + */ + public abstract setParamAsText ( + value: any + ): void; + + /** + * @inheritDoc + */ + public abstract setParamFromJson ( + value: any + ): void; + + /** + * @inheritDoc + */ + public abstract setParamFromTimestampString ( + value: any + ): void; + + /** + * @inheritDoc + */ + public abstract setParamAsTimestampValue ( + value: any + ): void; + + /** + * @inheritDoc + */ + public abstract setAssignmentWithParam ( + columnName: string, + value: any + ) : void; + + /** + * @inheritDoc + */ + public abstract setAssignmentWithParamAsTimestamp ( + columnName: string, + value: any + ) : void; + + /** + * @inheritDoc + */ + public abstract setAssignmentWithParamAsJson ( + columnName: string, + value: any + ) : void; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + public valueOf () { + return this.toString(); + } + + /** + * @inheritDoc + */ + public toString () : string { + return `"${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build () : QueryBuildResult { + return [this.buildQueryString(), this.buildQueryValues()]; + } + + public buildQueryString () : string { + return map(this._queryList, (f) => f()).join(this._separator); + } + + public buildQueryValues () : readonly any[] { + return map(this._valueList, (f) => f()); + } + + public getQueryValueFactories () : readonly QueryValueFactory[] { + return this._valueList; + } + + +} diff --git a/data/query/types/ChainQueryBuilder.ts b/data/query/types/ChainQueryBuilder.ts new file mode 100644 index 0000000..b356c13 --- /dev/null +++ b/data/query/types/ChainQueryBuilder.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "./QueryBuilder"; + +export type ChainQueryBuilderFactory = () => ChainQueryBuilder; + +/** + * Interface which implements API to objects used in SQL query building for + * formulas like `expression OR expression2 [ OR ... ]` or + * `expression AND expression2 [ AND ... ]` + */ +export interface ChainQueryBuilder extends QueryBuilder { + + /** + * This will add an expression like `table.column IN (value1, value2, ...)`, + * e.g. the table column's value must match one of the values supplied. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param values An array of values + */ + setColumnInList ( + tableName : string, + columnName : string, + values : readonly any[] + ) : void; + + /** + * This will add an expression like `table.column IN (value1, value2, ...)`, + * e.g. the table column's value must match one of the values supplied. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param values An array of values + */ + setColumnInListAsTime ( + tableName : string, + columnName : string, + values : readonly any[] + ) : void; + + /** + * This will add an expression like `table.column = value`, e.g. the table + * column's value must match the value. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param value The value + */ + setColumnEquals ( + tableName : string, + columnName : string, + value : any + ) : void; + + /** + * This will add an expression like `table.column::jsonb = value::jsonb` + * in PostgreSQL, e.g. the table column's value must match the value in JSONB format which + * support logical matching of JSON objects. + * + * @param tableName + * @param columnName + * @param value + */ + setColumnEqualsAsJson ( + tableName : string, + columnName : string, + value : any + ) : void; + + /** + * This will add an expression like `table.column IS NULL`, e.g. the table + * column's value must be null. + * + * @param tableName The table name for the column + * @param columnName The column name + */ + setColumnIsNull ( + tableName : string, + columnName : string + ) : void; + + /** + * This will add an expression like `table.column = value`, e.g. the table + * column's value must match the value. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param value The value + */ + setColumnEqualsAsTime ( + tableName : string, + columnName : string, + value : any + ) : void; + + /** + * This will add an expression like + * `table.column >= start && table.column <= end`, e.g. the table column's + * value must be between `start` and `end`. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param start Where the range starts + * @param end Where the range starts + */ + setColumnBetween ( + tableName : string, + columnName : string, + start : any, + end : any, + ) : void; + + /** + * This will add an expression like + * `table.column >= start && table.column <= end`, e.g. the table column's + * value must be between `start` and `end`. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param start Where the range starts + * @param end Where the range starts + */ + setColumnBetweenAsTime ( + tableName : string, + columnName : string, + start : any, + end : any, + ) : void; + + /** + * This will add an expression like + * `table.column >= start && table.column <= end`, e.g. the table column's + * value must be between `start` and `end`. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param value The value + */ + setColumnBefore ( + tableName : string, + columnName : string, + value : any + ) : void; + + /** + * This will add an expression like + * `table.column >= start && table.column <= end`, e.g. the table column's + * value must be between `start` and `end`. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param value The value + */ + setColumnBeforeAsTime ( + tableName : string, + columnName : string, + value : any + ) : void; + + /** + * This will add an expression like + * `table.column >= start && table.column <= end`, e.g. the table column's + * value must be between `start` and `end`. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param value The value + */ + setColumnAfter ( + tableName : string, + columnName : string, + value : any + ) : void; + + /** + * This will add an expression like + * `table.column >= start && table.column <= end`, e.g. the table column's + * value must be between `start` and `end`. + * + * @param tableName The table name for the column + * @param columnName The column name + * @param value The value + */ + setColumnAfterAsTime ( + tableName : string, + columnName : string, + value : any + ) : void; + + /** + * This will add the expression by using another QueryBuilder. + * + * @param builder + */ + setFromQueryBuilder ( + builder: QueryBuilder + ) : void; + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + */ + build () : QueryBuildResult; + + /** + * @inheritDoc */ + buildQueryString () : string; + + /** + * @inheritDoc */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + + +} diff --git a/data/query/types/FunctionQueryBuilder.ts b/data/query/types/FunctionQueryBuilder.ts new file mode 100644 index 0000000..6eb4c46 --- /dev/null +++ b/data/query/types/FunctionQueryBuilder.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "./QueryBuilder"; + +/** + * This generates formulas like `f(formula)` + */ +export class FunctionQueryBuilder implements QueryBuilder { + + /** + * The function name + * @private + */ + protected readonly _name : string; + + /** + * This is used in aggregate functions + * + * @private + */ + protected readonly _distinct : boolean; + + protected _builder : QueryBuilder | undefined; + + /** + * + * @param distinct If enabled, will result in `f(DISTINCT formula)`. + * @param name + * @protected + */ + protected constructor ( + distinct : boolean, + name : string + ) { + this._name = name ?? ''; + this._builder = undefined; + this._distinct = distinct; + } + + public static create ( + builder: QueryBuilder, + distinct: boolean, + name: string + ) : FunctionQueryBuilder { + const f = new FunctionQueryBuilder(distinct, name); + f.setFormulaFromQueryBuilder(builder); + return f; + } + + public setFormulaFromQueryBuilder (builder : QueryBuilder) { + this._builder = builder; + } + + + /////////////////////// QueryBuilder /////////////////////// + + + public valueOf () { + return this.toString(); + } + + public toString () : string { + return `PgFunctionBuilder "${this.buildQueryString()}" with ${this.buildQueryValues().map(item=>item()).join(' ')}`; + } + + public build (): QueryBuildResult { + return [ this.buildQueryString(), this.buildQueryValues() ]; + } + + public buildQueryString (): string { + if (!this._builder) throw new TypeError(`Could not build ${this._name}() query string: Query builder not initialized`); + return `${this._name}(${this._distinct ? 'DISTINCT ': ''}${this._builder.buildQueryString()})`; + } + + public buildQueryValues (): readonly any[] { + if (!this._builder) throw new TypeError(`Could not build ${this._name}() query values: Query builder not initialized`); + return this._builder.buildQueryValues(); + } + + public getQueryValueFactories (): readonly QueryValueFactory[] { + if (!this._builder) throw new TypeError(`Could not build ${this._name}() query factories: Query builder not initialized`); + return this._builder.getQueryValueFactories(); + } + +} diff --git a/data/query/types/ListQueryBuilder.ts b/data/query/types/ListQueryBuilder.ts new file mode 100644 index 0000000..441f3c7 --- /dev/null +++ b/data/query/types/ListQueryBuilder.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryBuildResult, QueryValueFactory } from "./QueryBuilder"; + +/** + * Defines an interface for a builder of relational database items in a list. + * + * For example, the items in the INSERT query value lists or the assignment list + * in the UPDATE query. + */ +export interface ListQueryBuilder extends QueryBuilder { + + /** + * Append expression using factory functions to the expression list. + * + * @param queryFactory + * @param valueFactories + */ + appendExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + /** + * Appends column expression which references the column as it is + * in the database + * + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + setTableColumn ( + tableName: string, + columnName: string + ): void; + + /** + * Appends expression which references the column with a cast to text + * + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + setTableColumnAsText ( + tableName: string, + columnName: string + ): void; + + /** + * Appends expression which references the column with a cast as ISO timestamp string. + * + * @param tableName The table name from where to read the value + * @param columnName The column name in the table where to read the value + */ + setTableColumnAsTimestampString ( + tableName: string, + columnName: string + ) : void; + + /** + * Appends expression which maps the value to next item in the list + * + * @param value + */ + setParam ( + value: any + ) : void; + + /** + * Appends expression which uses this factory function as the next value + * + * @param value + */ + setParamFactory ( + value: () => any + ) : void; + + /** + * Appends expression which maps the value to next item cast as a text + * + * @param value + */ + setParamAsText ( + value: any + ): void; + + /** + * Appends expression which maps the value to next item as cast to JSON string + * + * Use this when importing JSON to the database. + * + * @param value + */ + setParamFromJson ( + value: any + ): void; + + /** + * Appends expression which maps the value to next item as cast to ISO timestamp string + * + * Use this when importing timestamps to the database. + * + * @param value + */ + setParamFromTimestampString ( + value: any + ): void; + + /** + * Appends expression which maps the value to next item as cast to relational database time format from ISO timestamp string + * + * Use this when exporting data to the database. + * + * @param value + */ + setParamAsTimestampValue ( + value: any + ): void; + + /** + * Set a parameter with an assigment. + * + * This is usually used in the update clause, e.g. it is the + * `columnName = ?` part from the `UPDATE table SET columnName = ?`. + * + * @param columnName + * @param value + */ + setAssignmentWithParam ( + columnName: string, + value: any + ) : void + + /** + * Set a parameter with an assigment. + * + * This is usually used in the update clause, e.g. it is the + * `columnName = DATE_FORMAT(?, ...)` part from the + * `UPDATE table SET columnName = DATE_FORMAT(?, ...)`. + * + * @param columnName The column name to set + * @param value The time value + */ + setAssignmentWithParamAsTimestamp ( + columnName: string, + value: any + ) : void + + /** + * Set a parameter with an assigment. + * + * This is usually used in the update clause, e.g. it is the + * `columnName = ?` part from the + * `UPDATE table SET columnName = ?`. + * + * @param columnName The column name to set + * @param value The time value + */ + setAssignmentWithParamAsJson ( + columnName: string, + value: any + ) : void + + + /////////////////////// QueryBuilder /////////////////////// + + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + valueOf() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * @inheritDoc + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * @inheritDoc + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + +} diff --git a/data/query/types/QueryBuilder.ts b/data/query/types/QueryBuilder.ts new file mode 100644 index 0000000..0ca0cdb --- /dev/null +++ b/data/query/types/QueryBuilder.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export type QueryStringFactory = (() => string); +export type QueryValueFactory = (() => any); +export type QueryBuildResult = readonly [ string, readonly any[] ]; + +/** + * Defines an interface for a builder of relational database query. + * + * @see {@link ChainQueryBuilder} + * @see {@link DeleteQueryBuilder} + * @see {@link SelectQueryBuilder} + * @see {@link EntitySelectQueryBuilder} + */ +export interface QueryBuilder { + + /** + * Returns the value of the builder. This will be a string presentation of + * what this builder would do. + * + * @returns Textual presentation of what the builder would do + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + valueOf() : string; + + /** + * Returns a string presentation of + * what this builder would do. + * + * @returns Textual presentation of what the builder would do + * @see {@link QueryBuilder.valueOf} + * @see {@link QueryBuilder.toString} + */ + toString() : string; + + /** + * Builds query string and values for SQL query. + * + * @returns The query string and linked values for it + * @see {@link QueryBuilder.build} + */ + build () : QueryBuildResult; + + /** + * Builds the SQL query with possible value placeholders and returns it as + * a string + * + * @see {@link QueryBuilder.buildQueryString} + */ + buildQueryString () : string; + + /** + * Builds an array of values for placeholders in the SQL query + * + * @see {@link QueryBuilder.buildQueryValues} + */ + buildQueryValues () : readonly any[]; + + /** + * Returns array of factory functions which can be used to build array of + * values for the SQL query + * + * @see {@link QueryBuilder.getQueryValueFactories} + */ + getQueryValueFactories () : readonly QueryValueFactory[]; + +} diff --git a/data/query/types/QueryEntityOrderable.ts b/data/query/types/QueryEntityOrderable.ts new file mode 100644 index 0000000..70bd744 --- /dev/null +++ b/data/query/types/QueryEntityOrderable.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryStringFactory } from "./QueryBuilder"; +import { Sort } from "../../Sort"; +import { EntityField } from "../../types/EntityField"; +import { QueryOrderable } from "./QueryOrderable"; + +export interface QueryEntityOrderable extends QueryOrderable { + + /** + * + * @param sort + * @param tableName + * @param fields + */ + setOrderByTableFields ( + sort : Sort, + tableName : string, + fields : readonly EntityField[] + ) : void; + + + //////////////////// QueryOrderable ///////////////////// + + + /** + * Builds the result query string + */ + buildOrderQueryString () : string; + + /** + * Builds the result value factory array + */ + getOrderValueFactories () : readonly QueryStringFactory[]; + + /** + * Append expression to ORDER BY section using factory functions to the expression list. + * + * @param queryFactory + * @param valueFactories + */ + appendOrderExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryStringFactory[] + ) : void; + + /** + * Append factories for the ORDER BY section from another builder. + * + * @param builder + * @param valueFactories + * @see {@link ListQueryBuilder} + */ + appendOrderExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryStringFactory[] + ) : void; + + +} diff --git a/data/query/types/QueryEntityResultable.ts b/data/query/types/QueryEntityResultable.ts new file mode 100644 index 0000000..a5b1eb4 --- /dev/null +++ b/data/query/types/QueryEntityResultable.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryResultable } from "./QueryResultable"; +import { EntityField } from "../../types/EntityField"; +import { TemporalProperty } from "../../types/TemporalProperty"; + +export interface QueryEntityResultable extends QueryResultable { + + + includeEntityFields ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ): void; + + +} diff --git a/data/query/types/QueryEntityWhereable.ts b/data/query/types/QueryEntityWhereable.ts new file mode 100644 index 0000000..b6528a9 --- /dev/null +++ b/data/query/types/QueryEntityWhereable.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryValueFactory } from "./QueryBuilder"; +import { Where } from "../../Where"; +import { EntityField } from "../../types/EntityField"; +import { ChainQueryBuilder } from "./ChainQueryBuilder"; +import { QueryWhereable } from "./QueryWhereable"; +import { TemporalProperty } from "../../types/TemporalProperty"; + +export interface QueryEntityWhereable extends QueryWhereable { + + + /** + * Build a chain of "and" operations for filtering criteria. + * + * @param where The criteria to filter entities + * @param tableName The table name without prefix + * @param fields Entity field definitions + * @param temporalProperties + */ + buildAnd ( + where : Where, + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ) : ChainQueryBuilder; + + + /////////////////////// QueryWhereable /////////////////////// + + + /** + * Builds the result query string + */ + buildWhereQueryString () : string; + + /** + * Builds the result value factory array + */ + getWhereValueFactories () : readonly QueryValueFactory[]; + + /** + * Maps filtering options to the query from another query builder. + * + * @param builder + */ + setWhereFromQueryBuilder (builder: QueryBuilder): void; + + +} diff --git a/data/query/types/QueryGroupable.ts b/data/query/types/QueryGroupable.ts new file mode 100644 index 0000000..ef05fde --- /dev/null +++ b/data/query/types/QueryGroupable.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryStringFactory } from "./QueryBuilder"; + +export interface QueryGroupable { + + /** + * Builds the group by query string + */ + buildGroupByQueryString () : string; + + /** + * Builds the group by value factory array + */ + getGroupByValueFactories () : readonly QueryStringFactory[]; + + /** + * Set the query to group results by the column name. + * + * This is usually used with {@link SelectQueryBuilder.leftJoinTable} and + * aggregation functions. + * + * @param columnName The column name + * @see {@link SelectQueryBuilder.leftJoinTable} + */ + setGroupByColumn (columnName: string) : void; + + /** + * Set group by column. + * + * @see {@link SelectQueryBuilder.getGroupByColumn} + * @see {@link EntitySelectQueryBuilder.getGroupByColumn} + */ + getGroupByColumn (): string | undefined; + +} diff --git a/data/query/types/QueryLeftJoinable.ts b/data/query/types/QueryLeftJoinable.ts new file mode 100644 index 0000000..0a9596c --- /dev/null +++ b/data/query/types/QueryLeftJoinable.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryValueFactory } from "./QueryBuilder"; + +export interface QueryLeftJoinable { + + + /** + * Builds the result query string + */ + buildLeftJoinQueryString () : string; + + /** + * Builds the result value factory array + */ + getLeftJoinValueFactories () : readonly QueryValueFactory[]; + + /** + * Append expression to column section using factory functions to the expression list. + * + * @param queryFactory + * @param valueFactories + */ + appendLeftJoinExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + /** + * Append factories for the assignment list from another builder. + * + * @param builder + * @param valueFactories + * @see {@link ListQueryBuilder} + */ + appendLeftJoinExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + + /** + * Maps values from another table to the query base on common column. + * + * @param fromTableName + * @param fromColumnName + * @param sourceTableName + * @param sourceColumnName + * @see {@link SelectQueryBuilder.leftJoinTable} + */ + leftJoinTable ( + fromTableName : string, + fromColumnName : string, + sourceTableName : string, + sourceColumnName : string + ) : void; + + +} diff --git a/data/query/types/QueryOrderable.ts b/data/query/types/QueryOrderable.ts new file mode 100644 index 0000000..427cd89 --- /dev/null +++ b/data/query/types/QueryOrderable.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryStringFactory } from "./QueryBuilder"; + +export interface QueryOrderable { + + + /** + * Builds the result query string + */ + buildOrderQueryString () : string; + + /** + * Builds the result value factory array + */ + getOrderValueFactories () : readonly QueryStringFactory[]; + + /** + * Append expression to ORDER BY section using factory functions to the expression list. + * + * @param queryFactory + * @param valueFactories + */ + appendOrderExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryStringFactory[] + ) : void; + + /** + * Append factories for the ORDER BY section from another builder. + * + * @param builder + * @param valueFactories + * @see {@link ListQueryBuilder} + */ + appendOrderExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryStringFactory[] + ) : void; + + +} diff --git a/data/query/types/QueryResultable.ts b/data/query/types/QueryResultable.ts new file mode 100644 index 0000000..9d55c4b --- /dev/null +++ b/data/query/types/QueryResultable.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryValueFactory } from "./QueryBuilder"; + +export interface QueryResultable { + + /** + * Builds the result query string + */ + buildResultQueryString () : string; + + /** + * Builds the result value factory array + */ + getResultValueFactories () : readonly QueryValueFactory[]; + + /** + * Append expression to column section using factory functions to the expression list. + * + * @param queryFactory + * @param valueFactories + */ + appendResultExpression ( + queryFactory : (() => string), + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + /** + * Append factories for the assignment list from another builder. + * + * @param builder + * @param valueFactories + * @see {@link ListQueryBuilder} + */ + appendResultExpressionUsingQueryBuilder ( + builder: QueryBuilder, + ...valueFactories : readonly QueryValueFactory[] + ) : void; + + + /** + * Includes the column in the query in unspecified format. + * + * This will result to the type which the internal database system and/or + * the client library for it uses to map it as JavaScript value. + * + * @param tableName The table name without prefix + * @param columnName The column name + * @param asColumnName The column name to use in the result + */ + includeColumn (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * Includes the column in the query with conversion to text made to string + * on the database side. + * + * @param tableName The table name without prefix + * @param columnName The column name + * @param asColumnName The column name to use in the result + */ + includeColumnAsText (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * Includes the column in the query with conversion made to ISO timestamp + * string on the database side. + * + * @param tableName The table name without prefix + * @param columnName The column name + * @param asColumnName The column name to use in the result + */ + includeColumnAsTime (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * Includes the column in the query with conversion made to ISO timestamp + * string on the database side. + * + * @param tableName The table name without prefix + * @param columnName The column name + * @param asColumnName The column name to use in the result + */ + includeColumnAsDate (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * Includes the column in the query with conversion made to ISO timestamp + * string on the database side. + * + * @param tableName The table name without prefix + * @param columnName The column name + * @param asColumnName The column name to use in the result + */ + includeColumnAsTimestamp (tableName: string, columnName: string, asColumnName: string) : void; + + /** + * Includes all columns from the table. No conversations will be made. + * + * In SQL this will usually generate field like `"table"."column".*`. + * + * **NOTE!** If you use this method, you will get types mapped by the + * internal relational database module which may or may not be a correct + * JavaScript type. Hints in our entity annotations will not control this + * functionality. You will need to manually handle possible type conversions + * and/or by configuring the internal persister. + * + * @param tableName The table name without prefix + */ + includeAllColumnsFromTable (tableName: string) : void; + + /** + * Includes columns using the provided query builder. + * + * @param builder The query builder + * @param asColumnName The name in which the built formula will be mapped to + * in the result row + */ + includeColumnFromQueryBuilder (builder: QueryBuilder, asColumnName: string) : void; + + /** + * Includes columns as a custom formula. This must be in the format accepted + * by the internal persister module. + * + * It is not mandatory to support this in the persister implementation. In + * that case, the persister may throw an exception. + * + * @param formula The raw format to use, e.g. `COUNT(*)` + * @param asColumnName The name of the column where to map in the result + * @throws Error If the internal persister does not support this feature + */ + includeFormulaByString (formula: string, asColumnName: string): void; + + +} diff --git a/data/query/types/QueryWhereable.ts b/data/query/types/QueryWhereable.ts new file mode 100644 index 0000000..0ec5517 --- /dev/null +++ b/data/query/types/QueryWhereable.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { QueryBuilder, QueryValueFactory } from "./QueryBuilder"; + +export interface QueryWhereable { + + + /** + * Builds the result query string + */ + buildWhereQueryString () : string; + + /** + * Builds the result value factory array + */ + getWhereValueFactories () : readonly QueryValueFactory[]; + + /** + * Maps filtering options to the query from another query builder. + * + * @param builder + */ + setWhereFromQueryBuilder (builder: QueryBuilder): void; + + +} diff --git a/data/query/types/TablePrefixable.ts b/data/query/types/TablePrefixable.ts new file mode 100644 index 0000000..854ceb7 --- /dev/null +++ b/data/query/types/TablePrefixable.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export interface TablePrefixable { + + /** + * Set an optional prefix for each table name. + * + * This allows you to use same database for multiple purposes. + * + * @param prefix + */ + setTablePrefix (prefix: string) : void; + + /** + * Get the optional table prefix. + * + * Will return empty string if not defined. + */ + getTablePrefix () : string; + + /** + * Returns complete table name including the prefix. + * + * @param tableName The table name without prefix + * @returns The table name with prefix added + */ + getTableNameWithPrefix (tableName : string) : string; + + /** + * Set the main table which this query is intended for. + * + * @param tableName The table name without prefix + */ + setTableName (tableName: string) : void; + + /** + * Get the main table name without prefix. + * + * @returns The table name without prefix + */ + getTableName () : string; + + /** + * Get the main table name with prefix included. + * + * @returns The full table name as it's used inside the relational database + */ + getCompleteTableName () : string; + +} diff --git a/data/query/utils/ChainQueryBuilderUtils.test.ts b/data/query/utils/ChainQueryBuilderUtils.test.ts new file mode 100644 index 0000000..9d76e63 --- /dev/null +++ b/data/query/utils/ChainQueryBuilderUtils.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { ChainQueryBuilder, ChainQueryBuilderFactory } from "../types/ChainQueryBuilder"; +import { ChainQueryBuilderUtils } from "./ChainQueryBuilderUtils"; +import { EntityField } from "../../types/EntityField"; +import { Where } from "../../Where"; +import { TemporalProperty } from "../../types/TemporalProperty"; + +describe('ChainQueryBuilderUtils', () => { + + describe('#buildChain', () => { + + let mockAndBuilder: ChainQueryBuilder; + let mockOrBuilder: ChainQueryBuilder; + + let mockAndBuilderFactory: ChainQueryBuilderFactory; + let mockOrBuilderFactory: ChainQueryBuilderFactory; + + beforeEach(() => { + mockAndBuilder = { + valueOf: jest.fn().mockReturnValue('and'), + toString: jest.fn().mockReturnValue('and'), + setColumnInList: jest.fn(), + setColumnEquals: jest.fn(), + setColumnEqualsAsJson: jest.fn(), + setColumnIsNull: jest.fn(), + setColumnBetween: jest.fn(), + setColumnAfter: jest.fn(), + setColumnBefore: jest.fn(), + setColumnInListAsTime: jest.fn(), + setColumnEqualsAsTime: jest.fn(), + setColumnBetweenAsTime: jest.fn(), + setColumnAfterAsTime: jest.fn(), + setColumnBeforeAsTime: jest.fn(), + setFromQueryBuilder: jest.fn(), + build: jest.fn().mockReturnValue(['and', []]), + buildQueryString: jest.fn().mockReturnValue('and'), + buildQueryValues: jest.fn().mockReturnValue([]), + getQueryValueFactories: jest.fn().mockReturnValue([]), + }; + mockAndBuilderFactory = jest.fn().mockReturnValue(mockAndBuilder); + + mockOrBuilder = { + valueOf: jest.fn().mockReturnValue('or'), + toString: jest.fn().mockReturnValue('or'), + setColumnInList: jest.fn(), + setColumnEquals: jest.fn(), + setColumnEqualsAsJson: jest.fn(), + setColumnIsNull: jest.fn(), + setColumnBetween: jest.fn(), + setColumnAfter: jest.fn(), + setColumnBefore: jest.fn(), + setColumnInListAsTime: jest.fn(), + setColumnEqualsAsTime: jest.fn(), + setColumnBetweenAsTime: jest.fn(), + setColumnAfterAsTime: jest.fn(), + setColumnBeforeAsTime: jest.fn(), + setFromQueryBuilder: jest.fn(), + build: jest.fn().mockReturnValue(['or', []]), + buildQueryString: jest.fn().mockReturnValue('or'), + buildQueryValues: jest.fn().mockReturnValue([]), + getQueryValueFactories: jest.fn().mockReturnValue([]), + }; + + mockOrBuilderFactory = jest.fn().mockReturnValue(mockOrBuilder); + }); + + it('builds chain for equal condition', () => { + const where = Where.propertyEquals('city', 'New York'); + const completeTableName = 'tableName'; + const fields: EntityField[] = [{ propertyName: 'city', columnName: 'city_column' } as EntityField]; + const temporalProperties : TemporalProperty[] = []; + + ChainQueryBuilderUtils.buildChain(mockAndBuilder, where, completeTableName, fields, temporalProperties, mockAndBuilderFactory, mockOrBuilderFactory); + + expect(mockAndBuilder.setColumnEquals).toHaveBeenCalledWith(completeTableName, 'city_column', 'New York'); + expect(mockAndBuilder.setColumnEquals).toHaveBeenCalledTimes(1); + + expect(mockAndBuilder.setColumnBetween).toHaveBeenCalledTimes(0); + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenCalledTimes(0); + expect(mockOrBuilderFactory).toHaveBeenCalledTimes(0); + expect(mockAndBuilderFactory).toHaveBeenCalledTimes(0); + + }); + + it('builds chain for before condition', () => { + const where = Where.propertyBefore('age', 30); + const completeTableName = 'tableName'; + const fields: EntityField[] = [{ propertyName: 'age', columnName: 'age_column' } as EntityField]; + const temporalProperties : TemporalProperty[] = []; + + ChainQueryBuilderUtils.buildChain(mockAndBuilder, where, completeTableName, fields, temporalProperties, mockAndBuilderFactory, mockOrBuilderFactory); + + expect(mockAndBuilder.setColumnBefore).toHaveBeenCalledWith(completeTableName, 'age_column', 30); + expect(mockAndBuilder.setColumnBefore).toHaveBeenCalledTimes(1); + + expect(mockAndBuilder.setColumnAfter).toHaveBeenCalledTimes(0); + expect(mockAndBuilder.setColumnEquals).toHaveBeenCalledTimes(0); + expect(mockAndBuilder.setColumnBetween).toHaveBeenCalledTimes(0); + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenCalledTimes(0); + expect(mockOrBuilderFactory).toHaveBeenCalledTimes(0); + expect(mockAndBuilderFactory).toHaveBeenCalledTimes(0); + + }); + + it('builds chain for after condition', () => { + const where = Where.propertyAfter('age', 30); + const completeTableName = 'tableName'; + const fields: EntityField[] = [{ propertyName: 'age', columnName: 'age_column' } as EntityField]; + const temporalProperties : TemporalProperty[] = []; + + ChainQueryBuilderUtils.buildChain(mockAndBuilder, where, completeTableName, fields, temporalProperties, mockAndBuilderFactory, mockOrBuilderFactory); + + expect(mockAndBuilder.setColumnAfter).toHaveBeenCalledWith(completeTableName, 'age_column', 30); + expect(mockAndBuilder.setColumnAfter).toHaveBeenCalledTimes(1); + + expect(mockAndBuilder.setColumnBefore).toHaveBeenCalledTimes(0); + expect(mockAndBuilder.setColumnEquals).toHaveBeenCalledTimes(0); + expect(mockAndBuilder.setColumnBetween).toHaveBeenCalledTimes(0); + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenCalledTimes(0); + expect(mockOrBuilderFactory).toHaveBeenCalledTimes(0); + expect(mockAndBuilderFactory).toHaveBeenCalledTimes(0); + + }); + + it('builds chain for between condition', () => { + const where = Where.propertyBetween('age', 18, 30); + const completeTableName = 'tableName'; + const fields: EntityField[] = [{ propertyName: 'age', columnName: 'age_column' } as EntityField]; + const temporalProperties : TemporalProperty[] = []; + + ChainQueryBuilderUtils.buildChain(mockAndBuilder, where, completeTableName, fields, temporalProperties, mockAndBuilderFactory, mockOrBuilderFactory); + + expect(mockAndBuilder.setColumnBetween).toHaveBeenCalledWith(completeTableName, 'age_column', 18, 30); + expect(mockAndBuilder.setColumnBetween).toHaveBeenCalledTimes(1); + + expect(mockAndBuilder.setColumnEquals).toHaveBeenCalledTimes(0); + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenCalledTimes(0); + expect(mockOrBuilderFactory).toHaveBeenCalledTimes(0); + expect(mockAndBuilderFactory).toHaveBeenCalledTimes(0); + }); + + it('builds chain for AndCondition', () => { + + const where = Where.and( + Where.propertyEquals('city', 'New York'), + Where.propertyEquals('age', 25) + ); + const completeTableName = 'tableName'; + const fields: EntityField[] = [ + { propertyName: 'city', columnName: 'city_column' } as EntityField, + { propertyName: 'age', columnName: 'age_column' } as EntityField + ]; + const temporalProperties : TemporalProperty[] = []; + + ChainQueryBuilderUtils.buildChain(mockAndBuilder, where, completeTableName, fields, temporalProperties, mockAndBuilderFactory, mockOrBuilderFactory); + + expect(mockAndBuilder.setColumnEquals).toHaveBeenNthCalledWith(1, "tableName", "city_column", "New York"); + expect(mockAndBuilder.setColumnEquals).toHaveBeenNthCalledWith(2, "tableName", "age_column", 25); + expect(mockAndBuilder.setColumnEquals).toHaveBeenCalledTimes(2); + + expect(mockAndBuilder.setFromQueryBuilder).toHaveBeenCalledTimes(0); + expect(mockAndBuilder.setColumnBetween).toHaveBeenCalledTimes(0); + expect(mockOrBuilderFactory).toHaveBeenCalledTimes(0); + expect(mockAndBuilderFactory).toHaveBeenCalledTimes(0); + + }); + + it('builds chain for OrCondition', () => { + const where = Where.or( + Where.propertyEquals('city', 'New York'), + Where.propertyEquals('city', 'Los Angeles') + ); + const completeTableName = 'tableName'; + const fields: EntityField[] = [{ propertyName: 'city', columnName: 'city_column' } as EntityField]; + const temporalProperties : TemporalProperty[] = []; + + ChainQueryBuilderUtils.buildChain(mockOrBuilder, where, completeTableName, fields,temporalProperties, mockAndBuilderFactory, mockOrBuilderFactory); + + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenCalledTimes(1); + expect(mockOrBuilderFactory).toHaveBeenCalledTimes(1); + + expect(mockAndBuilder.setColumnEquals).toHaveBeenCalledTimes(0); + expect(mockAndBuilder.setColumnBetween).toHaveBeenCalledTimes(0); + expect(mockAndBuilderFactory).toHaveBeenCalledTimes(0); + + }); + + it('builds chain for OrCondition', () => { + const where = Where.or( + Where.and( + Where.propertyEquals('city', 'New York'), + Where.propertyEquals('city', 'Los Angeles') + ), + Where.and( + Where.propertyEquals('age', 18), + Where.propertyEquals('age', 30) + ) + ); + const completeTableName = 'tableName'; + const fields: EntityField[] = [ + { propertyName: 'age', columnName: 'age_column' } as EntityField, + { propertyName: 'city', columnName: 'city_column' } as EntityField + ]; + const temporalProperties : TemporalProperty[] = []; + + ChainQueryBuilderUtils.buildChain(mockOrBuilder, where, completeTableName, fields, temporalProperties, mockAndBuilderFactory, mockOrBuilderFactory); + + expect(mockAndBuilder.setColumnEquals).toHaveBeenNthCalledWith(1, "tableName", "city_column", "New York"); + expect(mockAndBuilder.setColumnEquals).toHaveBeenNthCalledWith(2, "tableName", "city_column", "Los Angeles"); + expect(mockAndBuilder.setColumnEquals).toHaveBeenNthCalledWith(3, "tableName", "age_column", 18); + expect(mockAndBuilder.setColumnEquals).toHaveBeenNthCalledWith(4, "tableName", "age_column", 30); + expect(mockAndBuilder.setColumnEquals).toHaveBeenCalledTimes(4); + + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenNthCalledWith(1, mockAndBuilder); + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenNthCalledWith(2, mockAndBuilder); + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenNthCalledWith(3, mockOrBuilder); + expect(mockOrBuilder.setFromQueryBuilder).toHaveBeenCalledTimes(3); + + expect(mockAndBuilderFactory).toHaveBeenNthCalledWith(1); + expect(mockAndBuilderFactory).toHaveBeenNthCalledWith(2); + expect(mockAndBuilderFactory).toHaveBeenCalledTimes(2); + + expect(mockOrBuilderFactory).toHaveBeenNthCalledWith(1); + expect(mockOrBuilderFactory).toHaveBeenCalledTimes(1); + + expect(mockAndBuilder.setColumnBetween).toHaveBeenCalledTimes(0); + + }); + + }); + +}); diff --git a/data/query/utils/ChainQueryBuilderUtils.ts b/data/query/utils/ChainQueryBuilderUtils.ts new file mode 100644 index 0000000..4663b13 --- /dev/null +++ b/data/query/utils/ChainQueryBuilderUtils.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { forEach } from "../../../functions/forEach"; +import { find } from "../../../functions/find"; +import { Where } from "../../Where"; +import { EntityField } from "../../types/EntityField"; +import { Condition } from "../../conditions/types/Condition"; +import { isWhereConditionTarget } from "../../conditions/types/WhereConditionTarget"; +import { isAndCondition } from "../../conditions/AndCondition"; +import { isOrCondition } from "../../conditions/OrCondition"; +import { isPropertyNameTarget } from "../../conditions/types/PropertyNameTarget"; +import { isEqualCondition } from "../../conditions/EqualCondition"; +import { isBetweenCondition } from "../../conditions/BetweenCondition"; +import { isBeforeCondition } from "../../conditions/BeforeCondition"; +import { isAfterCondition } from "../../conditions/AfterCondition"; +import { ChainQueryBuilder, ChainQueryBuilderFactory } from "../types/ChainQueryBuilder"; +import { TemporalProperty } from "../../types/TemporalProperty"; +import { isJsonColumnDefinition, isTimeColumnDefinition } from "../../types/ColumnDefinition"; + +export class ChainQueryBuilderUtils { + + /** + * + * @param builder + * @param where + * @param completeTableName + * @param fields + * @param temporalProperties + * @param timeColumnDefinitions + * @param buildAndChain + * @param buildOrChain + */ + public static buildChain ( + builder : ChainQueryBuilder, + where : Where, + completeTableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + buildAndChain : ChainQueryBuilderFactory, + buildOrChain : ChainQueryBuilderFactory + ) : void { + forEach( + where.getConditions(), + (item: Condition) => { + const target = item.getConditionTarget(); + + if (isWhereConditionTarget(target)) { + + if (isAndCondition(item)) { + const and : ChainQueryBuilder = buildAndChain(); + ChainQueryBuilderUtils.buildChain(and, item.getWhere(), completeTableName, fields, temporalProperties, buildAndChain, buildOrChain); + builder.setFromQueryBuilder(and); + return; + } + + if (isOrCondition(item)) { + const or : ChainQueryBuilder = buildOrChain(); + ChainQueryBuilderUtils.buildChain(or, item.getWhere(), completeTableName, fields, temporalProperties, buildAndChain, buildOrChain); + builder.setFromQueryBuilder(or); + return; + } + + throw new TypeError(`Unsupported condition for where target: ${item}`); + } + + if (isPropertyNameTarget(target)) { + const propertyName = target.getPropertyName(); + const field = find(fields, (field) => field.propertyName === propertyName); + if (!field) throw new TypeError(`Could not find field info for property "${propertyName}" from table "${completeTableName}"`); + const columnName = field?.columnName; + if (!columnName) throw new TypeError(`Could not find column name for property "${propertyName}" from table "${completeTableName}"`); + + const { columnDefinition } = field; + + const temporalProperty = find(temporalProperties, item => item.propertyName === propertyName); + const temporalType = temporalProperty?.temporalType; + + const isTime : boolean = !!temporalType || isTimeColumnDefinition(columnDefinition); + const isJson : boolean = isTime ? false : isJsonColumnDefinition(columnDefinition); + + if (isEqualCondition(item)) { + const value = item.getValue(); + if (isTime) { + builder.setColumnEqualsAsTime(completeTableName, columnName, value); + } else if (isJson) { + builder.setColumnEqualsAsJson(completeTableName, columnName, value); + } else { + builder.setColumnEquals(completeTableName, columnName, value); + } + } else if (isBetweenCondition(item)) { + const start = item.getRangeStart(); + const end = item.getRangeEnd(); + if (isTime) { + builder.setColumnBetweenAsTime( completeTableName, columnName, start, end ); + } else { + builder.setColumnBetween(completeTableName, columnName, start, end); + } + } else if (isBeforeCondition(item)) { + const value = item.getValue(); + if (isTime) { + builder.setColumnBeforeAsTime(completeTableName, columnName, value); + } else { + builder.setColumnBefore( completeTableName, columnName, value ); + } + } else if (isAfterCondition(item)) { + const value = item.getValue(); + if (isTime) { + builder.setColumnAfterAsTime(completeTableName, columnName, value); + } else { + builder.setColumnAfter( completeTableName, columnName, value ); + } + } else { + throw new TypeError(`The condition was unsupported: ${item}`) + } + + } else { + throw new TypeError(`The condition target was unsupported: ${target}`) + } + + } + ); + } + +} diff --git a/data/query/utils/EntitySelectQueryUtils.test.ts b/data/query/utils/EntitySelectQueryUtils.test.ts new file mode 100644 index 0000000..e513591 --- /dev/null +++ b/data/query/utils/EntitySelectQueryUtils.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { EntityField } from "../../types/EntityField"; +import { EntityFieldType } from "../../types/EntityFieldType"; +import { TemporalProperty } from "../../types/TemporalProperty"; +import { TemporalType } from "../../types/TemporalType"; +import { SelectQueryBuilder } from "../sql/select/SelectQueryBuilder"; +import { EntitySelectQueryUtils } from "./EntitySelectQueryUtils"; +import { ColumnDefinition } from "../../types/ColumnDefinition"; + +describe('EntitySelectQueryUtils', () => { + + describe('#includeEntityFields', () => { + + it('calls the correct methods on the builder for each field type', () => { + + const builder = { + includeColumnAsTimestamp: jest.fn(), + includeColumnAsTime: jest.fn(), + includeColumnAsDate: jest.fn(), + includeColumnAsText: jest.fn(), + includeColumn: jest.fn() + } as unknown as SelectQueryBuilder; + + const tableName = 'testTable'; + + const fields: EntityField[] = [ + { fieldType: EntityFieldType.DATE_TIME, propertyName: 'datetime', columnName: 'date_time', nullable: false, updatable: true, insertable: true }, + { fieldType: EntityFieldType.BIGINT, propertyName: 'string', columnName: 'string_column', nullable: false, columnDefinition: ColumnDefinition.BIGINT, updatable: true, insertable: true }, + ]; + + const temporalProperties: TemporalProperty[] = [ + { propertyName: 'date', temporalType: TemporalType.DATE }, + { propertyName: 'datetime', temporalType: TemporalType.TIMESTAMP }, + ]; + + EntitySelectQueryUtils.includeEntityFields(builder, tableName, fields, temporalProperties); + + expect(builder.includeColumnAsTimestamp).toHaveBeenCalledWith(tableName, 'date_time', 'date_time'); + expect(builder.includeColumnAsTimestamp).toHaveBeenCalledTimes(1); + + expect(builder.includeColumnAsText).toHaveBeenCalledWith(tableName, 'string_column', 'string_column'); + expect(builder.includeColumnAsText).toHaveBeenCalledTimes(1); + + expect(builder.includeColumnAsTime).toHaveBeenCalledTimes(0); + expect(builder.includeColumnAsDate).toHaveBeenCalledTimes(0); + expect(builder.includeColumn).toHaveBeenCalledTimes(0); + + // Add more expect calls as needed + + }); + + }); + +}); diff --git a/data/query/utils/EntitySelectQueryUtils.ts b/data/query/utils/EntitySelectQueryUtils.ts new file mode 100644 index 0000000..6079f94 --- /dev/null +++ b/data/query/utils/EntitySelectQueryUtils.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityField } from "../../types/EntityField"; +import { TemporalProperty } from "../../types/TemporalProperty"; +import { EntityBuilderUtils } from "../../utils/EntityBuilderUtils"; +import { SelectQueryBuilder } from "../sql/select/SelectQueryBuilder"; + +export class EntitySelectQueryUtils { + + /** + * Include entity fields in the select query results. + * + * @param builder + * @param tableName The table name without the prefix + * @param fields + * @param temporalProperties + */ + public static includeEntityFields ( + builder : SelectQueryBuilder, + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[] + ): void { + EntityBuilderUtils.includeFields( + tableName, + fields, + temporalProperties, + (tableName: string, columnName: string /*, propertyName: string*/) => { + builder.includeColumnAsTimestamp( tableName, columnName, columnName ); + }, + (tableName: string, columnName: string /*, propertyName: string*/) => { + builder.includeColumnAsTime( tableName, columnName, columnName ); + }, + (tableName: string, columnName: string /*, propertyName: string*/) => { + builder.includeColumnAsDate( tableName, columnName, columnName ); + }, + (tableName: string, columnName: string /*, propertyName: string*/) => { + builder.includeColumnAsText( tableName, columnName, columnName ); + }, + (tableName: string, columnName: string /*, propertyName: string*/) => { + builder.includeColumn( tableName, columnName, columnName ); + }, + ); + } + +} diff --git a/data/tests/allRepositoryTests.ts b/data/tests/allRepositoryTests.ts new file mode 100644 index 0000000..d186ee6 --- /dev/null +++ b/data/tests/allRepositoryTests.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createRepositoryTestContext, RepositoryTestContext } from "./types/types/RepositoryTestContext"; +import { basicCrudTests } from "./basicCrudTests"; +import { entityRelationshipTests } from "./entityRelationshipTests"; +import { Persister } from "../types/Persister"; +import { typeJsonTests } from "./typeJsonTests"; +import { typeNativeJsonTests } from "./typeNativeJsonTests"; +import { PersisterType } from "../persisters/types/PersisterType"; +import { basicLifeCycleTests } from "./basicLifeCycleTests"; + +export const allRepositoryTests = ( + persisterType : PersisterType, + createPersister : () => Persister +) => { + + let context : RepositoryTestContext = createRepositoryTestContext(persisterType); + + beforeEach(() => { + context.persister = createPersister(); + }); + + afterEach( () => { + context.persister?.destroy(); + context.persister = undefined; + }); + + describe('CRUD operations', () => { + basicCrudTests(context); + }); + + describe('Life cycle operations', () => { + basicLifeCycleTests(context); + }); + + describe('Entity relationships', () => { + entityRelationshipTests(context); + }); + + describe('Types', () => { + + describe('JSON (string)', () => { + typeJsonTests(context); + }); + + describe('JSON (native)', () => { + typeNativeJsonTests(context); + }); + + }); + +}; diff --git a/data/tests/basicCrudTests.ts b/data/tests/basicCrudTests.ts new file mode 100644 index 0000000..e3a8fbb --- /dev/null +++ b/data/tests/basicCrudTests.ts @@ -0,0 +1,1307 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../../testing/jest/matchers/index"; +import { find } from "../../functions/find"; +import { Repository } from "../types/Repository"; +import { RepositoryTestContext } from "./types/types/RepositoryTestContext"; +import { Persister } from "../types/Persister"; +import { createCrudRepositoryWithPersister } from "../types/CrudRepository"; +import { Sort } from "../Sort"; +import { Table } from "../Table"; +import { Entity } from "../Entity"; +import { Id } from "../Id"; +import { Column } from "../Column"; +import { Temporal } from "../Temporal"; +import { TemporalType } from "../types/TemporalType"; +import { CreationTimestamp } from "../CreationTimestamp"; + +export const basicCrudTests = (context : RepositoryTestContext) : void => { + + /** + * Test entity for tests that require empty repository + */ + @Table('foos') + class FooEntity extends Entity { + constructor (dto ?: {fooName: string, fooDate: string | undefined, nonUpdatable : string}) { + super() + this.fooName = dto?.fooName; + this.fooDate = dto?.fooDate; + this.nonUpdatable = dto?.nonUpdatable ?? ''; + } + + @Id() + @Column('foo_id', 'BIGINT', {updatable: false}) + public fooId ?: string; + + @CreationTimestamp() + @Column('foo_date', 'timestamp') + @Temporal(TemporalType.TIMESTAMP) + public fooDate ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('non_updatable', undefined, {updatable: false}) + public nonUpdatable ?: string; + + } + + /** + * Test entity for tests which require non-empty repository + */ + @Table('bars') + class BarEntity extends Entity { + + constructor (dto ?: {barName: string, barDate: string}) { + super() + const barName = dto?.barName ?? undefined; + const barDate = dto?.barDate ?? undefined; + this.barName = barName; + this.barDate = barDate; + } + + @Id() + @Column('bar_id', 'BIGINT', {updatable: false}) + public barId ?: string; + + @Temporal(TemporalType.TIMESTAMP) + @Column('bar_date', 'timestamp') + public barDate ?: string; + + @Column('bar_name') + public barName ?: string; + + } + + interface FooRepository extends Repository { + + findAllByFooName(name: string, sort?: Sort) : Promise; + findByFooName (name: string, sort?: Sort): Promise; + deleteAllByFooName (name: string): Promise; + existsByFooName (name : string): Promise; + countByFooName (name: string) : Promise; + + findAllByFooId (ids: readonly string[], sort?: Sort) : Promise; + findByFooId (id: string, sort?: Sort): Promise; + deleteAllByFooId (id: string): Promise; + existsByFooId (id : string): Promise; + countByFooId (id : string) : Promise; + + findAllByFooDateBetween (start: string, end: string, sort?: Sort) : Promise; + findByFooDateBetween (start: string, end: string, sort?: Sort): Promise; + deleteAllByFooDateBetween (start: string, end: string): Promise; + existsByFooDateBetween (start: string, end: string): Promise; + countByFooDateBetween (start: string, end: string) : Promise; + + } + + interface BarRepository extends Repository { + + findAllByBarName(name: string, sort?: Sort) : Promise; + findByBarName (name: string, sort?: Sort): Promise; + deleteAllByBarName (name: string): Promise; + existsByBarName (name : string): Promise; + countByBarName (name : string) : Promise; + + findAllByBarId(id: string, sort?: Sort) : Promise; + findByBarId (id: string, sort?: Sort) : Promise; + deleteAllByBarId (id: string) : Promise; + existsByBarId (id : string) : Promise; + countByBarId (id : string) : Promise; + + findAllByBarDateBetween (start: string, end: string, sort?: Sort) : Promise; + findByBarDateBetween (start: string, end: string, sort?: Sort): Promise; + deleteAllByBarDateBetween (start: string, end: string): Promise; + existsByBarDateBetween (start: string, end: string): Promise; + countByBarDateBetween (start: string, end: string) : Promise; + + findAllByBarDateBefore (value: string, sort?: Sort) : Promise; + findByBarDateBefore (value: string, sort?: Sort): Promise; + deleteAllByBarDateBefore (value: string): Promise; + existsByBarDateBefore (value: string): Promise; + countByBarDateBefore (value: string) : Promise; + + findAllByBarDateAfter (value: string, sort?: Sort) : Promise; + findByBarDateAfter (value: string, sort?: Sort): Promise; + deleteAllByBarDateAfter (value: string): Promise; + existsByBarDateAfter (value: string): Promise; + countByBarDateAfter (value: string) : Promise; + + } + + let persister : Persister; + + /** + * This is an empty repository for testing + */ + let fooRepository : FooRepository; + + /** + * This repository will have four items + */ + let barRepository : BarRepository; + let barEntity1 : BarEntity; + let barEntity2 : BarEntity; + let barEntity3 : BarEntity; + let barEntity4 : BarEntity; + let barEntityId1 : string; + let barEntityId2 : string; + let barEntityId3 : string; + let barEntityId4 : string; + + let barEntityName1 : string = 'Bar 123'; + let barEntityName2 : string = 'Bar 456'; + let barEntityName3 : string = 'Bar 789'; + let barEntityName4 : string = 'Bar 123'; + + let dateBeforeEntity1 : string = '2023-04-04T14:58:59Z'; + let barEntityDate1 : string = '2023-04-30T10:03:12Z'; + let dateBetweenEntity1And2 : string = '2023-05-02T17:44:14Z'; + let barEntityDate2 : string = '2023-05-11T17:12:03Z'; + let dateBetweenEntity2And3 : string = '2023-05-11T17:57:00Z'; + let barEntityDate3 : string = '2023-05-12T07:44:12Z'; + let dateBetweenEntity3And4 : string = '2023-05-12T15:30:42Z'; + let barEntityDate4 : string = '2023-05-12T15:55:39Z'; + let dateAfterEntity4 : string = '2023-05-12T15:55:59Z'; + + beforeEach( async () => { + + persister = context.getPersister(); + + // Will be initialized with no entities + fooRepository = createCrudRepositoryWithPersister( + new FooEntity(), + persister + ); + await fooRepository.deleteAll(); + + // Will be initialized with four entities + barRepository = createCrudRepositoryWithPersister( + new BarEntity(), + persister + ); + await barRepository.deleteAll(); + + barEntity1 = await persister.insert( + new BarEntity().getMetadata(), + new BarEntity({barName: barEntityName1, barDate: barEntityDate1}), + ); + + barEntityId1 = barEntity1?.barId as string; + if (!barEntityId1) throw new TypeError('barEntity1 failed to initialize'); + if (barEntity1.barDate !== barEntityDate1) throw new TypeError(`barEntity1 date did not initialize correctly: ${barEntity1.barDate}`); + + barEntity2 = await persister.insert( + new BarEntity().getMetadata(), + new BarEntity({barName: barEntityName2, barDate: barEntityDate2}), + ); + barEntityId2 = barEntity2?.barId as string; + if (!barEntityId2) throw new TypeError('barEntity2 failed to initialize'); + if (barEntityId1 === barEntityId2) throw new TypeError(`barEntity2 failed to initialize (not unique ID with barEntityId1 and barEntityId2): ${barEntityId1}`); + if (barEntity2.barDate !== barEntityDate2) throw new TypeError(`barEntity1 date did not initialize correctly: ${barEntity2.barDate}`); + + + barEntity3 = await persister.insert( + new BarEntity().getMetadata(), + new BarEntity({barName: barEntityName3, barDate: barEntityDate3}), + ); + barEntityId3 = barEntity3?.barId as string; + if (!barEntityId3) throw new TypeError('barEntity3 failed to initialize'); + if (barEntityId1 === barEntityId3) throw new TypeError(`barEntityId3 failed to initialize (not unique ID with entity 1): ${barEntityId1}`); + if (barEntityId2 === barEntityId3) throw new TypeError(`barEntityId3 failed to initialize (not unique ID with entity 2): ${barEntityId2}`); + if (barEntity3.barDate !== barEntityDate3) throw new TypeError(`barEntity1 date did not initialize correctly: ${barEntity3.barDate}`); + + barEntity4 = await persister.insert( + new BarEntity().getMetadata(), + new BarEntity({barName: barEntityName4, barDate: barEntityDate4}), + ); + barEntityId4 = barEntity4?.barId as string; + if (!barEntityId4) throw new TypeError('barEntity4 failed to initialize'); + if (barEntityId1 === barEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 1): ${barEntityId1}`); + if (barEntityId2 === barEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 2): ${barEntityId2}`); + if (barEntityId3 === barEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 3): ${barEntityId3}`); + if (barEntity4.barDate !== barEntityDate4) throw new TypeError(`barEntity1 date did not initialize correctly: ${barEntity4.barDate}`); + + }); + + describe('#count', () => { + + it('can count entities', async () => { + expect( await barRepository.count() ).toBe(4); + }); + + }); + + describe('#delete', () => { + + it('can delete entity by entity object', async () => { + + expect( await barRepository.count() ).toBe(4); + await barRepository.delete(barEntity2); + expect( await barRepository.count() ).toBe(3); + + let entity : BarEntity | undefined = await barRepository.findByBarId(barEntityId2); + expect(entity).not.toBeDefined(); + + }); + + }); + + describe('#deleteById', () => { + + it('can delete entity by id', async () => { + + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteById(barEntityId2); + expect( await barRepository.count() ).toBe(3); + + let entity : BarEntity | undefined = await barRepository.findByBarId(barEntityId2); + expect(entity).not.toBeDefined(); + + }); + + }); + + describe('#deleteAll', () => { + + it('can delete all entities', async () => { + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteAll(); + expect( await barRepository.count() ).toBe(0); + }); + + it('can delete all entities with few ids', async () => { + + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteAll( + [ + barEntity2, + barEntity3 + ] + ); + expect( await barRepository.count() ).toBe(2); + + let entity1 : BarEntity | undefined = await barRepository.findByBarId(barEntityId1); + expect(entity1).toBeDefined(); + + let entity2 : BarEntity | undefined = await barRepository.findByBarId(barEntityId2); + expect(entity2).not.toBeDefined(); + + let entity3 : BarEntity | undefined = await barRepository.findByBarId(barEntityId3); + expect(entity3).not.toBeDefined(); + + let entity4 : BarEntity | undefined = await barRepository.findByBarId(barEntityId4); + expect(entity4).toBeDefined(); + + }); + + }); + + describe('#deleteAllById', () => { + + it('can delete all entities by id', async () => { + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteAllById( [barEntityId2] ); + expect( await barRepository.count() ).toBe(3); + }); + + it('can delete all entities by few ids', async () => { + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteAllById( + [ + barEntityId2, + barEntityId3 + ] + ); + expect( await barRepository.count() ).toBe(2); + + let entity1 : BarEntity | undefined = await barRepository.findByBarId(barEntityId1); + expect(entity1).toBeDefined(); + + let entity2 : BarEntity | undefined = await barRepository.findByBarId(barEntityId2); + expect(entity2).not.toBeDefined(); + + let entity3 : BarEntity | undefined = await barRepository.findByBarId(barEntityId3); + expect(entity3).not.toBeDefined(); + + let entity4 : BarEntity | undefined = await barRepository.findByBarId(barEntityId4); + expect(entity4).toBeDefined(); + + }); + + }); + + describe('#existsById', () => { + + it('can find if entity exists', async () => { + expect( await barRepository.existsById( barEntityId2 ) ).toBe(true); + await barRepository.deleteAllById( [barEntityId2] ); + expect( await barRepository.existsById( barEntityId2 ) ).toBe(false); + }); + + }); + + describe('#findAll', () => { + + it('can find all entities unsorted', async () => { + const items = await barRepository.findAll(); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + // Order may be different + const item1 = find(items, (item) => item.barId === barEntityId1); + const item2 = find(items, (item) => item.barId === barEntityId2); + const item3 = find(items, (item) => item.barId === barEntityId3); + + expect(item1).toBeDefined(); + expect(item1?.barId).toBe(barEntityId1); + expect(item1?.barName).toBe(barEntityName1); + + expect(item2).toBeDefined(); + expect(item2?.barId).toBe(barEntityId2); + expect(item2?.barName).toBe(barEntityName2); + + expect(item3).toBeDefined(); + expect(item3?.barId).toBe(barEntityId3); + expect(item3?.barName).toBe(barEntityName3); + + }); + + it('can find all entities sorted by name and id in ascending order', async () => { + + const items = await barRepository.findAll( Sort.by('barName', 'barId') ); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId1); + expect(items[0]?.barName).toBe(barEntityName1); + + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId4); + expect(items[1]?.barName).toBe(barEntityName4); + + expect(items[2]).toBeDefined(); + expect(items[2]?.barId).toBe(barEntityId2); + expect(items[2]?.barName).toBe(barEntityName2); + + expect(items[3]).toBeDefined(); + expect(items[3]?.barId).toBe(barEntityId3); + expect(items[3]?.barName).toBe(barEntityName3); + + }); + + it('can find all entities sorted by name and id in desc order', async () => { + + const items = await barRepository.findAll( Sort.by(Sort.Direction.DESC, 'barName', 'barId') ); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId3); + expect(items[0]?.barName).toBe(barEntityName3); + + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId2); + expect(items[1]?.barName).toBe(barEntityName2); + + expect(items[2]).toBeDefined(); + expect(items[2]?.barId).toBe(barEntityId4); + expect(items[2]?.barName).toBe(barEntityName4); + + expect(items[3]).toBeDefined(); + expect(items[3]?.barId).toBe(barEntityId1); + expect(items[3]?.barName).toBe(barEntityName1); + + }); + + }); + + describe('#findAllById', () => { + + it('can find all entities by id unsorted', async () => { + const items = await barRepository.findAllById([barEntityId2, barEntityId3]); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + expect(items[1]?.barId).toBe(barEntityId3); + expect(items[1]?.barName).toBe(barEntityName3); + }); + + it('can find all entities by id in ascending order', async () => { + const items = await barRepository.findAllById([barEntityId2, barEntityId3], Sort.by('barName') ); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId3); + expect(items[1]?.barName).toBe(barEntityName3); + }); + + it('can find all entities by id in desc order', async () => { + const items = await barRepository.findAllById([barEntityId2, barEntityId3], Sort.by(Sort.Direction.DESC,'barName') ); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId2); + expect(items[1]?.barName).toBe(barEntityName2); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId3); + expect(items[0]?.barName).toBe(barEntityName3); + }); + + }); + + describe('#findById', () => { + + it('can find entity by id unsorted', async () => { + const item = await barRepository.findById(barEntityId2); + expect(item).toBeDefined(); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find entity by id by asc order', async () => { + const item = await barRepository.findById(barEntityId2, Sort.by('barName')); + expect(item).toBeDefined(); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find entity by id by desc order', async () => { + const item = await barRepository.findById(barEntityId2, Sort.by(Sort.Direction.DESC,'barName')); + expect(item).toBeDefined(); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + }); + + describe('#find', () => { + + it('can find entities by property unsorted', async () => { + const items = await barRepository.find("barName", barEntityName2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can find entities by property in asc order', async () => { + const items = await barRepository.find("barName", barEntityName2, Sort.by('barName')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can find entities by property in desc order', async () => { + const items = await barRepository.find("barName", barEntityName2, Sort.by(Sort.Direction.DESC,'barName')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + }); + + describe('#save', () => { + + it('can save fresh entity', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + const savedItem = await fooRepository.save(newEntity); + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + + const addedId : string = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + const foundItem = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + + }); + + it('can save fresh entity with undefined field', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: undefined, nonUpdatable: 'hello'}); + + const savedItem = await fooRepository.save(newEntity); + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.fooDate).toBeDefined(); + + expect( await fooRepository.count() ).toBe(1); + + }); + + it('can save fresh entity with non-updatable column', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + const savedItem = await fooRepository.save(newEntity); + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.nonUpdatable).toBe('hello'); + + const addedId : string = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + const foundItem = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + }); + + it('can save older entity', async () => { + + expect( await barRepository.count() ).toBe(4); + + barEntity2.barName = 'Hello world'; + + const savedItem = await barRepository.save(barEntity2); + expect(savedItem).toBeDefined(); + expect(savedItem.barId).toBe(barEntityId2); + expect(savedItem.barName).toBe('Hello world'); + + expect( await barRepository.count() ).toBe(4); + + const foundItem = await barRepository.findById(barEntityId2); + expect(foundItem).toBeDefined(); + expect(foundItem?.barId).toBe(barEntityId2); + expect(foundItem?.barName).toBe('Hello world'); + + }); + + it('cannot save older entity with non-updatable field when no other field has changed', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + let savedItem = await fooRepository.save(newEntity); + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.nonUpdatable).toBe('hello'); + + let addedId : string = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + let foundItem : FooEntity | undefined = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + if (!foundItem) throw new TypeError(`foundItem not defined`); + + foundItem.nonUpdatable = 'Something else'; + + savedItem = await fooRepository.save(foundItem); + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.nonUpdatable).toBe('hello'); + + addedId = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + foundItem = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + }); + + it('cannot save older entity with non-updatable field when some other field has changed', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + let savedItem = await fooRepository.save(newEntity); + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.nonUpdatable).toBe('hello'); + + let addedId : string = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + let foundItem : FooEntity | undefined = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + if (!foundItem) throw new TypeError(`foundItem not defined`); + + foundItem.nonUpdatable = 'Something else'; + foundItem.fooName = 'New name'; + + savedItem = await fooRepository.save(foundItem); + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('New name'); + expect(savedItem.nonUpdatable).toBe('hello'); + + addedId = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + foundItem = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('New name'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + }); + + }); + + describe('#saveAll', () => { + + it('can save fresh entities', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity1 = new FooEntity({fooName: 'Hello world 1', fooDate: '2023-05-12T10:22:32+03:00', nonUpdatable: 'hello'}); + const newEntity2 = new FooEntity({fooName: 'Hello world 2', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + const savedItems = await fooRepository.saveAll([newEntity1, newEntity2]); + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(2); + + expect(savedItems[0]?.fooId).toBeDefined(); + expect(savedItems[0]?.fooName).toBe('Hello world 1'); + + expect(savedItems[1]?.fooId).toBeDefined(); + expect(savedItems[1]?.fooName).toBe('Hello world 2'); + + const addedId1 : string = savedItems[0]?.fooId as string; + const addedId2 : string = savedItems[1]?.fooId as string; + + expect( await fooRepository.count() ).toBe(2); + + const foundItem1 = await fooRepository.findById(addedId1); + expect(foundItem1).toBeDefined(); + expect(foundItem1?.fooId).toBe(addedId1); + expect(foundItem1?.fooName).toBe('Hello world 1'); + + const foundItem2 = await fooRepository.findById(addedId2); + expect(foundItem2).toBeDefined(); + expect(foundItem2?.fooId).toBe(addedId2); + expect(foundItem2?.fooName).toBe('Hello world 2'); + + }); + + it('can save older entities', async () => { + + expect( await barRepository.count() ).toBe(4); + + barEntity2.barName = 'Hello world 1'; + barEntity3.barName = 'Hello world 2'; + + const savedItems = await barRepository.saveAll([barEntity2, barEntity3]); + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(2); + + expect(savedItems[0].barId).toBe(barEntityId2); + expect(savedItems[0].barName).toBe('Hello world 1'); + + expect(savedItems[1].barId).toBe(barEntityId3); + expect(savedItems[1].barName).toBe('Hello world 2'); + + expect( await barRepository.count() ).toBe(4); + + const foundItem2 = await barRepository.findById(barEntityId2); + expect(foundItem2).toBeDefined(); + expect(foundItem2?.barId).toBe(barEntityId2); + expect(foundItem2?.barName).toBe('Hello world 1'); + + const foundItem3 = await barRepository.findById(barEntityId3); + expect(foundItem3).toBeDefined(); + expect(foundItem3?.barId).toBe(barEntityId3); + expect(foundItem3?.barName).toBe('Hello world 2'); + + }); + + }); + + + + describe('#findAllByBarName', () => { + + it('can fetch single entity by barName property unsorted', async () => { + const items = await barRepository.findAllByBarName(barEntityName2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can fetch single entity by barName property in asc order', async () => { + const items = await barRepository.findAllByBarName(barEntityName2, Sort.by('barName')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can fetch single entity by barName property in desc order', async () => { + const items = await barRepository.findAllByBarName(barEntityName2, Sort.by(Sort.Direction.DESC,'barName')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can fetch multiple entities by barName property unsorted', async () => { + const items = await barRepository.findAllByBarName(barEntityName1); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + + const item1 = find(items, (item) => item.barId === barEntity1.barId); + const item4 = find(items, (item) => item.barId === barEntity4.barId); + + expect(item1).toBeDefined(); + expect(item4).toBeDefined(); + expect(item1?.barId).toBe(barEntityId1); + expect(item1?.barName).toBe(barEntityName1); + expect(item4?.barId).toBe(barEntityId4); + expect(item4?.barName).toBe(barEntityName4); + }); + + it('can fetch multiple entities by barId property in asc order', async () => { + const items = await barRepository.findAllByBarName(barEntityName1, Sort.by('barId')); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId1); + expect(items[0]?.barName).toBe(barEntityName1); + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId4); + expect(items[1]?.barName).toBe(barEntityName4); + }); + + it('can fetch multiple entities by barId property in desc order', async () => { + const items = await barRepository.findAllByBarName(barEntityName1, Sort.by(Sort.Direction.DESC,'barId')); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId4); + expect(items[0]?.barName).toBe(barEntityName4); + + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId1); + expect(items[1]?.barName).toBe(barEntityName1); + }); + + }); + + describe('#findByBarName', () => { + + it('can find entity by barName property unsorted', async () => { + const entity : BarEntity | undefined = await barRepository.findByBarName(barEntityName2); + expect(entity).toBeDefined(); + expect(entity?.barId).toBe(barEntityId2); + expect(entity?.barName).toBe(barEntityName2); + }); + + it('can find entity by barName property in asc order', async () => { + const entity : BarEntity | undefined = await barRepository.findByBarName(barEntityName2, Sort.by('barName')); + expect(entity).toBeDefined(); + expect(entity?.barId).toBe(barEntityId2); + expect(entity?.barName).toBe(barEntityName2); + }); + + it('can find entity by barName property in desc order', async () => { + const entity : BarEntity | undefined = await barRepository.findByBarName(barEntityName2, Sort.by(Sort.Direction.DESC,'barName')); + expect(entity).toBeDefined(); + expect(entity?.barId).toBe(barEntityId2); + expect(entity?.barName).toBe(barEntityName2); + }); + + }); + + describe('#deleteAllByBarName', () => { + + it('can delete all properties by barName', async () => { + await barRepository.deleteAllByBarName(barEntityName2); + const entity : BarEntity | undefined = await barRepository.findByBarName(barEntityName2); + expect(entity).not.toBeDefined(); + }); + + }); + + describe('#existsByBarName', () => { + + it('can find if entity exists by barName', async () => { + expect( await barRepository.existsByBarName(barEntityName2) ).toBe(true); + await barRepository.deleteAllByBarName(barEntityName2); + expect( await barRepository.existsByBarName(barEntityName2) ).toBe(false); + }); + + }); + + describe('#countByBarName', () => { + + it('can count entities by barName', async () => { + expect( await barRepository.countByBarName(barEntityName2) ).toBe(1); + await barRepository.deleteAllByBarName(barEntityName2); + expect( await barRepository.countByBarName(barEntityName2) ).toBe(0); + }); + + }); + + + + describe('#findAllByBarId', () => { + + it('can find all entities by barId unsorted', async () => { + const items = await barRepository.findAllByBarId(barEntityId2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can find all entities by barId in asc order', async () => { + const items = await barRepository.findAllByBarId(barEntityId2, Sort.by('barName')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can find all entities by barId in desc order', async () => { + const items = await barRepository.findAllByBarId(barEntityId2, Sort.by(Sort.Direction.DESC,'barName')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + }); + + describe('#findByBarId', () => { + + it('can find an entity by barId unsorted', async () => { + const item = await barRepository.findByBarId(barEntityId2); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find an entity by barId in asc order', async () => { + const item = await barRepository.findByBarId(barEntityId2, Sort.by('barName')); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find an entity by barId in desc order', async () => { + const item = await barRepository.findByBarId(barEntityId2, Sort.by(Sort.Direction.DESC,'barName')); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + }); + + describe('#deleteAllByBarId', () => { + + it('can delete all entities by barId', async () => { + await barRepository.deleteAllByBarId(barEntityId2); + const item = await barRepository.findByBarId(barEntityId2); + expect(item).toBeUndefined(); + }); + + }); + + describe('#existsByBarId', () => { + + it('can find if entities exist by barId', async () => { + expect( await barRepository.existsByBarId(barEntityId2) ).toBe(true); + await barRepository.deleteAllByBarId(barEntityId2); + expect( await barRepository.existsByBarId(barEntityId2) ).toBe(false); + }); + + }); + + describe('#countByBarId', () => { + + it('can count entities by barId', async () => { + expect( await barRepository.countByBarId(barEntityId2) ).toBe(1); + await barRepository.deleteAllByBarId(barEntityId2); + expect( await barRepository.countByBarId(barEntityId2) ).toBe(0); + }); + + }); + + + + describe('#findAllByBarDateBetween', () => { + + it('can find all entities between values by date unordered', async () => { + const items = await barRepository.findAllByBarDateBetween(barEntityDate2, barEntityDate3); + expect(items).toHaveLength(2); + + const item2 = find(items, (item) => item.barId === barEntityId2); + const item3 = find(items, (item) => item.barId === barEntityId3); + + expect(item2).toBeDefined(); + expect(item3).toBeDefined(); + + expect(item2?.barId).toBe(barEntityId2); + expect(item2?.barName).toBe(barEntityName2); + expect(item2?.barDate).toBe(barEntityDate2); + + expect(item3?.barId).toBe(barEntityId3); + expect(item3?.barName).toBe(barEntityName3); + expect(item3?.barDate).toBe(barEntityDate3); + + }); + + it('can find all entities by barId in asc order', async () => { + const items = await barRepository.findAllByBarDateBetween(barEntityDate2, barEntityDate3, Sort.by('barDate')); + expect(items).toHaveLength(2); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + expect(items[0]?.barDate).toBe(barEntityDate2); + expect(items[1]?.barId).toBe(barEntityId3); + expect(items[1]?.barName).toBe(barEntityName3); + expect(items[1]?.barDate).toBe(barEntityDate3); + }); + + it('can find all entities by barId in desc order', async () => { + const items = await barRepository.findAllByBarDateBetween(barEntityDate2, barEntityDate3, Sort.by(Sort.Direction.DESC,'barDate')); + expect(items).toHaveLength(2); + expect(items[0]?.barId).toBe(barEntityId3); + expect(items[0]?.barName).toBe(barEntityName3); + expect(items[0]?.barDate).toBe(barEntityDate3); + expect(items[1]?.barId).toBe(barEntityId2); + expect(items[1]?.barName).toBe(barEntityName2); + expect(items[1]?.barDate).toBe(barEntityDate2); + }); + + }); + + describe('#findByBarDateBetween', () => { + + it('can find an entity between times in unsorted order', async () => { + const item = await barRepository.findByBarDateBetween(dateBetweenEntity3And4, dateAfterEntity4); + expect(item?.barId).toBe(barEntityId4); + expect(item?.barName).toBe(barEntityName4); + }); + + it('can find an entity between times in asc order', async () => { + const item = await barRepository.findByBarDateBetween(dateBeforeEntity1, dateBetweenEntity1And2, Sort.by('barDate')); + expect(item?.barId).toBe(barEntityId1); + expect(item?.barName).toBe(barEntityName1); + }); + + it('can find an entity between times in desc order', async () => { + const item = await barRepository.findByBarDateBetween(dateBetweenEntity1And2, dateBetweenEntity2And3, Sort.by(Sort.Direction.DESC,'barDate')); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + }); + + describe('#deleteAllByBarDateBetween', () => { + + it('can delete all entities by barDate between range', async () => { + await barRepository.deleteAllByBarDateBetween(dateBeforeEntity1, dateBetweenEntity3And4); + const item = await barRepository.count(); + expect(item).toBe(1); + }); + + }); + + describe('#existsByBarDateBetween', () => { + + it('can find if entities exist between range', async () => { + expect( await barRepository.existsByBarDateBetween(dateBetweenEntity1And2, dateBetweenEntity3And4) ).toBe(true); + await barRepository.deleteAllByBarDateBetween(barEntityDate2, barEntityDate3); + expect( await barRepository.existsByBarDateBetween(dateBetweenEntity1And2, dateBetweenEntity3And4) ).toBe(false); + }); + + }); + + describe('#countByBarDateBetween', () => { + + it('can count entities by barId', async () => { + expect( await barRepository.countByBarDateBetween(dateBetweenEntity1And2, dateAfterEntity4) ).toBe(3); + await barRepository.deleteAllByBarDateBetween(dateBetweenEntity1And2, dateAfterEntity4); + expect( await barRepository.countByBarDateBetween(dateBeforeEntity1, dateBetweenEntity3And4) ).toBe(1); + }); + + }); + + + + describe('#findAllByBarDateBefore', () => { + + it('can find all entities before time, unordered', async () => { + + // Matches 1 and 2 ...OR... 2 and 1 (because unordered) + const items = await barRepository.findAllByBarDateBefore(dateBetweenEntity2And3); + expect(items).toHaveLength(2); + + const item1 = find(items, (item) => item.barId === barEntityId1); + const item2 = find(items, (item) => item.barId === barEntityId2); + + expect(item1).toBeDefined(); + expect(item2).toBeDefined(); + + expect(item1?.barId).toBe(barEntityId1); + expect(item1?.barName).toBe(barEntityName1); + expect(item1?.barDate).toBe(barEntityDate1); + + expect(item2?.barId).toBe(barEntityId2); + expect(item2?.barName).toBe(barEntityName2); + expect(item2?.barDate).toBe(barEntityDate2); + + }); + + it('can find all entities before time, in asc order', async () => { + // Matches 1 and 2, in asc order + const items = await barRepository.findAllByBarDateBefore(dateBetweenEntity2And3, Sort.by('barDate')); + expect(items).toHaveLength(2); + + expect(items[0]?.barId).toBe(barEntityId1); + expect(items[0]?.barName).toBe(barEntityName1); + expect(items[0]?.barDate).toBe(barEntityDate1); + + expect(items[1]?.barId).toBe(barEntityId2); + expect(items[1]?.barName).toBe(barEntityName2); + expect(items[1]?.barDate).toBe(barEntityDate2); + + }); + + it('can find all entities before time, in desc order', async () => { + + // Matches 2 and 1, in desc order + const items = await barRepository.findAllByBarDateBefore(dateBetweenEntity2And3, Sort.by(Sort.Direction.DESC,'barDate')); + expect(items).toHaveLength(2); + + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + expect(items[0]?.barDate).toBe(barEntityDate2); + + expect(items[1]?.barId).toBe(barEntityId1); + expect(items[1]?.barName).toBe(barEntityName1); + expect(items[1]?.barDate).toBe(barEntityDate1); + }); + + }); + + describe('#findByBarDateBefore', () => { + + it('cannot find an entity before there was any', async () => { + const item = await barRepository.findByBarDateBefore(dateBeforeEntity1); + expect(item).toBeUndefined(); + }); + + it('can find an entity before time in unsorted order', async () => { + + // Matches entity 1 + const item = await barRepository.findByBarDateBefore(dateBetweenEntity1And2); + expect(item?.barId).toBe(barEntityId1); + expect(item?.barName).toBe(barEntityName1); + + }); + + it('can find an entity before time in asc order', async () => { + // Matches entity 1 + const item = await barRepository.findByBarDateBefore(dateBetweenEntity3And4, Sort.by('barDate')); + expect(item?.barId).toBe(barEntityId1); + expect(item?.barName).toBe(barEntityName1); + }); + + it('can find an entity before time in desc order', async () => { + // Matches entity 3 + const item = await barRepository.findByBarDateBefore(dateBetweenEntity3And4, Sort.by(Sort.Direction.DESC,'barDate')); + expect(item?.barId).toBe(barEntityId3); + expect(item?.barName).toBe(barEntityName3); + }); + + }); + + describe('#deleteAllByBarDateBefore', () => { + + it('can delete all entities before time', async () => { + + // Deletes entities 1, 2 and 3 + await barRepository.deleteAllByBarDateBefore(dateBetweenEntity3And4); + + // Matches entity 4 + expect( await barRepository.count() ).toBe(1); + + }); + + }); + + describe('#existsByBarDateBefore', () => { + + it('can find if entities exist before time', async () => { + expect( await barRepository.existsByBarDateBefore(dateBetweenEntity1And2) ).toBe(true); + await barRepository.deleteAllByBarDateBefore(barEntityDate2); + expect( await barRepository.existsByBarDateBefore(dateBetweenEntity1And2) ).toBe(false); + }); + + }); + + describe('#countByBarDateBefore', () => { + + it('can count entities before time', async () => { + + // Matches entities 1, 2 and 3 + expect( await barRepository.countByBarDateBefore(dateBetweenEntity3And4) ).toBe(3); + + // Deletes entity 1 + await barRepository.deleteAllByBarDateBefore(dateBetweenEntity1And2); + + // Matches entities 2 and 3 + expect( await barRepository.countByBarDateBefore(dateBetweenEntity3And4) ).toBe(2); + + }); + + }); + + + + describe('#findAllByBarDateAfter', () => { + + it('can find all entities after time, unordered', async () => { + + // Finds entities 3 and 4 ... OR .. 4 and 3 + const items = await barRepository.findAllByBarDateAfter(dateBetweenEntity2And3); + expect(items).toHaveLength(2); + + const item3 = find(items, (item) => item.barId === barEntityId3); + const item4 = find(items, (item) => item.barId === barEntityId4); + + expect(item3).toBeDefined(); + expect(item4).toBeDefined(); + + expect(item4?.barId).toBe(barEntityId4); + expect(item4?.barName).toBe(barEntityName4); + expect(item4?.barDate).toBe(barEntityDate4); + + expect(item3?.barId).toBe(barEntityId3); + expect(item3?.barName).toBe(barEntityName3); + expect(item3?.barDate).toBe(barEntityDate3); + + }); + + it('can find all entities after time in asc order', async () => { + + // Finds entities 3 and 4 in asc order + const items = await barRepository.findAllByBarDateAfter(dateBetweenEntity2And3, Sort.by('barDate')); + expect(items).toHaveLength(2); + expect(items[0]?.barId).toBe(barEntityId3); + expect(items[0]?.barName).toBe(barEntityName3); + expect(items[0]?.barDate).toBe(barEntityDate3); + expect(items[1]?.barId).toBe(barEntityId4); + expect(items[1]?.barName).toBe(barEntityName4); + expect(items[1]?.barDate).toBe(barEntityDate4); + }); + + it('can find all entities after time in desc order', async () => { + // Finds entities 4 and 3 + const items = await barRepository.findAllByBarDateAfter(dateBetweenEntity2And3, Sort.by(Sort.Direction.DESC,'barDate')); + expect(items).toHaveLength(2); + expect(items[0]?.barId).toBe(barEntityId4); + expect(items[0]?.barName).toBe(barEntityName4); + expect(items[0]?.barDate).toBe(barEntityDate4); + expect(items[1]?.barId).toBe(barEntityId3); + expect(items[1]?.barName).toBe(barEntityName3); + expect(items[1]?.barDate).toBe(barEntityDate3); + }); + + }); + + describe('#findByBarDateAfter', () => { + + it('can find an entity after time in unsorted order', async () => { + // Matches 4 + const item = await barRepository.findByBarDateAfter(dateBetweenEntity3And4); + expect(item?.barId).toBe(barEntityId4); + expect(item?.barName).toBe(barEntityName4); + }); + + it('can find an entity after time in asc order', async () => { + // Matches entities 2, 3 and 4, in asc order 2 will be first + const item = await barRepository.findByBarDateAfter(dateBetweenEntity1And2, Sort.by('barDate')); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find an entity after time in DESC order', async () => { + // Matches entities 3 and 4, in desc order 4 fill be first one + const item = await barRepository.findByBarDateAfter(dateBetweenEntity1And2, Sort.by(Sort.Direction.DESC,'barDate')); + expect(item?.barId).toBe(barEntityId4); + expect(item?.barName).toBe(barEntityName4); + }); + + }); + + describe('#deleteAllByBarDateAfter', () => { + + it('can delete all entities after barDate', async () => { + await barRepository.deleteAllByBarDateAfter(dateBetweenEntity3And4); // Deletes item 4 + const item = await barRepository.count(); + expect(item).toBe(3); + }); + + }); + + describe('#existsByBarDateAfter', () => { + + it('can find if entities exist after barDate', async () => { + expect( await barRepository.existsByBarDateAfter(dateBetweenEntity1And2) ).toBe(true); + await barRepository.deleteAllByBarDateBetween(dateBetweenEntity1And2, dateAfterEntity4); // Deletes items 2, 3 and 4 + expect( await barRepository.existsByBarDateAfter(dateBetweenEntity1And2) ).toBe(false); + }); + + }); + + describe('#countByBarDateAfter', () => { + + it('can count entities after barDate', async () => { + expect( await barRepository.countByBarDateAfter(dateBetweenEntity1And2) ).toBe(3); + await barRepository.deleteAllByBarDateAfter(dateBetweenEntity1And2); + expect( await barRepository.countByBarDateAfter(dateBeforeEntity1) ).toBe(1); + }); + + }); + + +}; diff --git a/data/tests/basicLifeCycleTests.ts b/data/tests/basicLifeCycleTests.ts new file mode 100644 index 0000000..77890c3 --- /dev/null +++ b/data/tests/basicLifeCycleTests.ts @@ -0,0 +1,2157 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import "../../../testing/jest/matchers/index"; +import { find } from "../../functions/find"; +import { Repository } from "../types/Repository"; +import { RepositoryTestContext } from "./types/types/RepositoryTestContext"; +import { Persister } from "../types/Persister"; +import { createCrudRepositoryWithPersister } from "../types/CrudRepository"; +import { Sort } from "../Sort"; +import { Table } from "../Table"; +import { Entity } from "../Entity"; +import { Id } from "../Id"; +import { Column } from "../Column"; +import { Temporal } from "../Temporal"; +import { TemporalType } from "../types/TemporalType"; +import { PrePersist } from "../PrePersist"; +import { PostPersist } from "../PostPersist"; +import { PreRemove } from "../PreRemove"; +import { PostRemove } from "../PostRemove"; +import { PreUpdate } from "../PreUpdate"; +import { PostUpdate } from "../PostUpdate"; +import { PostLoad } from "../PostLoad"; +import { LogLevel } from "../../types/LogLevel"; +import { EntityCallbackUtils } from "../utils/EntityCallbackUtils"; + +EntityCallbackUtils.setLogLevel(LogLevel.NONE); + +export const basicLifeCycleTests = (context : RepositoryTestContext) : void => { + + const onPrePersistFn = jest.fn(); + const onPostPersistFn = jest.fn(); + const onPreRemoveFn = jest.fn(); + const onPostRemoveFn = jest.fn(); + const onPreUpdateFn = jest.fn(); + const onPostUpdateFn = jest.fn(); + const onPostLoadFn = jest.fn(); + + /** + * Test entity for tests that require empty repository + */ + @Table('foos') + class FooEntity extends Entity { + constructor (dto ?: {fooName: string, fooDate: string, nonUpdatable : string}) { + super() + this.fooName = dto?.fooName; + this.fooDate = dto?.fooDate; + this.nonUpdatable = dto?.nonUpdatable ?? ''; + } + + @Id() + @Column('foo_id', 'BIGINT', {updatable: false}) + public fooId ?: string; + + @Column('foo_date', 'timestamp') + @Temporal(TemporalType.TIMESTAMP) + public fooDate ?: string; + + @Column('foo_name') + public fooName ?: string; + + @Column('non_updatable', undefined, {updatable: false}) + public nonUpdatable ?: string; + + @PrePersist() + onPrePersist () { + onPrePersistFn(); + } + + + @PostPersist() + onPostPersist () { + onPostPersistFn(); + } + + @PreRemove() + onPreRemove () { + onPreRemoveFn(); + } + + + @PostRemove() + onPostRemove () { + onPostRemoveFn(); + } + + @PreUpdate() + onPreUpdate () { + onPreUpdateFn(); + } + + + @PostUpdate() + onPostUpdate () { + onPostUpdateFn(); + } + + @PostLoad() + onPostLoad () { + onPostLoadFn(); + } + + } + + /** + * Test entity for tests which require non-empty repository + */ + @Table('bars') + class BarEntity extends Entity { + + constructor (dto ?: {barName: string, barDate: string}) { + super() + const barName = dto?.barName ?? undefined; + const barDate = dto?.barDate ?? undefined; + this.barName = barName; + this.barDate = barDate; + } + + @Id() + @Column('bar_id', 'BIGINT', {updatable: false}) + public barId ?: string; + + @Temporal(TemporalType.TIMESTAMP) + @Column('bar_date', 'timestamp') + public barDate ?: string; + + @Column('bar_name') + public barName ?: string; + + @PrePersist() + onPrePersist () { + onPrePersistFn(); + } + + @PostPersist() + onPostPersist () { + onPostPersistFn(); + } + + @PreRemove() + onPreRemove () { + onPreRemoveFn(); + } + + + @PostRemove() + onPostRemove () { + onPostRemoveFn(); + } + + @PreUpdate() + onPreUpdate () { + onPreUpdateFn(); + } + + + @PostUpdate() + onPostUpdate () { + onPostUpdateFn(); + } + + @PostLoad() + onPostLoad () { + onPostLoadFn(); + } + + } + + interface FooRepository extends Repository { + + findAllByFooName(name: string, sort?: Sort) : Promise; + findByFooName (name: string, sort?: Sort): Promise; + deleteAllByFooName (name: string): Promise; + existsByFooName (name : string): Promise; + countByFooName (name: string) : Promise; + + findAllByFooId (ids: readonly string[], sort?: Sort) : Promise; + findByFooId (id: string, sort?: Sort): Promise; + deleteAllByFooId (id: string): Promise; + existsByFooId (id : string): Promise; + countByFooId (id : string) : Promise; + + findAllByFooDateBetween (start: string, end: string, sort?: Sort) : Promise; + findByFooDateBetween (start: string, end: string, sort?: Sort): Promise; + deleteAllByFooDateBetween (start: string, end: string): Promise; + existsByFooDateBetween (start: string, end: string): Promise; + countByFooDateBetween (start: string, end: string) : Promise; + + } + + interface BarRepository extends Repository { + + findAllByBarName(name: string, sort?: Sort) : Promise; + findByBarName (name: string, sort?: Sort): Promise; + deleteAllByBarName (name: string): Promise; + existsByBarName (name : string): Promise; + countByBarName (name : string) : Promise; + + findAllByBarId(id: string, sort?: Sort) : Promise; + findByBarId (id: string, sort?: Sort) : Promise; + deleteAllByBarId (id: string) : Promise; + existsByBarId (id : string) : Promise; + countByBarId (id : string) : Promise; + + findAllByBarDateBetween (start: string, end: string, sort?: Sort) : Promise; + findByBarDateBetween (start: string, end: string, sort?: Sort): Promise; + deleteAllByBarDateBetween (start: string, end: string): Promise; + existsByBarDateBetween (start: string, end: string): Promise; + countByBarDateBetween (start: string, end: string) : Promise; + + findAllByBarDateBefore (value: string, sort?: Sort) : Promise; + findByBarDateBefore (value: string, sort?: Sort): Promise; + deleteAllByBarDateBefore (value: string): Promise; + existsByBarDateBefore (value: string): Promise; + countByBarDateBefore (value: string) : Promise; + + findAllByBarDateAfter (value: string, sort?: Sort) : Promise; + findByBarDateAfter (value: string, sort?: Sort): Promise; + deleteAllByBarDateAfter (value: string): Promise; + existsByBarDateAfter (value: string): Promise; + countByBarDateAfter (value: string) : Promise; + + } + + let persister : Persister; + + /** + * This is an empty repository for testing + */ + let fooRepository : FooRepository; + + /** + * This repository will have four items + */ + let barRepository : BarRepository; + let barEntity1 : BarEntity; + let barEntity2 : BarEntity; + let barEntity3 : BarEntity; + let barEntity4 : BarEntity; + let barEntityId1 : string; + let barEntityId2 : string; + let barEntityId3 : string; + let barEntityId4 : string; + + let barEntityName1 : string = 'Bar 123'; + let barEntityName2 : string = 'Bar 456'; + let barEntityName3 : string = 'Bar 789'; + let barEntityName4 : string = 'Bar 123'; + + let dateBeforeEntity1 : string = '2023-04-04T14:58:59Z'; + let barEntityDate1 : string = '2023-04-30T10:03:12Z'; + let dateBetweenEntity1And2 : string = '2023-05-02T17:44:14Z'; + let barEntityDate2 : string = '2023-05-11T17:12:03Z'; + let dateBetweenEntity2And3 : string = '2023-05-11T17:57:00Z'; + let barEntityDate3 : string = '2023-05-12T07:44:12Z'; + let dateBetweenEntity3And4 : string = '2023-05-12T15:30:42Z'; + let barEntityDate4 : string = '2023-05-12T15:55:39Z'; + let dateAfterEntity4 : string = '2023-05-12T15:55:59Z'; + + beforeEach( async () => { + + persister = context.getPersister(); + + // Will be initialized with no entities + fooRepository = createCrudRepositoryWithPersister( + new FooEntity(), + persister + ); + await fooRepository.deleteAll(); + + // Will be initialized with four entities + barRepository = createCrudRepositoryWithPersister( + new BarEntity(), + persister + ); + await barRepository.deleteAll(); + + barEntity1 = await persister.insert( + new BarEntity().getMetadata(), + new BarEntity({barName: barEntityName1, barDate: barEntityDate1}), + ); + + barEntityId1 = barEntity1?.barId as string; + if (!barEntityId1) throw new TypeError('barEntity1 failed to initialize'); + if (barEntity1.barDate !== barEntityDate1) throw new TypeError(`barEntity1 date did not initialize correctly: ${barEntity1.barDate}`); + + barEntity2 = await persister.insert( + new BarEntity().getMetadata(), + new BarEntity({barName: barEntityName2, barDate: barEntityDate2}), + ); + barEntityId2 = barEntity2?.barId as string; + if (!barEntityId2) throw new TypeError('barEntity2 failed to initialize'); + if (barEntityId1 === barEntityId2) throw new TypeError(`barEntity2 failed to initialize (not unique ID with barEntityId1 and barEntityId2): ${barEntityId1}`); + if (barEntity2.barDate !== barEntityDate2) throw new TypeError(`barEntity1 date did not initialize correctly: ${barEntity2.barDate}`); + + + barEntity3 = await persister.insert( + new BarEntity().getMetadata(), + new BarEntity({barName: barEntityName3, barDate: barEntityDate3}), + ); + barEntityId3 = barEntity3?.barId as string; + if (!barEntityId3) throw new TypeError('barEntity3 failed to initialize'); + if (barEntityId1 === barEntityId3) throw new TypeError(`barEntityId3 failed to initialize (not unique ID with entity 1): ${barEntityId1}`); + if (barEntityId2 === barEntityId3) throw new TypeError(`barEntityId3 failed to initialize (not unique ID with entity 2): ${barEntityId2}`); + if (barEntity3.barDate !== barEntityDate3) throw new TypeError(`barEntity1 date did not initialize correctly: ${barEntity3.barDate}`); + + barEntity4 = await persister.insert( + new BarEntity().getMetadata(), + new BarEntity({barName: barEntityName4, barDate: barEntityDate4}), + ); + barEntityId4 = barEntity4?.barId as string; + if (!barEntityId4) throw new TypeError('barEntity4 failed to initialize'); + if (barEntityId1 === barEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 1): ${barEntityId1}`); + if (barEntityId2 === barEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 2): ${barEntityId2}`); + if (barEntityId3 === barEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 3): ${barEntityId3}`); + if (barEntity4.barDate !== barEntityDate4) throw new TypeError(`barEntity1 date did not initialize correctly: ${barEntity4.barDate}`); + + onPrePersistFn.mockClear(); + onPostPersistFn.mockClear(); + onPreRemoveFn.mockClear(); + onPostRemoveFn.mockClear(); + onPreUpdateFn.mockClear(); + onPostUpdateFn.mockClear(); + onPostLoadFn.mockClear(); + + }); + + describe('#count', () => { + + it('can count entities', async () => { + expect( await barRepository.count() ).toBe(4); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + }); + + }); + + describe('#delete', () => { + + it('can delete entity by entity object', async () => { + + expect( await barRepository.count() ).toBe(4); + + await barRepository.delete(barEntity2); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(1); + + expect( await barRepository.count() ).toBe(3); + + let entity : BarEntity | undefined = await barRepository.findByBarId(barEntityId2); + expect(entity).not.toBeDefined(); + + }); + + }); + + describe('#deleteById', () => { + + it('can delete entity by id', async () => { + + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteById(barEntityId2); + expect( await barRepository.count() ).toBe(3); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(1); + + let entity : BarEntity | undefined = await barRepository.findByBarId(barEntityId2); + expect(entity).not.toBeDefined(); + + }); + + }); + + describe('#deleteAll', () => { + + it('can delete all entities', async () => { + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteAll(); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(4); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(4); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(4); + + expect( await barRepository.count() ).toBe(0); + }); + + it('can delete all entities with few ids', async () => { + + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteAll( + [ + barEntity2, + barEntity3 + ] + ); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(2); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(2); + + expect( await barRepository.count() ).toBe(2); + + let entity1 : BarEntity | undefined = await barRepository.findByBarId(barEntityId1); + expect(entity1).toBeDefined(); + + let entity2 : BarEntity | undefined = await barRepository.findByBarId(barEntityId2); + expect(entity2).not.toBeDefined(); + + let entity3 : BarEntity | undefined = await barRepository.findByBarId(barEntityId3); + expect(entity3).not.toBeDefined(); + + let entity4 : BarEntity | undefined = await barRepository.findByBarId(barEntityId4); + expect(entity4).toBeDefined(); + + }); + + }); + + describe('#deleteAllById', () => { + + it('can delete all entities by id', async () => { + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteAllById( [barEntityId2] ); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(1); + + expect( await barRepository.count() ).toBe(3); + }); + + it('can delete all entities by few ids', async () => { + expect( await barRepository.count() ).toBe(4); + await barRepository.deleteAllById( + [ + barEntityId2, + barEntityId3 + ] + ); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(2); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(2); + + expect( await barRepository.count() ).toBe(2); + + let entity1 : BarEntity | undefined = await barRepository.findByBarId(barEntityId1); + expect(entity1).toBeDefined(); + + let entity2 : BarEntity | undefined = await barRepository.findByBarId(barEntityId2); + expect(entity2).not.toBeDefined(); + + let entity3 : BarEntity | undefined = await barRepository.findByBarId(barEntityId3); + expect(entity3).not.toBeDefined(); + + let entity4 : BarEntity | undefined = await barRepository.findByBarId(barEntityId4); + expect(entity4).toBeDefined(); + + }); + + }); + + describe('#existsById', () => { + + it('can find if entity exists', async () => { + expect( await barRepository.existsById( barEntityId2 ) ).toBe(true); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + await barRepository.deleteAllById( [barEntityId2] ); + + expect( await barRepository.existsById( barEntityId2 ) ).toBe(false); + }); + + }); + + describe('#findAll', () => { + + it('can find all entities unsorted', async () => { + const items = await barRepository.findAll(); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(4); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + // Order may be different + const item1 = find(items, (item) => item.barId === barEntityId1); + const item2 = find(items, (item) => item.barId === barEntityId2); + const item3 = find(items, (item) => item.barId === barEntityId3); + + expect(item1).toBeDefined(); + expect(item1?.barId).toBe(barEntityId1); + expect(item1?.barName).toBe(barEntityName1); + + expect(item2).toBeDefined(); + expect(item2?.barId).toBe(barEntityId2); + expect(item2?.barName).toBe(barEntityName2); + + expect(item3).toBeDefined(); + expect(item3?.barId).toBe(barEntityId3); + expect(item3?.barName).toBe(barEntityName3); + + }); + + it('can find all entities sorted by name and id in ascending order', async () => { + + const items = await barRepository.findAll( Sort.by('barName', 'barId') ); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(4); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId1); + expect(items[0]?.barName).toBe(barEntityName1); + + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId4); + expect(items[1]?.barName).toBe(barEntityName4); + + expect(items[2]).toBeDefined(); + expect(items[2]?.barId).toBe(barEntityId2); + expect(items[2]?.barName).toBe(barEntityName2); + + expect(items[3]).toBeDefined(); + expect(items[3]?.barId).toBe(barEntityId3); + expect(items[3]?.barName).toBe(barEntityName3); + + }); + + it('can find all entities sorted by name and id in desc order', async () => { + + const items = await barRepository.findAll( Sort.by(Sort.Direction.DESC, 'barName', 'barId') ); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(4); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId3); + expect(items[0]?.barName).toBe(barEntityName3); + + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId2); + expect(items[1]?.barName).toBe(barEntityName2); + + expect(items[2]).toBeDefined(); + expect(items[2]?.barId).toBe(barEntityId4); + expect(items[2]?.barName).toBe(barEntityName4); + + expect(items[3]).toBeDefined(); + expect(items[3]?.barId).toBe(barEntityId1); + expect(items[3]?.barName).toBe(barEntityName1); + + }); + + }); + + describe('#findAllById', () => { + + it('can find all entities by id unsorted', async () => { + const items = await barRepository.findAllById([barEntityId2, barEntityId3]); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + expect(items[1]?.barId).toBe(barEntityId3); + expect(items[1]?.barName).toBe(barEntityName3); + }); + + it('can find all entities by id in ascending order', async () => { + const items = await barRepository.findAllById([barEntityId2, barEntityId3], Sort.by('barName') ); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId3); + expect(items[1]?.barName).toBe(barEntityName3); + }); + + it('can find all entities by id in desc order', async () => { + const items = await barRepository.findAllById([barEntityId2, barEntityId3], Sort.by(Sort.Direction.DESC,'barName') ); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId2); + expect(items[1]?.barName).toBe(barEntityName2); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId3); + expect(items[0]?.barName).toBe(barEntityName3); + }); + + }); + + describe('#findById', () => { + + it('can find entity by id unsorted', async () => { + const item = await barRepository.findById(barEntityId2); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(item).toBeDefined(); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find entity by id by asc order', async () => { + const item = await barRepository.findById(barEntityId2, Sort.by('barName')); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(item).toBeDefined(); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find entity by id by desc order', async () => { + const item = await barRepository.findById(barEntityId2, Sort.by(Sort.Direction.DESC,'barName')); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(item).toBeDefined(); + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + }); + + describe('#find', () => { + + it('can find entities by property unsorted', async () => { + const items = await barRepository.find("barName", barEntityName2); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can find entities by property in asc order', async () => { + const items = await barRepository.find("barName", barEntityName2, Sort.by('barName')); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can find entities by property in desc order', async () => { + const items = await barRepository.find("barName", barEntityName2, Sort.by(Sort.Direction.DESC,'barName')); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + }); + + describe('#save', () => { + + it('can save fresh entity', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + const savedItem = await fooRepository.save(newEntity); + + expect( onPrePersistFn ).toHaveBeenCalledTimes(1); + expect( onPostPersistFn ).toHaveBeenCalledTimes(1); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + + const addedId : string = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + const foundItem = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + + }); + + it('can save fresh entity with non-updatable column', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + const savedItem = await fooRepository.save(newEntity); + + expect( onPrePersistFn ).toHaveBeenCalledTimes(1); + expect( onPostPersistFn ).toHaveBeenCalledTimes(1); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.nonUpdatable).toBe('hello'); + + const addedId : string = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + const foundItem = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + }); + + it('can save older entity', async () => { + + expect( await barRepository.count() ).toBe(4); + + barEntity2.barName = 'Hello world'; + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + const savedItem = await barRepository.save(barEntity2); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).toHaveBeenCalledTimes(1); + expect( onPostUpdateFn ).toHaveBeenCalledTimes(1); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(savedItem).toBeDefined(); + expect(savedItem.barId).toBe(barEntityId2); + expect(savedItem.barName).toBe('Hello world'); + + expect( await barRepository.count() ).toBe(4); + + const foundItem = await barRepository.findById(barEntityId2); + expect(foundItem).toBeDefined(); + expect(foundItem?.barId).toBe(barEntityId2); + expect(foundItem?.barName).toBe('Hello world'); + + }); + + it('cannot save older entity with non-updatable field when no other field has changed', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + let savedItem = await fooRepository.save(newEntity); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).toHaveBeenCalledTimes(1); + expect( onPostPersistFn ).toHaveBeenCalledTimes(1); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + onPrePersistFn.mockClear(); + onPostPersistFn.mockClear(); + onPostLoadFn.mockClear(); + + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.nonUpdatable).toBe('hello'); + + let addedId : string = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + let foundItem : FooEntity | undefined = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + if (!foundItem) throw new TypeError(`foundItem not defined`); + + foundItem.nonUpdatable = 'Something else'; + + onPrePersistFn.mockClear(); + onPostPersistFn.mockClear(); + onPostLoadFn.mockClear(); + + savedItem = await fooRepository.save(foundItem); + + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).toHaveBeenCalledTimes(1); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.nonUpdatable).toBe('hello'); + + addedId = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + foundItem = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + }); + + it('cannot save older entity with non-updatable field when some other field has changed', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + let savedItem = await fooRepository.save(newEntity); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).toHaveBeenCalledTimes(1); + expect( onPostPersistFn ).toHaveBeenCalledTimes(1); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('Hello world'); + expect(savedItem.nonUpdatable).toBe('hello'); + + let addedId : string = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + let foundItem : FooEntity | undefined = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('Hello world'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + if (!foundItem) throw new TypeError(`foundItem not defined`); + + foundItem.nonUpdatable = 'Something else'; + foundItem.fooName = 'New name'; + + onPrePersistFn.mockClear(); + onPostPersistFn.mockClear(); + onPostLoadFn.mockClear(); + + savedItem = await fooRepository.save(foundItem); + + expect( onPreUpdateFn ).toHaveBeenCalledTimes(1); + expect( onPostUpdateFn ).toHaveBeenCalledTimes(1); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(savedItem).toBeDefined(); + expect(savedItem.fooId).toBeDefined(); + expect(savedItem.fooName).toBe('New name'); + expect(savedItem.nonUpdatable).toBe('hello'); + + addedId = savedItem?.fooId as string; + + expect( await fooRepository.count() ).toBe(1); + + foundItem = await fooRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.fooId).toBe(addedId); + expect(foundItem?.fooName).toBe('New name'); + expect(foundItem?.nonUpdatable).toBe('hello'); + + }); + + it('cannot save fresh entity if pre persist life cycle function throws', async () => { + expect( await fooRepository.count() ).toBe(0); + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + onPrePersistFn.mockImplementationOnce(() => { + throw new TypeError( 'onPrePersistFn' ); + }); + try { + await fooRepository.save(newEntity); + } catch (err) { + expect(`${err}`).toContain('onPrePersistFn'); + } + expect( await fooRepository.count() ).toBe(0); + expect.assertions(3); + }); + + it('cannot save fresh entity if post persist life cycle function throws', async () => { + expect( await fooRepository.count() ).toBe(0); + const newEntity = new FooEntity({fooName: 'Hello world', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + onPostPersistFn.mockImplementationOnce(() => { + throw new TypeError( 'onPostPersistFn' ); + }); + try { + await fooRepository.save(newEntity); + } catch (err) { + expect(`${err}`).toContain('onPostPersistFn'); + } + expect( await fooRepository.count() ).toBe(0); + expect.assertions(3); + }); + + it('cannot save older entity if PostUpdate life cycle method throws', async () => { + + expect( await barRepository.count() ).toBe(4); + + onPostUpdateFn.mockImplementationOnce(() => { + throw new TypeError( 'onPostUpdateFn' ); + }); + + barEntity2.barName = 'Hello world'; + + try { + await barRepository.save(barEntity2); + } catch (err) { + expect(`${err}`).toContain('onPostUpdateFn'); + } + + expect( await barRepository.count() ).toBe(4); + expect.assertions(3); + + }); + + + }); + + describe('#saveAll', () => { + + it('can save fresh entities', async () => { + + expect( await fooRepository.count() ).toBe(0); + + const newEntity1 = new FooEntity({fooName: 'Hello world 1', fooDate: '2023-05-12T10:22:32+03:00', nonUpdatable: 'hello'}); + const newEntity2 = new FooEntity({fooName: 'Hello world 2', fooDate: '2023-05-12T15:42:09+03:00', nonUpdatable: 'hello'}); + + const savedItems = await fooRepository.saveAll([newEntity1, newEntity2]); + + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).toHaveBeenCalledTimes(2); + expect( onPostPersistFn ).toHaveBeenCalledTimes(2); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(2); + + expect(savedItems[0]?.fooId).toBeDefined(); + expect(savedItems[0]?.fooName).toBe('Hello world 1'); + + expect(savedItems[1]?.fooId).toBeDefined(); + expect(savedItems[1]?.fooName).toBe('Hello world 2'); + + const addedId1 : string = savedItems[0]?.fooId as string; + const addedId2 : string = savedItems[1]?.fooId as string; + + expect( await fooRepository.count() ).toBe(2); + + const foundItem1 = await fooRepository.findById(addedId1); + expect(foundItem1).toBeDefined(); + expect(foundItem1?.fooId).toBe(addedId1); + expect(foundItem1?.fooName).toBe('Hello world 1'); + + const foundItem2 = await fooRepository.findById(addedId2); + expect(foundItem2).toBeDefined(); + expect(foundItem2?.fooId).toBe(addedId2); + expect(foundItem2?.fooName).toBe('Hello world 2'); + + }); + + it('can save older entities', async () => { + + expect( await barRepository.count() ).toBe(4); + + barEntity2.barName = 'Hello world 1'; + barEntity3.barName = 'Hello world 2'; + + const savedItems = await barRepository.saveAll([barEntity2, barEntity3]); + + expect( onPreUpdateFn ).toHaveBeenCalledTimes(2); + expect( onPostUpdateFn ).toHaveBeenCalledTimes(2); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(2); + + expect(savedItems[0].barId).toBe(barEntityId2); + expect(savedItems[0].barName).toBe('Hello world 1'); + + expect(savedItems[1].barId).toBe(barEntityId3); + expect(savedItems[1].barName).toBe('Hello world 2'); + + expect( await barRepository.count() ).toBe(4); + + const foundItem2 = await barRepository.findById(barEntityId2); + expect(foundItem2).toBeDefined(); + expect(foundItem2?.barId).toBe(barEntityId2); + expect(foundItem2?.barName).toBe('Hello world 1'); + + const foundItem3 = await barRepository.findById(barEntityId3); + expect(foundItem3).toBeDefined(); + expect(foundItem3?.barId).toBe(barEntityId3); + expect(foundItem3?.barName).toBe('Hello world 2'); + + }); + + }); + + + + describe('#findAllByBarName', () => { + + it('can fetch single entity by barName property unsorted', async () => { + const items = await barRepository.findAllByBarName(barEntityName2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can fetch single entity by barName property in asc order', async () => { + const items = await barRepository.findAllByBarName(barEntityName2, Sort.by('barName')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can fetch single entity by barName property in desc order', async () => { + const items = await barRepository.findAllByBarName(barEntityName2, Sort.by(Sort.Direction.DESC,'barName')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can fetch multiple entities by barName property unsorted', async () => { + const items = await barRepository.findAllByBarName(barEntityName1); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toBeArray(); + expect(items?.length).toBe(2); + + const item1 = find(items, (item) => item.barId === barEntity1.barId); + const item4 = find(items, (item) => item.barId === barEntity4.barId); + + expect(item1).toBeDefined(); + expect(item4).toBeDefined(); + expect(item1?.barId).toBe(barEntityId1); + expect(item1?.barName).toBe(barEntityName1); + expect(item4?.barId).toBe(barEntityId4); + expect(item4?.barName).toBe(barEntityName4); + }); + + it('can fetch multiple entities by barId property in asc order', async () => { + const items = await barRepository.findAllByBarName(barEntityName1, Sort.by('barId')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId1); + expect(items[0]?.barName).toBe(barEntityName1); + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId4); + expect(items[1]?.barName).toBe(barEntityName4); + }); + + it('can fetch multiple entities by barId property in desc order', async () => { + const items = await barRepository.findAllByBarName(barEntityName1, Sort.by(Sort.Direction.DESC,'barId')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toBeArray(); + expect(items?.length).toBe(2); + + expect(items[0]).toBeDefined(); + expect(items[0]?.barId).toBe(barEntityId4); + expect(items[0]?.barName).toBe(barEntityName4); + + expect(items[1]).toBeDefined(); + expect(items[1]?.barId).toBe(barEntityId1); + expect(items[1]?.barName).toBe(barEntityName1); + }); + + }); + + describe('#findByBarName', () => { + + it('can find entity by barName property unsorted', async () => { + const entity : BarEntity | undefined = await barRepository.findByBarName(barEntityName2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(entity).toBeDefined(); + expect(entity?.barId).toBe(barEntityId2); + expect(entity?.barName).toBe(barEntityName2); + }); + + it('can find entity by barName property in asc order', async () => { + const entity : BarEntity | undefined = await barRepository.findByBarName(barEntityName2, Sort.by('barName')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(entity).toBeDefined(); + expect(entity?.barId).toBe(barEntityId2); + expect(entity?.barName).toBe(barEntityName2); + }); + + it('can find entity by barName property in desc order', async () => { + const entity : BarEntity | undefined = await barRepository.findByBarName(barEntityName2, Sort.by(Sort.Direction.DESC,'barName')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(entity).toBeDefined(); + expect(entity?.barId).toBe(barEntityId2); + expect(entity?.barName).toBe(barEntityName2); + }); + + }); + + describe('#deleteAllByBarName', () => { + + it('can delete all properties by barName', async () => { + await barRepository.deleteAllByBarName(barEntityName2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + const entity : BarEntity | undefined = await barRepository.findByBarName(barEntityName2); + expect(entity).not.toBeDefined(); + }); + + }); + + describe('#existsByBarName', () => { + + it('can find if entity exists by barName', async () => { + expect( await barRepository.existsByBarName(barEntityName2) ).toBe(true); + await barRepository.deleteAllByBarName(barEntityName2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect( await barRepository.existsByBarName(barEntityName2) ).toBe(false); + }); + + }); + + describe('#countByBarName', () => { + + it('can count entities by barName', async () => { + expect( await barRepository.countByBarName(barEntityName2) ).toBe(1); + await barRepository.deleteAllByBarName(barEntityName2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect( await barRepository.countByBarName(barEntityName2) ).toBe(0); + }); + + }); + + + + describe('#findAllByBarId', () => { + + it('can find all entities by barId unsorted', async () => { + const items = await barRepository.findAllByBarId(barEntityId2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can find all entities by barId in asc order', async () => { + const items = await barRepository.findAllByBarId(barEntityId2, Sort.by('barName')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + it('can find all entities by barId in desc order', async () => { + const items = await barRepository.findAllByBarId(barEntityId2, Sort.by(Sort.Direction.DESC,'barName')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + }); + + }); + + describe('#findByBarId', () => { + + it('can find an entity by barId unsorted', async () => { + const item = await barRepository.findByBarId(barEntityId2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find an entity by barId in asc order', async () => { + const item = await barRepository.findByBarId(barEntityId2, Sort.by('barName')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find an entity by barId in desc order', async () => { + const item = await barRepository.findByBarId(barEntityId2, Sort.by(Sort.Direction.DESC,'barName')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + }); + + describe('#deleteAllByBarId', () => { + + it('can delete all entities by barId', async () => { + await barRepository.deleteAllByBarId(barEntityId2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + const item = await barRepository.findByBarId(barEntityId2); + expect(item).toBeUndefined(); + }); + + }); + + describe('#existsByBarId', () => { + + it('can find if entities exist by barId', async () => { + expect( await barRepository.existsByBarId(barEntityId2) ).toBe(true); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + + await barRepository.deleteAllByBarId(barEntityId2); + expect( await barRepository.existsByBarId(barEntityId2) ).toBe(false); + }); + + }); + + describe('#countByBarId', () => { + + it('can count entities by barId', async () => { + expect( await barRepository.countByBarId(barEntityId2) ).toBe(1); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + + await barRepository.deleteAllByBarId(barEntityId2); + expect( await barRepository.countByBarId(barEntityId2) ).toBe(0); + }); + + }); + + + + describe('#findAllByBarDateBetween', () => { + + it('can find all entities between values by date unordered', async () => { + const items = await barRepository.findAllByBarDateBetween(barEntityDate2, barEntityDate3); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + + const item2 = find(items, (item) => item.barId === barEntityId2); + const item3 = find(items, (item) => item.barId === barEntityId3); + + expect(item2).toBeDefined(); + expect(item3).toBeDefined(); + + expect(item2?.barId).toBe(barEntityId2); + expect(item2?.barName).toBe(barEntityName2); + expect(item2?.barDate).toBe(barEntityDate2); + + expect(item3?.barId).toBe(barEntityId3); + expect(item3?.barName).toBe(barEntityName3); + expect(item3?.barDate).toBe(barEntityDate3); + + }); + + it('can find all entities by barId in asc order', async () => { + const items = await barRepository.findAllByBarDateBetween(barEntityDate2, barEntityDate3, Sort.by('barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + expect(items[0]?.barDate).toBe(barEntityDate2); + expect(items[1]?.barId).toBe(barEntityId3); + expect(items[1]?.barName).toBe(barEntityName3); + expect(items[1]?.barDate).toBe(barEntityDate3); + }); + + it('can find all entities by barId in desc order', async () => { + const items = await barRepository.findAllByBarDateBetween(barEntityDate2, barEntityDate3, Sort.by(Sort.Direction.DESC,'barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + expect(items[0]?.barId).toBe(barEntityId3); + expect(items[0]?.barName).toBe(barEntityName3); + expect(items[0]?.barDate).toBe(barEntityDate3); + expect(items[1]?.barId).toBe(barEntityId2); + expect(items[1]?.barName).toBe(barEntityName2); + expect(items[1]?.barDate).toBe(barEntityDate2); + }); + + }); + + describe('#findByBarDateBetween', () => { + + it('can find an entity between times in unsorted order', async () => { + const item = await barRepository.findByBarDateBetween(dateBetweenEntity3And4, dateAfterEntity4); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId4); + expect(item?.barName).toBe(barEntityName4); + }); + + it('can find an entity between times in asc order', async () => { + const item = await barRepository.findByBarDateBetween(dateBeforeEntity1, dateBetweenEntity1And2, Sort.by('barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId1); + expect(item?.barName).toBe(barEntityName1); + }); + + it('can find an entity between times in desc order', async () => { + const item = await barRepository.findByBarDateBetween(dateBetweenEntity1And2, dateBetweenEntity2And3, Sort.by(Sort.Direction.DESC,'barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + }); + + describe('#deleteAllByBarDateBetween', () => { + + it('can delete all entities by barDate between range', async () => { + + await barRepository.deleteAllByBarDateBetween(dateBeforeEntity1, dateBetweenEntity3And4); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(3); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(3); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(3); + + const item = await barRepository.count(); + expect(item).toBe(1); + }); + + }); + + describe('#existsByBarDateBetween', () => { + + it('can find if entities exist between range', async () => { + expect( await barRepository.existsByBarDateBetween(dateBetweenEntity1And2, dateBetweenEntity3And4) ).toBe(true); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + + await barRepository.deleteAllByBarDateBetween(barEntityDate2, barEntityDate3); + expect( await barRepository.existsByBarDateBetween(dateBetweenEntity1And2, dateBetweenEntity3And4) ).toBe(false); + }); + + }); + + describe('#countByBarDateBetween', () => { + + it('can count entities by barId', async () => { + expect( await barRepository.countByBarDateBetween(dateBetweenEntity1And2, dateAfterEntity4) ).toBe(3); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + + await barRepository.deleteAllByBarDateBetween(dateBetweenEntity1And2, dateAfterEntity4); + expect( await barRepository.countByBarDateBetween(dateBeforeEntity1, dateBetweenEntity3And4) ).toBe(1); + }); + + }); + + + + describe('#findAllByBarDateBefore', () => { + + it('can find all entities before time, unordered', async () => { + + // Matches 1 and 2 ...OR... 2 and 1 (because unordered) + const items = await barRepository.findAllByBarDateBefore(dateBetweenEntity2And3); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + + const item1 = find(items, (item) => item.barId === barEntityId1); + const item2 = find(items, (item) => item.barId === barEntityId2); + + expect(item1).toBeDefined(); + expect(item2).toBeDefined(); + + expect(item1?.barId).toBe(barEntityId1); + expect(item1?.barName).toBe(barEntityName1); + expect(item1?.barDate).toBe(barEntityDate1); + + expect(item2?.barId).toBe(barEntityId2); + expect(item2?.barName).toBe(barEntityName2); + expect(item2?.barDate).toBe(barEntityDate2); + + }); + + it('can find all entities before time, in asc order', async () => { + // Matches 1 and 2, in asc order + const items = await barRepository.findAllByBarDateBefore(dateBetweenEntity2And3, Sort.by('barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + + expect(items[0]?.barId).toBe(barEntityId1); + expect(items[0]?.barName).toBe(barEntityName1); + expect(items[0]?.barDate).toBe(barEntityDate1); + + expect(items[1]?.barId).toBe(barEntityId2); + expect(items[1]?.barName).toBe(barEntityName2); + expect(items[1]?.barDate).toBe(barEntityDate2); + + }); + + it('can find all entities before time, in desc order', async () => { + + // Matches 2 and 1, in desc order + const items = await barRepository.findAllByBarDateBefore(dateBetweenEntity2And3, Sort.by(Sort.Direction.DESC,'barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + + expect(items[0]?.barId).toBe(barEntityId2); + expect(items[0]?.barName).toBe(barEntityName2); + expect(items[0]?.barDate).toBe(barEntityDate2); + + expect(items[1]?.barId).toBe(barEntityId1); + expect(items[1]?.barName).toBe(barEntityName1); + expect(items[1]?.barDate).toBe(barEntityDate1); + }); + + }); + + describe('#findByBarDateBefore', () => { + + it('cannot find an entity before there was any', async () => { + const item = await barRepository.findByBarDateBefore(dateBeforeEntity1); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(0); + + expect(item).toBeUndefined(); + }); + + it('can find an entity before time in unsorted order', async () => { + + // Matches entity 1 + const item = await barRepository.findByBarDateBefore(dateBetweenEntity1And2); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId1); + expect(item?.barName).toBe(barEntityName1); + + }); + + it('can find an entity before time in asc order', async () => { + // Matches entity 1 + const item = await barRepository.findByBarDateBefore(dateBetweenEntity3And4, Sort.by('barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId1); + expect(item?.barName).toBe(barEntityName1); + }); + + it('can find an entity before time in desc order', async () => { + // Matches entity 3 + const item = await barRepository.findByBarDateBefore(dateBetweenEntity3And4, Sort.by(Sort.Direction.DESC,'barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId3); + expect(item?.barName).toBe(barEntityName3); + }); + + }); + + describe('#deleteAllByBarDateBefore', () => { + + it('can delete all entities before time', async () => { + + // Deletes entities 1, 2 and 3 + await barRepository.deleteAllByBarDateBefore(dateBetweenEntity3And4); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(3); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(3); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(3); + + // Matches entity 4 + expect( await barRepository.count() ).toBe(1); + + }); + + }); + + describe('#existsByBarDateBefore', () => { + + it('can find if entities exist before time', async () => { + expect( await barRepository.existsByBarDateBefore(dateBetweenEntity1And2) ).toBe(true); + + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + + await barRepository.deleteAllByBarDateBefore(barEntityDate2); + expect( await barRepository.existsByBarDateBefore(dateBetweenEntity1And2) ).toBe(false); + }); + + }); + + describe('#countByBarDateBefore', () => { + + it('can count entities before time', async () => { + + // Matches entities 1, 2 and 3 + expect( await barRepository.countByBarDateBefore(dateBetweenEntity3And4) ).toBe(3); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + + // Deletes entity 1 + await barRepository.deleteAllByBarDateBefore(dateBetweenEntity1And2); + // Matches entities 2 and 3 + expect( await barRepository.countByBarDateBefore(dateBetweenEntity3And4) ).toBe(2); + + }); + + }); + + + + describe('#findAllByBarDateAfter', () => { + + it('can find all entities after time, unordered', async () => { + + // Finds entities 3 and 4 ... OR .. 4 and 3 + const items = await barRepository.findAllByBarDateAfter(dateBetweenEntity2And3); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + + const item3 = find(items, (item) => item.barId === barEntityId3); + const item4 = find(items, (item) => item.barId === barEntityId4); + + expect(item3).toBeDefined(); + expect(item4).toBeDefined(); + + expect(item4?.barId).toBe(barEntityId4); + expect(item4?.barName).toBe(barEntityName4); + expect(item4?.barDate).toBe(barEntityDate4); + + expect(item3?.barId).toBe(barEntityId3); + expect(item3?.barName).toBe(barEntityName3); + expect(item3?.barDate).toBe(barEntityDate3); + + }); + + it('can find all entities after time in asc order', async () => { + + // Finds entities 3 and 4 in asc order + const items = await barRepository.findAllByBarDateAfter(dateBetweenEntity2And3, Sort.by('barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + expect(items[0]?.barId).toBe(barEntityId3); + expect(items[0]?.barName).toBe(barEntityName3); + expect(items[0]?.barDate).toBe(barEntityDate3); + expect(items[1]?.barId).toBe(barEntityId4); + expect(items[1]?.barName).toBe(barEntityName4); + expect(items[1]?.barDate).toBe(barEntityDate4); + }); + + it('can find all entities after time in desc order', async () => { + // Finds entities 4 and 3 + const items = await barRepository.findAllByBarDateAfter(dateBetweenEntity2And3, Sort.by(Sort.Direction.DESC,'barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(2); + + expect(items).toHaveLength(2); + expect(items[0]?.barId).toBe(barEntityId4); + expect(items[0]?.barName).toBe(barEntityName4); + expect(items[0]?.barDate).toBe(barEntityDate4); + expect(items[1]?.barId).toBe(barEntityId3); + expect(items[1]?.barName).toBe(barEntityName3); + expect(items[1]?.barDate).toBe(barEntityDate3); + }); + + }); + + describe('#findByBarDateAfter', () => { + + it('can find an entity after time in unsorted order', async () => { + // Matches 4 + const item = await barRepository.findByBarDateAfter(dateBetweenEntity3And4); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId4); + expect(item?.barName).toBe(barEntityName4); + }); + + it('can find an entity after time in asc order', async () => { + // Matches entities 2, 3 and 4, in asc order 2 will be first + const item = await barRepository.findByBarDateAfter(dateBetweenEntity1And2, Sort.by('barDate')); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + expect(item?.barId).toBe(barEntityId2); + expect(item?.barName).toBe(barEntityName2); + }); + + it('can find an entity after time in DESC order', async () => { + // Matches entities 3 and 4, in desc order 4 fill be first one + const item = await barRepository.findByBarDateAfter(dateBetweenEntity1And2, Sort.by(Sort.Direction.DESC,'barDate')); + expect(item?.barId).toBe(barEntityId4); + expect(item?.barName).toBe(barEntityName4); + }); + + }); + + describe('#deleteAllByBarDateAfter', () => { + + it('can delete all entities after barDate', async () => { + await barRepository.deleteAllByBarDateAfter(dateBetweenEntity3And4); // Deletes item 4 + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPostRemoveFn ).toHaveBeenCalledTimes(1); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).toHaveBeenCalledTimes(1); + + const item = await barRepository.count(); + expect(item).toBe(3); + }); + + }); + + describe('#existsByBarDateAfter', () => { + + it('can find if entities exist after barDate', async () => { + expect( await barRepository.existsByBarDateAfter(dateBetweenEntity1And2) ).toBe(true); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + + + await barRepository.deleteAllByBarDateBetween(dateBetweenEntity1And2, dateAfterEntity4); // Deletes items 2, 3 and 4 + expect( await barRepository.existsByBarDateAfter(dateBetweenEntity1And2) ).toBe(false); + }); + + }); + + describe('#countByBarDateAfter', () => { + + it('can count entities after barDate', async () => { + expect( await barRepository.countByBarDateAfter(dateBetweenEntity1And2) ).toBe(3); + + expect( onPreUpdateFn ).not.toHaveBeenCalled(); + expect( onPostUpdateFn ).not.toHaveBeenCalled(); + expect( onPreRemoveFn ).not.toHaveBeenCalled(); + expect( onPostRemoveFn ).not.toHaveBeenCalled(); + expect( onPrePersistFn ).not.toHaveBeenCalled(); + expect( onPostPersistFn ).not.toHaveBeenCalled(); + expect( onPostLoadFn ).not.toHaveBeenCalled(); + + await barRepository.deleteAllByBarDateAfter(dateBetweenEntity1And2); + expect( await barRepository.countByBarDateAfter(dateBeforeEntity1) ).toBe(1); + }); + + }); + + +}; diff --git a/data/tests/entityRelationshipTests.ts b/data/tests/entityRelationshipTests.ts new file mode 100644 index 0000000..8613b92 --- /dev/null +++ b/data/tests/entityRelationshipTests.ts @@ -0,0 +1,789 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../../testing/jest/matchers/index"; +import { find } from "../../functions/find"; +import { OneToMany } from "../OneToMany"; +import { JoinColumn } from "../JoinColumn"; +import { RepositoryTestContext } from "./types/types/RepositoryTestContext"; +import { Persister } from "../types/Persister"; +import { Repository } from "../types/Repository"; +import { createCrudRepositoryWithPersister } from "../types/CrudRepository"; +import { ManyToOne } from "../ManyToOne"; +import { isString } from "../../types/String"; +import { Table } from "../Table"; +import { Entity } from "../Entity"; +import { Id } from "../Id"; +import { Column } from "../Column"; + +export const entityRelationshipTests = (context : RepositoryTestContext) : void => { + + @Table('carts') + class CartEntity extends Entity { + + constructor (dto ?: { + readonly items ?: readonly CartItemEntity[], + readonly name ?: string, + readonly contacts ?: readonly ContactEntity[] + }) { + super(); + this.contacts = dto?.contacts ?? []; + this.items = dto?.items ?? []; + this.name = dto?.name; + } + + @Id() + @Column('cart_id', 'BIGINT') + public id ?: string; + + @Column('cart_name') + public name ?: string; + + // We need two one to many -mappings to test that it works correctly + // with multiple of these + @OneToMany('contacts', "cart") + public contacts : readonly ContactEntity[]; + + @OneToMany('cart_items', "cart") + public items : readonly CartItemEntity[]; + + } + + @Table('contacts') + class ContactEntity extends Entity { + + constructor (dto ?: {readonly cartId ?: string, readonly name ?: string}) { + super(); + this.cartId = dto?.cartId; + this.name = dto?.name; + } + + @Id() + @Column('contact_id', 'BIGINT') + public id ?: string; + + @Column('cart_id', 'BIGINT') + public cartId ?: string; + + @Column('contact_name') + public name ?: string; + + @ManyToOne(CartEntity) + @JoinColumn('cart_id', false) + public cart ?: CartEntity; + + } + + @Table('cart_items') + class CartItemEntity extends Entity { + + constructor (dto ?: {readonly cartId ?: string, readonly name ?: string}) { + super(); + this.cartId = dto?.cartId; + this.name = dto?.name; + } + + @Id() + @Column('cart_item_id', 'BIGINT') + public id ?: string; + + @Column('cart_id', 'BIGINT') + public cartId ?: string; + + @Column('cart_item_name') + public name ?: string; + + @ManyToOne(CartEntity) + @JoinColumn('cart_id', false) + public cart ?: CartEntity; + + } + + interface CartRepository extends Repository { + + findAllByName (name: string) : Promise; + findByName (name: string): Promise; + + } + + interface CartItemRepository extends Repository { + + findAllByName (name: string) : Promise; + findByName (name: string): Promise; + + } + + interface ContactRepository extends Repository { + + findAllByName (name: string) : Promise; + findByName (name: string): Promise; + + } + + let persister : Persister; + let cartRepository : CartRepository; + let cartItemRepository : CartItemRepository; + let contactRepository : ContactRepository; + + /** + * Our main test entity + */ + let cartA : CartEntity; + let cartA_id : string; + let cartA_name : string = 'Cart A'; + + let cartA_item1 : CartItemEntity; + let cartA_item1_id : string; + let cartA_item1_name : string = 'Cart A item 1'; + let cartA_item2 : CartItemEntity; + let cartA_item2_id : string; + let cartA_item2_name : string = 'Cart A item 2'; + let cartA_item3 : CartItemEntity; + let cartA_item3_id : string; + let cartA_item3_name : string = 'Cart A item 3'; + + let cartA_contact1 : ContactEntity; + let cartA_contact1_id : string; + let cartA_contact1_name : string = 'Cart A contact 1'; + let cartA_contact2 : ContactEntity; + let cartA_contact2_id : string; + let cartA_contact2_name : string = 'Cart A contact 2'; + let cartA_contact3 : ContactEntity; + let cartA_contact3_id : string; + let cartA_contact3_name : string = 'Cart A contact 3'; + + /** + * Another cart entity so that there is data in the database beside our test data + */ + let cartB : CartEntity; + let cartB_id : string; + let cartB_name : string = 'Cart B'; + + let cartB_item1 : CartItemEntity; + let cartB_item1_id : string; + let cartB_item1_name : string = 'Cart B item 1'; + let cartB_item2 : CartItemEntity; + let cartB_item2_id : string; + let cartB_item2_name : string = 'Cart B item 2'; + let cartB_item3 : CartItemEntity; + let cartB_item3_id : string; + let cartB_item3_name : string = 'Cart B item 3'; + + let cartB_contact1 : ContactEntity; + let cartB_contact1_id : string; + let cartB_contact1_name : string = 'Cart B contact 1'; + let cartB_contact2 : ContactEntity; + let cartB_contact2_id : string; + let cartB_contact2_name : string = 'Cart B contact 2'; + let cartB_contact3 : ContactEntity; + let cartB_contact3_id : string; + let cartB_contact3_name : string = 'Cart B contact 3'; + + // This cart must not have items + let cartC : CartEntity; + let cartC_id : string; + let cartC_name : string = 'Cart C'; + + beforeEach( async () => { + + persister = context.getPersister(); + + cartRepository = createCrudRepositoryWithPersister( + new CartEntity(), + persister + ); + + cartItemRepository = createCrudRepositoryWithPersister( + new CartItemEntity(), + persister + ); + + contactRepository = createCrudRepositoryWithPersister( + new ContactEntity(), + persister + ); + + await cartRepository.deleteAll(); + await cartItemRepository.deleteAll(); + await contactRepository.deleteAll(); + + // LOG.debug(`Step 1`) + cartA = new CartEntity({name: cartA_name}); + cartA = await persister.insert( + cartA.getMetadata(), + cartA, + ); + cartA_id = cartA?.id as string; + if (!isString(cartA_id)) throw new TypeError(`cartA_id failed to initialize: ${JSON.stringify(cartA_id)}`); + + + // LOG.debug(`Step 2`) + cartA_item1 = new CartItemEntity({cartId: cartA_id, name: cartA_item1_name}); + cartA_item1 = await persister.insert( + cartA_item1.getMetadata(), + cartA_item1, + ); + cartA_item1_id = cartA_item1?.id as string; + if (!isString(cartA_item1_id)) throw new TypeError(`cartItemA1_id failed to initialize: ${JSON.stringify(cartA_item1_id)}`); + + // LOG.debug(`Step 3`) + cartA_item2 = new CartItemEntity({cartId: cartA_id, name: cartA_item2_name}); + cartA_item2 = await persister.insert( + cartA_item2.getMetadata(), + cartA_item2, + ); + cartA_item2_id = cartA_item2?.id as string; + if (!isString(cartA_item2_id)) throw new TypeError(`cartItemA2_id failed to initialize: ${JSON.stringify(cartA_item2_id)}`); + + // LOG.debug(`Step 4`) + cartA_item3 = new CartItemEntity({cartId: cartA_id, name: cartA_item3_name}); + cartA_item3 = await persister.insert( + cartA_item3.getMetadata(), + cartA_item3, + ); + cartA_item3_id = cartA_item3?.id as string; + if (!isString(cartA_item3_id)) throw new TypeError(`cartItemA3_id failed to initialize: ${JSON.stringify(cartA_item3_id)}`); + + + // LOG.debug(`Step 2`) + cartA_contact1 = new ContactEntity({cartId: cartA_id, name: cartA_contact1_name}); + cartA_contact1 = await persister.insert( + cartA_contact1.getMetadata(), + cartA_contact1, + ); + cartA_contact1_id = cartA_contact1?.id as string; + if (!isString(cartA_contact1_id)) throw new TypeError(`contactA1_id failed to initialize: ${JSON.stringify(cartA_contact1_id)}`); + + // LOG.debug(`Step 3`) + cartA_contact2 = new ContactEntity({cartId: cartA_id, name: cartA_contact2_name}); + cartA_contact2 = await persister.insert( + cartA_contact2.getMetadata(), + cartA_contact2, + ); + cartA_contact2_id = cartA_contact2?.id as string; + if (!isString(cartA_contact2_id)) throw new TypeError(`contactA2_id failed to initialize: ${JSON.stringify(cartA_contact2_id)}`); + + // LOG.debug(`Step 4`) + cartA_contact3 = new ContactEntity({cartId: cartA_id, name: cartA_contact3_name}); + cartA_contact3 = await persister.insert( + cartA_contact3.getMetadata(), + cartA_contact3, + ); + cartA_contact3_id = cartA_contact3?.id as string; + if (!isString(cartA_contact3_id)) throw new TypeError(`contactA3_id failed to initialize: ${JSON.stringify(cartA_contact3_id)}`); + + + // LOG.debug(`Step 5`) + cartB = new CartEntity({name: cartB_name}); + cartB = await persister.insert( + cartB.getMetadata(), + cartB, + ); + cartB_id = cartB?.id as string; + if (!isString(cartB_id)) throw new TypeError(`cartB_id failed to initialize: ${JSON.stringify(cartB_id)}`); + + + // LOG.debug(`Step 6`) + cartB_item1 = new CartItemEntity({cartId: cartB_id, name: cartB_item1_name}); + cartB_item1 = await persister.insert( + cartB_item1.getMetadata(), + cartB_item1, + ); + cartB_item1_id = cartB_item1?.id as string; + if (!isString(cartB_item1_id)) throw new TypeError(`cartB_item1_id failed to initialize: ${JSON.stringify(cartB_item1_id)}`); + + // LOG.debug(`Step 7`) + cartB_item2 = new CartItemEntity({cartId: cartB_id, name: cartB_item2_name}); + cartB_item2 = await persister.insert( + cartB_item2.getMetadata(), + cartB_item2, + ); + cartB_item2_id = cartB_item2?.id as string; + if (!isString(cartB_item2_id)) throw new TypeError(`cartB_item2_id failed to initialize: ${JSON.stringify(cartB_item2_id)}`); + + // LOG.debug(`Step 8`) + cartB_item3 = new CartItemEntity({cartId: cartB_id, name: cartB_item3_name}); + cartB_item3 = await persister.insert( + cartB_item3.getMetadata(), + cartB_item3, + ); + cartB_item3_id = cartB_item3?.id as string; + if (!isString(cartB_item3_id)) throw new TypeError(`cartB_item3_id failed to initialize: ${JSON.stringify(cartB_item3_id)}`); + + + // LOG.debug(`Step 6`) + cartB_contact1 = new ContactEntity({cartId: cartB_id, name: cartB_contact1_name}); + cartB_contact1 = await persister.insert( + cartB_contact1.getMetadata(), + cartB_contact1, + ); + cartB_contact1_id = cartB_contact1?.id as string; + if (!isString(cartB_contact1_id)) throw new TypeError(`cartB_contact1_id failed to initialize: ${JSON.stringify(cartB_contact1_id)}`); + + // LOG.debug(`Step 7`) + cartB_contact2 = new ContactEntity({cartId: cartB_id, name: cartB_contact2_name}); + cartB_contact2 = await persister.insert( + cartB_contact2.getMetadata(), + cartB_contact2, + ); + cartB_contact2_id = cartB_contact2?.id as string; + if (!isString(cartB_contact2_id)) throw new TypeError(`cartB_contact2_id failed to initialize: ${JSON.stringify(cartB_contact2_id)}`); + + // LOG.debug(`Step 8`) + cartB_contact3 = new ContactEntity({cartId: cartB_id, name: cartB_contact3_name}); + cartB_contact3 = await persister.insert( + cartB_contact3.getMetadata(), + cartB_contact3, + ); + cartB_contact3_id = cartB_contact3?.id as string; + if (!isString(cartB_contact3_id)) throw new TypeError(`cartB_contact3_id failed to initialize: ${JSON.stringify(cartB_contact3_id)}`); + + + // LOG.debug(`Step 1`) + cartC = new CartEntity({name: cartC_name}); + cartC = await persister.insert( + cartC.getMetadata(), + cartC, + ); + cartC_id = cartC?.id as string; + if (!isString(cartC_id)) throw new TypeError(`cartC_id failed to initialize: ${JSON.stringify(cartC_id)}`); + + + // LOG.debug(`Step 9`) + + }); + + describe('#findAll', () => { + + it('returns related cart items mapped by @OneToMany', async () => { + + const items = await cartRepository.findAll(); + expect(items).toBeArray(); + expect(items?.length).toBeGreaterThanOrEqual(2); + + const cart1 = find(items, (item: CartEntity) : boolean => item.id === cartA_id); + const cart2 = find(items, (item: CartEntity) : boolean => item.id === cartB_id); + const cart3 = find(items, (item: CartEntity) : boolean => item.id === cartC_id); + expect(cart1?.id).toBe(cartA_id); + expect(cart2?.id).toBe(cartB_id); + expect(cart3?.id).toBe(cartC_id); + + expect((cart1?.items as any)?.length).toBeGreaterThanOrEqual(3); + const cart1_item1 = find(cart1?.items, (item) => item.id === cartA_item1_id); + const cart1_item2 = find(cart1?.items, (item) => item.id === cartA_item2_id); + const cart1_item3 = find(cart1?.items, (item) => item.id === cartA_item3_id); + expect(cart1_item1?.id).toBe(cartA_item1_id); + expect(cart1_item2?.id).toBe(cartA_item2_id); + expect(cart1_item3?.id).toBe(cartA_item3_id); + expect((cart1?.items as any)?.length).toBe(3); + + expect((cart2?.items as any)?.length).toBeGreaterThanOrEqual(3); + const cart2_item1 = find(cart2?.items, (item) => item.id === cartB_item1_id); + const cart2_item2 = find(cart2?.items, (item) => item.id === cartB_item2_id); + const cart2_item3 = find(cart2?.items, (item) => item.id === cartB_item3_id); + expect(cart2_item1?.id).toBe(cartB_item1_id); + expect(cart2_item2?.id).toBe(cartB_item2_id); + expect(cart2_item3?.id).toBe(cartB_item3_id); + expect((cart2?.items as any)?.length).toBe(3); + + expect((cart3?.items as any)?.length).toBe(0); + + expect(items?.length).toBe(3); + + }); + + it('returns related cart items mapped by @ManyToOne', async () => { + + const items = await cartItemRepository.findAll(); + expect(items).toBeArray(); + expect(items?.length).toBe(6); + + const item1 = find(items, (item) => item?.id === cartA_item1_id); + const item2 = find(items, (item) => item?.id === cartA_item2_id); + const item3 = find(items, (item) => item?.id === cartA_item3_id); + const item4 = find(items, (item) => item?.id === cartB_item1_id); + const item5 = find(items, (item) => item?.id === cartB_item2_id); + const item6 = find(items, (item) => item?.id === cartB_item3_id); + + expect(item1?.cart).toBeDefined(); + expect(item1?.cart?.id).toBe(cartA_id); + expect(item1?.cart?.name).toBe(cartA_name); + expect((item1?.cart?.items as any)?.length).toBe(0); + // expect((item1?.cart?.items as any)[0]?.id).toBe(cartA_item1_id); + // expect((item1?.cart?.items as any)[1]?.id).toBe(cartA_item2_id); + // expect((item1?.cart?.items as any)[2]?.id).toBe(cartA_item3_id); + + expect(item2?.cart).toBeDefined(); + expect(item2?.cart?.id).toBe(cartA_id); + expect(item2?.cart?.name).toBe(cartA_name); + expect((item2?.cart?.items as any)?.length).toBe(0); + // expect((item2?.cart?.items as any)[0]?.id).toBe(cartA_item1_id); + // expect((item2?.cart?.items as any)[1]?.id).toBe(cartA_item2_id); + // expect((item2?.cart?.items as any)[2]?.id).toBe(cartA_item3_id); + + expect(item3?.cart).toBeDefined(); + expect(item3?.cart?.id).toBe(cartA_id); + expect(item3?.cart?.name).toBe(cartA_name); + expect((item3?.cart?.items as any)?.length).toBe(0); + // expect((item3?.cart?.items as any)[0]?.id).toBe(cartA_item1_id); + // expect((item3?.cart?.items as any)[1]?.id).toBe(cartA_item2_id); + // expect((item3?.cart?.items as any)[2]?.id).toBe(cartA_item3_id); + + expect(item4?.cart).toBeDefined(); + expect(item4?.cart?.id).toBe(cartB_id); + expect(item4?.cart?.name).toBe(cartB_name); + expect((item4?.cart?.items as any)?.length).toBe(0); + // expect((item4?.cart?.items as any)[0]?.id).toBe(cartB_item1_id); + // expect((item4?.cart?.items as any)[1]?.id).toBe(cartB_item2_id); + // expect((item4?.cart?.items as any)[2]?.id).toBe(cartB_item3_id); + + expect(item5?.cart).toBeDefined(); + expect(item5?.cart?.id).toBe(cartB_id); + expect(item5?.cart?.name).toBe(cartB_name); + expect((item5?.cart?.items as any)?.length).toBe(0); + // expect((item5?.cart?.items as any)[0]?.id).toBe(cartB_item1_id); + // expect((item5?.cart?.items as any)[1]?.id).toBe(cartB_item2_id); + // expect((item5?.cart?.items as any)[2]?.id).toBe(cartB_item3_id); + + expect(item6?.cart).toBeDefined(); + expect(item6?.cart?.id).toBe(cartB_id); + expect(item6?.cart?.name).toBe(cartB_name); + expect((item6?.cart?.items as any)?.length).toBe(0); + // expect((item6?.cart?.items as any)[0]?.id).toBe(cartB_item1_id); + // expect((item6?.cart?.items as any)[1]?.id).toBe(cartB_item2_id); + // expect((item6?.cart?.items as any)[2]?.id).toBe(cartB_item3_id); + + }); + + }); + + describe('#findAllById', () => { + + it('returns related cart mapped by @OneToMany to cart item entities', async () => { + + const items = await cartRepository.findAllById([cartA_id]); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.id).toBe(cartA_id); + expect(items[0]?.items?.length).toBe(3); + + const item1 = find((items[0]?.items as any), (item) => item?.id === cartA_item1_id); + const item2 = find((items[0]?.items as any), (item) => item?.id === cartA_item2_id); + const item3 = find((items[0]?.items as any), (item) => item?.id === cartA_item3_id); + + expect(item1?.id).toBe(cartA_item1_id); + expect(item2?.id).toBe(cartA_item2_id); + expect(item3?.id).toBe(cartA_item3_id); + }); + + it('returns related cart item mapped by @ManyToOne to cart entity', async () => { + const items = await cartItemRepository.findAllById([cartA_item1_id]); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + const cartItem = items[0]; + expect(cartItem).toBeDefined(); + + const cart = cartItem.cart; + expect(cart).toBeDefined(); + + expect(cart?.items?.length).toBe(0); + // expect((cart?.items as any)[0]?.id).toBe(cartA_item1_id); + // expect((cart?.items as any)[1]?.id).toBe(cartA_item2_id); + // expect((cart?.items as any)[2]?.id).toBe(cartA_item3_id); + + }); + + }); + + describe('#findById', () => { + + it('returns related cart mapped by @OneToMany to cart item entities', async () => { + const entity : CartEntity | undefined = await cartRepository.findById(cartA_id); + expect(entity).toBeDefined(); + expect(entity?.id).toBe(cartA_id); + expect(entity?.items).toBeArray(); + expect(entity?.items?.length).toBe(3); + + const item1 = find((entity?.items as any), (item) => item?.id === cartA_item1_id); + const item2 = find((entity?.items as any), (item) => item?.id === cartA_item2_id); + const item3 = find((entity?.items as any), (item) => item?.id === cartA_item3_id); + + expect(item1?.id).toBe(cartA_item1_id); + expect(item2?.id).toBe(cartA_item2_id); + expect(item3?.id).toBe(cartA_item3_id); + }); + + it('returns related cart item mapped by @ManyToOne to cart entity', async () => { + const entity : CartItemEntity | undefined = await cartItemRepository.findById(cartA_item1_id); + expect(entity).toBeDefined(); + expect(entity?.cart).toBeDefined(); + + const cartEntity = entity?.cart; + expect(cartEntity).toBeDefined(); + expect(cartEntity?.items).toBeArray(); + expect(cartEntity?.items?.length).toBe(0); + // expect((cartEntity?.items as any)[0]?.id).toBe(cartA_item1_id); + // expect((cartEntity?.items as any)[1]?.id).toBe(cartA_item2_id); + // expect((cartEntity?.items as any)[2]?.id).toBe(cartA_item3_id); + }); + + }); + + describe('#find', () => { + + it('returns related cart mapped by @OneToMany to cart item entities', async () => { + const items = await cartRepository.find("name", cartA_name); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.id).toBe(cartA_id); + expect(items[0]?.name).toBe(cartA_name); + + const cartEntity = items[0] as any; + expect(cartEntity).toBeDefined(); + expect(cartEntity?.items).toBeArray(); + expect(cartEntity?.items?.length).toBe(3); + expect((cartEntity?.items as any)[0]?.id).toBe(cartA_item1_id); + expect((cartEntity?.items as any)[1]?.id).toBe(cartA_item2_id); + expect((cartEntity?.items as any)[2]?.id).toBe(cartA_item3_id); + + }); + + it('returns related cart item mapped by @ManyToOne to the cart entity', async () => { + const items = await cartItemRepository.find("name", cartA_item1_name); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.id).toBe(cartA_item1_id); + expect(items[0]?.name).toBe(cartA_item1_name); + + const cartEntity = items[0]?.cart as any; + expect(cartEntity).toBeDefined(); + expect(cartEntity?.items).toBeArray(); + expect(cartEntity?.items?.length).toBe(0); + // expect((cartEntity?.items as any)[0]?.id).toBe(cartA_item1_id); + // expect((cartEntity?.items as any)[1]?.id).toBe(cartA_item2_id); + // expect((cartEntity?.items as any)[2]?.id).toBe(cartA_item3_id); + + }); + + }); + + describe('#saveAll', () => { + + // TODO: Implement support for this user flow: We're missing ability to insert related entities + it.skip('can save items mapped by @OneToMany', async () => { + + const newItem = new CartItemEntity( + { + name: 'New Item 1' + } + ); + + const newCart = new CartEntity( + { + name: 'Hello world 1', + items: [newItem] + } + ); + + const savedItems = await cartRepository.saveAll([newCart]); + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(1); + + const savedCart = savedItems[0] as any; + expect(savedCart).toBeDefined(); + expect(savedCart?.name).toBe('Hello world 1'); + expect(savedCart?.items).toBeArray(); + expect(savedCart?.items?.length).toBe(1); + + const savedCartItem = savedCart?.items[0]; + + expect(savedCartItem?.id).toBeDefined(); + expect(savedCartItem?.name).toBe('New Item 1'); + + }); + + // TODO: Implement support for this user flow: We're missing ability to insert related entities + it.skip('can save items mapped by @ManyToOne', async () => { + + // const newCart = new CartEntity( + // { + // name: 'Hello world 1', + // items: [] + // } + // ); + + const newItem = new CartItemEntity( + { + name: 'New Item 1' + } + ); + + const savedItems = await cartItemRepository.saveAll([newItem]); + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(1); + + const savedCartItem = savedItems[0] as any; + expect(savedCartItem?.id).toBeDefined(); + expect(savedCartItem?.name).toBe('New Item 1'); + + const savedCart = savedCartItem?.cart as any; + expect(savedCart).toBeDefined(); + expect(savedCart?.name).toBe('Hello world 1'); + expect(savedCart?.items).toBeArray(); + expect(savedCart?.items?.length).toBe(1); + + }); + + }); + + describe('#save', () => { + + // TODO: Implement support for this user flow: We're missing ability to insert related entities + it.skip('can save carts with items mapped by @OneToMany', async () => { + + const newItem = new CartItemEntity( + { + name: 'New Item 1' + } + ); + + const newEntity = new CartEntity( + { + name: 'Hello world', + items: [newItem] + } + ); + + const savedCart = await cartRepository.save(newEntity); + expect(savedCart).toBeDefined(); + expect(savedCart?.name).toBe('Hello world'); + expect(savedCart?.items).toBeArray(); + expect(savedCart?.items?.length).toBe(1); + + const savedCartItem = savedCart?.items[0]; + + expect(savedCartItem?.id).toBeDefined(); + expect(savedCartItem?.name).toBe('New Item 1'); + + }); + + // TODO: Implement support for this user flow: We're missing ability to insert related entities + it.skip('can save carts mapped to items by @ManyToOne', async () => { + + // const newEntity = new CartEntity( + // { + // name: 'Hello world', + // items: [] + // } + // ); + + const newItem = new CartItemEntity( + { + name: 'New Item 1' + } + ); + + const savedCartItem = await cartItemRepository.save(newItem); + expect(savedCartItem?.id).toBeDefined(); + expect(savedCartItem?.name).toBe('New Item 1'); + + const savedCart = savedCartItem?.cart; + expect(savedCart).toBeDefined(); + expect(savedCart?.name).toBe('Hello world'); + expect(savedCart?.items).toBeArray(); + expect(savedCart?.items?.length).toBe(1); + + }); + + }); + + describe('#findAllByName', () => { + + it('returns related cart mapped by @OneToMany to cart item entities', async () => { + + const items = await cartRepository.findAllByName(cartA_name); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + + const cartEntity = items[0] as any; + expect(cartEntity).toBeDefined(); + expect(cartEntity?.id).toBe(cartA_id); + expect(cartEntity?.name).toBe(cartA_name); + expect(cartEntity?.items).toBeArray(); + expect(cartEntity?.items?.length).toBe(3); + expect((cartEntity?.items as any)[0]?.id).toBe(cartA_item1_id); + expect((cartEntity?.items as any)[1]?.id).toBe(cartA_item2_id); + expect((cartEntity?.items as any)[2]?.id).toBe(cartA_item3_id); + + }); + + it('returns related cart items mapped by @ManyToOne to the cart entity', async () => { + const items = await cartItemRepository.findAllByName(cartA_item1_name); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + + const cartItemEntity = items[0] as any; + expect( cartItemEntity?.id ).toBe(cartA_item1_id); + expect( cartItemEntity?.name ).toBe(cartA_item1_name); + + const cartEntity = cartItemEntity?.cart as any; + expect(cartEntity).toBeDefined(); + expect(cartEntity?.id).toBe(cartA_id); + expect(cartEntity?.name).toBe(cartA_name); + expect(cartEntity?.items).toBeArray(); + expect(cartEntity?.items?.length).toBe(0); + // expect((cartEntity?.items as any)[0]?.id).toBe(cartA_item1_id); + // expect((cartEntity?.items as any)[1]?.id).toBe(cartA_item2_id); + // expect((cartEntity?.items as any)[2]?.id).toBe(cartA_item3_id); + + }); + + }); + + describe('#findByName', () => { + + it('returns related cart mapped by @OneToMany to cart item entities', async () => { + const cartEntity : CartEntity | undefined = await cartRepository.findByName(cartA_name); + expect(cartEntity).toBeDefined(); + expect(cartEntity?.id).toBe(cartA_id); + expect(cartEntity?.name).toBe(cartA_name); + expect(cartEntity?.items).toBeArray(); + expect(cartEntity?.items?.length).toBe(3); + expect((cartEntity?.items as any)[0]?.id).toBe(cartA_item1_id); + expect((cartEntity?.items as any)[1]?.id).toBe(cartA_item2_id); + expect((cartEntity?.items as any)[2]?.id).toBe(cartA_item3_id); + }); + + it('returns related cart items mapped by @ManyToOne to the cart entity', async () => { + + const cartItemEntity : CartItemEntity | undefined = await cartItemRepository.findByName(cartA_item1_name); + expect( cartItemEntity ).toBeDefined(); + expect( cartItemEntity?.id ).toBe(cartA_item1_id); + expect( cartItemEntity?.name ).toBe(cartA_item1_name); + + const cartEntity = cartItemEntity?.cart as any; + + expect(cartEntity).toBeDefined(); + expect(cartEntity?.id).toBe(cartA_id); + expect(cartEntity?.name).toBe(cartA_name); + expect(cartEntity?.items).toBeArray(); + expect(cartEntity?.items?.length).toBe(0); + // expect((cartEntity?.items as any)[0]?.id).toBe(cartA_item1_id); + // expect((cartEntity?.items as any)[1]?.id).toBe(cartA_item2_id); + // expect((cartEntity?.items as any)[2]?.id).toBe(cartA_item3_id); + }); + + it('returns related cart items mapped by @ManyToOne to the cart entity when there is a removed cart', async () => { + + // First we'll remove the linked item, so it cannot be left joined. + await cartRepository.deleteById(cartA_id); + + const cartItemEntity : CartItemEntity | undefined = await cartItemRepository.findByName(cartA_item1_name); + expect( cartItemEntity ).toBeDefined(); + expect( cartItemEntity?.id ).toBe(cartA_item1_id); + expect( cartItemEntity?.name ).toBe(cartA_item1_name); + expect( cartItemEntity?.cart ).toBeUndefined(); + }); + + }); + +}; diff --git a/data/tests/typeJsonTests.ts b/data/tests/typeJsonTests.ts new file mode 100644 index 0000000..3d4b869 --- /dev/null +++ b/data/tests/typeJsonTests.ts @@ -0,0 +1,1152 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../../testing/jest/matchers/index"; +import { find } from "../../functions/find"; +import { Repository } from "../types/Repository"; +import { RepositoryTestContext } from "./types/types/RepositoryTestContext"; +import { Persister } from "../types/Persister"; +import { createCrudRepositoryWithPersister } from "../types/CrudRepository"; +import { Sort } from "../Sort"; +import { Table } from "../Table"; +import { Entity } from "../Entity"; +import { Id } from "../Id"; +import { Column } from "../Column"; +import { ReadonlyJsonObject } from "../../Json"; +import { isDeepStrictEqual } from "util"; + +export const typeJsonTests = (context : RepositoryTestContext) : void => { + + interface MyJsonData extends ReadonlyJsonObject { + readonly name : string; + } + + /** + * Test json entities + */ + @Table('type_test_json_data') + class DataEntity extends Entity { + + constructor (dto ?: {dataJson: MyJsonData}) { + super() + this.dataJson = dto?.dataJson; + } + + @Id() + @Column('data_id', 'BIGINT') + public dataId ?: string; + + @Column('data_json', 'JSON') + public dataJson ?: MyJsonData; + + } + + interface DataRepository extends Repository { + + findAllByDataJson (ids: readonly string[] | string, sort?: Sort) : Promise; + findByDataJson (id: string, sort?: Sort): Promise; + deleteAllByDataJson (id: string): Promise; + existsByDataJson (id : string): Promise; + countByDataJson (id : string) : Promise; + + findAllByDataJson(name: MyJsonData, sort?: Sort) : Promise; + findByDataJson (name: MyJsonData, sort?: Sort): Promise; + deleteAllByDataJson (name: MyJsonData): Promise; + existsByDataJson (name : MyJsonData): Promise; + countByDataJson (name: MyJsonData) : Promise; + + findAllByDataJsonBetween (start: MyJsonData, end: MyJsonData, sort?: Sort) : Promise; + findByDataJsonBetween (start: MyJsonData, end: MyJsonData, sort?: Sort): Promise; + deleteAllByDataJsonBetween (start: MyJsonData, end: MyJsonData): Promise; + existsByDataJsonBetween (start: MyJsonData, end: MyJsonData): Promise; + countByDataJsonBetween (start: MyJsonData, end: MyJsonData) : Promise; + + findAllByDataJsonBefore (value: MyJsonData, sort?: Sort) : Promise; + findByDataJsonBefore (value: MyJsonData, sort?: Sort): Promise; + deleteAllByDataJsonBefore (value: MyJsonData): Promise; + existsByDataJsonBefore (value: MyJsonData): Promise; + countByDataJsonBefore (value: MyJsonData) : Promise; + + findAllByDataJsonAfter (value: MyJsonData, sort?: Sort) : Promise; + findByDataJsonAfter (value: MyJsonData, sort?: Sort): Promise; + deleteAllByDataJsonAfter (value: MyJsonData): Promise; + existsByDataJsonAfter (value: MyJsonData): Promise; + countByDataJsonAfter (value: MyJsonData) : Promise; + + } + + let persister : Persister; + + /** + * This repository will have four items + */ + let dataRepository : DataRepository; + let dataEntity1 : DataEntity; + let dataEntity2 : DataEntity; + let dataEntity3 : DataEntity; + let dataEntity4 : DataEntity; + let dataEntityId1 : string; + let dataEntityId2 : string; + let dataEntityId3 : string; + let dataEntityId4 : string; + + // Entity 5 is duplicate of json in dataEntityId1. This is not initialized by default + let dataEntity5 : DataEntity; + let dataEntityId5 : string; + + const jsonDataNameBefore1 : string = 'Bar 1'; + const jsonDataName1 : string = 'Bar 12'; + const jsonDataNameBetween1And2 : string = 'Bar 124'; + const jsonDataName2 : string = 'Bar 456'; + const jsonDataNameBetween2And3 : string = 'Bar 567'; + const jsonDataName3 : string = 'Bar 789'; + const jsonDataNameBetween3And4 : string = 'Bar 900'; + const jsonDataName4 : string = 'Bar 1200'; + const jsonDataNameAfter4 : string = 'Bar 2900'; + + const jsonDataBefore1 : MyJsonData = { name: jsonDataNameBefore1 }; + const jsonDataEntity1 : MyJsonData = { name: jsonDataName1 }; + const jsonDataBetween1And2 : MyJsonData = { name: jsonDataNameBetween1And2 }; + const jsonDataEntity2 : MyJsonData = { name: jsonDataName2 }; + const jsonDataBetween2And3 : MyJsonData = { name: jsonDataNameBetween2And3 }; + const jsonDataEntity3 : MyJsonData = { name: jsonDataName3 }; + const jsonDataBetween3And4 : MyJsonData = { name: jsonDataNameBetween3And4 }; + const jsonDataEntity4 : MyJsonData = { name: jsonDataName4 }; + const jsonDataAfter4 : MyJsonData = { name: jsonDataNameAfter4 }; + + // Entity 5 Must be same as dataEntityId1, but not initialized by default + const jsonDataName5 : string = jsonDataName1; + const jsonDataEntity5 : MyJsonData = { name: jsonDataName5 }; + + beforeEach( async () => { + + persister = context.getPersister(); + + // Will be initialized with four entities + dataRepository = createCrudRepositoryWithPersister( + new DataEntity(), + persister + ); + await dataRepository.deleteAll(); + + dataEntity1 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity1}), + ); + + dataEntityId1 = dataEntity1?.dataId as string; + if (!dataEntityId1) throw new TypeError('barEntity1 failed to initialize'); + if (!isDeepStrictEqual(dataEntity1.dataJson, jsonDataEntity1)) throw new TypeError(`barEntity1 data did not initialize correctly: ${dataEntity1.dataJson}`); + + dataEntity2 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity2}), + ); + dataEntityId2 = dataEntity2?.dataId as string; + if (!dataEntityId2) throw new TypeError('barEntity2 failed to initialize'); + if (dataEntityId1 === dataEntityId2) throw new TypeError(`barEntity2 failed to initialize (not unique ID with barEntityId1 and barEntityId2): ${dataEntityId1}`); + if (!isDeepStrictEqual(dataEntity2.dataJson, jsonDataEntity2)) throw new TypeError(`barEntity2 data did not initialize correctly: ${dataEntity2.dataJson}`); + + dataEntity3 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity3}), + ); + dataEntityId3 = dataEntity3?.dataId as string; + if (!dataEntityId3) throw new TypeError('barEntity3 failed to initialize'); + if (dataEntityId1 === dataEntityId3) throw new TypeError(`barEntityId3 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId3) throw new TypeError(`barEntityId3 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (!isDeepStrictEqual(dataEntity3.dataJson, jsonDataEntity3)) throw new TypeError(`barEntity3 data did not initialize correctly: ${dataEntity3.dataJson}`); + + dataEntity4 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity4}), + ); + dataEntityId4 = dataEntity4?.dataId as string; + if (!dataEntityId4) throw new TypeError('barEntity4 failed to initialize'); + if (dataEntityId1 === dataEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (dataEntityId3 === dataEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 3): ${dataEntityId3}`); + if (!isDeepStrictEqual(dataEntity4.dataJson, jsonDataEntity4)) throw new TypeError(`barEntity4 data did not initialize correctly: ${dataEntity4.dataJson}`); + + }); + + describe('Create', () => { + + describe('#save', () => { + + it('can save fresh entity', async () => { + + expect( await dataRepository.count() ).toBe(4); + + const newEntity = new DataEntity({dataJson: { name: 'Hello world' }}); + + const savedItem = await dataRepository.save(newEntity); + expect(savedItem).toBeDefined(); + expect(savedItem.dataId).toBeDefined(); + expect(savedItem.dataJson).toStrictEqual( { name: 'Hello world' }); + + const addedId : string = savedItem?.dataId as string; + + expect( await dataRepository.count() ).toBe(5); + + const foundItem = await dataRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.dataId).toBe(addedId); + expect(foundItem?.dataJson).toStrictEqual( { name: 'Hello world' }); + + }); + + }); + + describe('#saveAll', () => { + + it('can save multiple fresh entities', async () => { + + expect( await dataRepository.count() ).toBe(4); + + const newEntity1 = new DataEntity({dataJson: { name: 'Hello world 1' }}); + const newEntity2 = new DataEntity({dataJson: { name: 'Hello world 2' }}); + + const savedItems = await dataRepository.saveAll([newEntity1, newEntity2]); + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(2); + + expect(savedItems[0]?.dataId).toBeDefined(); + expect(savedItems[0]?.dataJson).toStrictEqual( { name: 'Hello world 1' }); + + expect(savedItems[1]?.dataId).toBeDefined(); + expect(savedItems[1]?.dataJson).toStrictEqual( { name: 'Hello world 2' }); + + const addedId1 : string = savedItems[0]?.dataId as string; + const addedId2 : string = savedItems[1]?.dataId as string; + + expect( await dataRepository.count() ).toBe(6); + + const foundItem1 = await dataRepository.findById(addedId1); + expect(foundItem1).toBeDefined(); + expect(foundItem1?.dataId).toBe(addedId1); + expect(foundItem1?.dataJson).toStrictEqual( {name: 'Hello world 1' }); + + const foundItem2 = await dataRepository.findById(addedId2); + expect(foundItem2).toBeDefined(); + expect(foundItem2?.dataId).toBe(addedId2); + expect(foundItem2?.dataJson).toStrictEqual( { name: 'Hello world 2' }); + + }); + + }); + + }); + + describe('Read', () => { + + describe('#count', () => { + + it('can count entities', async () => { + expect( await dataRepository.count() ).toBe(4); + }); + + }); + + describe('#countByDataJson', () => { + + it('can count entities by dataJson', async () => { + expect( await dataRepository.countByDataJson(jsonDataEntity2) ).toBe(1); + await dataRepository.deleteAllByDataJson(jsonDataEntity2); + expect( await dataRepository.countByDataJson(jsonDataEntity2) ).toBe(0); + }); + + }); + + + describe('#existsByDataJson', () => { + + it('can find if entity exists by dataJson', async () => { + expect( await dataRepository.existsByDataJson(jsonDataEntity2) ).toBe(true); + await dataRepository.deleteAllByDataJson(jsonDataEntity2); + expect( await dataRepository.existsByDataJson(jsonDataEntity2) ).toBe(false); + }); + + }); + + describe('#existsById', () => { + + it('can find if entity exists', async () => { + expect( await dataRepository.existsById( dataEntityId2 ) ).toBe(true); + await dataRepository.deleteAllById( [dataEntityId2] ); + expect( await dataRepository.existsById( dataEntityId2 ) ).toBe(false); + }); + + }); + + + describe('#findAll', () => { + + it('can find all entities unsorted', async () => { + const items = await dataRepository.findAll(); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + // Order may be different + const item1 = find(items, (item) => item.dataId === dataEntityId1); + const item2 = find(items, (item) => item.dataId === dataEntityId2); + const item3 = find(items, (item) => item.dataId === dataEntityId3); + + expect(item1).toBeDefined(); + expect(item1?.dataId).toBe(dataEntityId1); + expect(item1?.dataJson).toStrictEqual(jsonDataEntity1); + + expect(item2).toBeDefined(); + expect(item2?.dataId).toBe(dataEntityId2); + expect(item2?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(item3).toBeDefined(); + expect(item3?.dataId).toBe(dataEntityId3); + expect(item3?.dataJson).toStrictEqual(jsonDataEntity3); + + }); + + // FIXME: Requires JSON property matching in Sorting + it.skip('can find all entities sorted by name and id in ascending order', async () => { + + const items = await dataRepository.findAll( Sort.by('dataJson.name', 'dataId') ); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId1); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity1); + + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId4); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity4); + + expect(items[2]).toBeDefined(); + expect(items[2]?.dataId).toBe(dataEntityId2); + expect(items[2]?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(items[3]).toBeDefined(); + expect(items[3]?.dataId).toBe(dataEntityId3); + expect(items[3]?.dataJson).toStrictEqual(jsonDataEntity3); + + }); + + // FIXME: Requires JSON property matching in Sorting + it.skip('can find all entities sorted by name and id in desc order', async () => { + + const items = await dataRepository.findAll( Sort.by(Sort.Direction.DESC, 'dataJson.name', 'dataId') ); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId3); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity3); + + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId2); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(items[2]).toBeDefined(); + expect(items[2]?.dataId).toBe(dataEntityId4); + expect(items[2]?.dataJson).toStrictEqual(jsonDataEntity4); + + expect(items[3]).toBeDefined(); + expect(items[3]?.dataId).toBe(dataEntityId1); + expect(items[3]?.dataJson).toStrictEqual(jsonDataEntity1); + + }); + + }); + + describe('#findAllByDataJson', () => { + + it('can fetch single entity by dataJson property unsorted', async () => { + const items = await dataRepository.findAllByDataJson(jsonDataEntity2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (property based sorting not implemented) + it.skip('can fetch single entity by dataJson property in asc order', async () => { + const items = await dataRepository.findAllByDataJson(jsonDataEntity2, Sort.by('dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (property based sorting not implemented) + it.skip('can fetch single entity by dataJson property in desc order', async () => { + const items = await dataRepository.findAllByDataJson(jsonDataEntity2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + it('can fetch multiple entities by dataJson property unsorted', async () => { + + // Initialize as same as entity 1 + dataEntity5 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity5}), + ); + dataEntityId5 = dataEntity5?.dataId as string; + if (!dataEntityId5) throw new TypeError('barEntity5 failed to initialize'); + if (dataEntityId1 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (dataEntityId3 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 3): ${dataEntityId3}`); + if (!isDeepStrictEqual(dataEntity5?.dataJson, jsonDataEntity5)) throw new TypeError(`barEntity5 data did not initialize correctly: ${dataEntity5.dataJson}`); + + const items = await dataRepository.findAllByDataJson(jsonDataEntity1); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + + const item1 = find(items, (item) => item.dataId === dataEntity1.dataId); + const item5 = find(items, (item) => item.dataId === dataEntity5.dataId); + + expect(item1).toBeDefined(); + expect(item5).toBeDefined(); + expect(item1?.dataId).toBe(dataEntityId1); + expect(item1?.dataJson).toStrictEqual(jsonDataEntity1); + expect(item5?.dataId).toBe(dataEntityId5); + expect(item5?.dataJson).toStrictEqual(jsonDataEntity5); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (JSON sorting not implemented) + it.skip('can fetch multiple entities by dataId property in asc order', async () => { + + // Initialize as same as entity 1 + dataEntity5 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity5}), + ); + dataEntityId5 = dataEntity5?.dataId as string; + if (!dataEntityId5) throw new TypeError('barEntity5 failed to initialize'); + if (dataEntityId1 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (dataEntityId3 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 3): ${dataEntityId3}`); + if (!isDeepStrictEqual(dataEntity5?.dataJson, jsonDataEntity5)) throw new TypeError(`barEntity5 data did not initialize correctly: ${dataEntity5.dataJson}`); + + const items = await dataRepository.findAllByDataJson(jsonDataEntity1, Sort.by('dataJson')); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId1); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity1); + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId5); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity5); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (JSON sorting not implemented) + it.skip('can fetch multiple entities by dataId property in desc order', async () => { + + // Initialize as same as entity 1 + dataEntity5 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity5}), + ); + dataEntityId5 = dataEntity5?.dataId as string; + if (!dataEntityId5) throw new TypeError('barEntity5 failed to initialize'); + if (dataEntityId1 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (dataEntityId3 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 3): ${dataEntityId3}`); + if (!isDeepStrictEqual(dataEntity5?.dataJson, jsonDataEntity5)) throw new TypeError(`barEntity5 data did not initialize correctly: ${dataEntity5.dataJson}`); + + const items = await dataRepository.findAllByDataJson(jsonDataEntity1, Sort.by(Sort.Direction.DESC,'dataJson')); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId5); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity5); + + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId1); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity1); + }); + + }); + + describe('#findAllById', () => { + + it('can find all entities by id unsorted', async () => { + const items = await dataRepository.findAllById([dataEntityId2, dataEntityId3]); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + expect(items[1]?.dataId).toBe(dataEntityId3); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (property in json sorting not implemented) + it.skip('can find all entities by id in ascending order', async () => { + const items = await dataRepository.findAllById([dataEntityId2, dataEntityId3], Sort.by('dataJson.name') ); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId3); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (property in json sorting not implemented) + it.skip('can find all entities by id in desc order', async () => { + const items = await dataRepository.findAllById([dataEntityId2, dataEntityId3], Sort.by(Sort.Direction.DESC,'dataJson.name') ); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId2); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity2); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId3); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + }); + + + describe('#find', () => { + + it('can find entities by property unsorted', async () => { + const items = await dataRepository.find("dataJson", jsonDataEntity2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This requires JSON property matching + it.skip('can find entities by property in asc order', async () => { + const items = await dataRepository.find("dataJson", jsonDataEntity2, Sort.by('dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This requires JSON property sorting + it.skip('can find entities by property in desc order', async () => { + const items = await dataRepository.find("dataJson", jsonDataEntity2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (json property sorting not implemented) + it.skip('can find entities by child property unsorted', async () => { + const items = await dataRepository.find("dataJson.name", jsonDataName2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson?.name).toStrictEqual(jsonDataName2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (json property matching not implemented) + it.skip('can find entities by child property in asc order', async () => { + const items = await dataRepository.find("dataJson.name", jsonDataName2, Sort.by('dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson?.name).toStrictEqual(jsonDataName2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (json property matching not implemented) + it.skip('can find entities by child property in desc order', async () => { + const items = await dataRepository.find("dataJson.name", jsonDataName2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson?.name).toStrictEqual(jsonDataName2); + }); + + }); + + describe('#findByDataJson', () => { + + it('can find entity by dataJson property unsorted', async () => { + const entity : DataEntity | undefined = await dataRepository.findByDataJson(jsonDataEntity2); + expect(entity).toBeDefined(); + expect(entity?.dataId).toBe(dataEntityId2); + expect(entity?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (sorting not implemented) + it.skip('can find entity by dataJson property in asc order', async () => { + const entity : DataEntity | undefined = await dataRepository.findByDataJson(jsonDataEntity2, Sort.by('dataJson.name')); + expect(entity).toBeDefined(); + expect(entity?.dataId).toBe(dataEntityId2); + expect(entity?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (sorting not implemented) + it.skip('can find entity by dataJson property in desc order', async () => { + const entity : DataEntity | undefined = await dataRepository.findByDataJson(jsonDataEntity2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(entity).toBeDefined(); + expect(entity?.dataId).toBe(dataEntityId2); + expect(entity?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + }); + + describe('#findById', () => { + + it('can find entity by id unsorted', async () => { + const item = await dataRepository.findById(dataEntityId2); + expect(item).toBeDefined(); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (sorting not implemented) + it.skip('can find entity by id by asc order', async () => { + const item = await dataRepository.findById(dataEntityId2, Sort.by('dataJson.name')); + expect(item).toBeDefined(); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (sorting not implemented) + it.skip('can find entity by id by desc order', async () => { + const item = await dataRepository.findById(dataEntityId2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(item).toBeDefined(); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + }); + + }); + + describe('Update', () => { + + describe('#save', () => { + + it('can save older entity', async () => { + + expect( await dataRepository.count() ).toBe(4); + + dataEntity2.dataJson = { + ...(dataEntity2.dataJson ? dataEntity2.dataJson : {}), + name: 'Hello world' + }; + + const savedItem = await dataRepository.save(dataEntity2); + expect(savedItem).toBeDefined(); + expect(savedItem.dataId).toBe(dataEntityId2); + expect(savedItem?.dataJson).toStrictEqual( { name: 'Hello world' }); + + expect( await dataRepository.count() ).toBe(4); + + const foundItem = await dataRepository.findById(dataEntityId2); + expect(foundItem).toBeDefined(); + expect(foundItem?.dataId).toBe(dataEntityId2); + expect(foundItem?.dataJson).toStrictEqual( { name: 'Hello world' }); + + }); + + }); + + describe('#saveAll', () => { + + it('can save multiple older entities', async () => { + + expect( await dataRepository.count() ).toBe(4); + + dataEntity2.dataJson = { name : 'Hello world 1' }; + dataEntity3.dataJson = { name : 'Hello world 2' }; + + const savedItems = await dataRepository.saveAll([dataEntity2, dataEntity3]); + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(2); + + expect(savedItems[0].dataId).toBe(dataEntityId2); + expect(savedItems[0].dataJson).toStrictEqual( { name: 'Hello world 1' }); + + expect(savedItems[1].dataId).toBe(dataEntityId3); + expect(savedItems[1].dataJson).toStrictEqual( { name: 'Hello world 2' }); + + expect( await dataRepository.count() ).toBe(4); + + const foundItem2 = await dataRepository.findById(dataEntityId2); + expect(foundItem2).toBeDefined(); + expect(foundItem2?.dataId).toBe(dataEntityId2); + expect(foundItem2?.dataJson).toStrictEqual( { name: 'Hello world 1' }); + + const foundItem3 = await dataRepository.findById(dataEntityId3); + expect(foundItem3).toBeDefined(); + expect(foundItem3?.dataId).toBe(dataEntityId3); + expect(foundItem3?.dataJson).toStrictEqual( { name: 'Hello world 2' }); + + }); + + }); + + }); + + describe('Delete', () => { + + describe('#delete', () => { + + it('can delete entity by entity object', async () => { + + expect( await dataRepository.count() ).toBe(4); + await dataRepository.delete(dataEntity2); + expect( await dataRepository.count() ).toBe(3); + + let entity : DataEntity | undefined = await dataRepository.findById(dataEntityId2); + expect(entity).not.toBeDefined(); + + }); + + }); + + describe('#deleteById', () => { + + it('can delete entity by id', async () => { + + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteById(dataEntityId2); + expect( await dataRepository.count() ).toBe(3); + + let entity : DataEntity | undefined = await dataRepository.findById(dataEntityId2); + expect(entity).not.toBeDefined(); + + }); + + }); + + describe('#deleteAll', () => { + + it('can delete all entities', async () => { + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteAll(); + expect( await dataRepository.count() ).toBe(0); + }); + + it('can delete few entities with an array of entities', async () => { + + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteAll( + [ + dataEntity2, + dataEntity3 + ] + ); + expect( await dataRepository.count() ).toBe(2); + + let entity1 : DataEntity | undefined = await dataRepository.findById(dataEntityId1); + expect(entity1).toBeDefined(); + + let entity2 : DataEntity | undefined = await dataRepository.findById(dataEntityId2); + expect(entity2).not.toBeDefined(); + + let entity3 : DataEntity | undefined = await dataRepository.findById(dataEntityId3); + expect(entity3).not.toBeDefined(); + + let entity4 : DataEntity | undefined = await dataRepository.findById(dataEntityId4); + expect(entity4).toBeDefined(); + + }); + + }); + + describe('#deleteAllById', () => { + + it('can delete all entities by id', async () => { + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteAllById( [dataEntityId2] ); + expect( await dataRepository.count() ).toBe(3); + }); + + it('can delete all entities by few ids', async () => { + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteAllById( + [ + dataEntityId2, + dataEntityId3 + ] + ); + expect( await dataRepository.count() ).toBe(2); + + let entity1 : DataEntity | undefined = await dataRepository.findById(dataEntityId1); + expect(entity1).toBeDefined(); + + let entity2 : DataEntity | undefined = await dataRepository.findById(dataEntityId2); + expect(entity2).not.toBeDefined(); + + let entity3 : DataEntity | undefined = await dataRepository.findById(dataEntityId3); + expect(entity3).not.toBeDefined(); + + let entity4 : DataEntity | undefined = await dataRepository.findById(dataEntityId4); + expect(entity4).toBeDefined(); + + }); + + }); + + describe('#deleteAllByDataJson', () => { + + it('can delete all properties by dataJson', async () => { + await dataRepository.deleteAllByDataJson(jsonDataEntity2); + const entity : DataEntity | undefined = await dataRepository.findByDataJson(jsonDataEntity2); + expect(entity).not.toBeDefined(); + }); + + }); + + }); + + // TODO: Skipped: We don't have reliable solution to implement range query for the whole JSON object or array. + describe.skip('Between', () => { + + describe('#findAllByDataJsonBetween', () => { + + it('can find all entities between values by date unordered', async () => { + const items = await dataRepository.findAllByDataJsonBetween( { name: '1' }, { name: '2' }); + expect(items).toHaveLength(2); + + const item2 = find(items, (item) => item.dataId === dataEntityId2); + const item3 = find(items, (item) => item.dataId === dataEntityId3); + + expect(item2).toBeDefined(); + expect(item3).toBeDefined(); + + expect(item2?.dataId).toBe(dataEntityId2); + expect(item2?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(item3?.dataId).toBe(dataEntityId3); + expect(item3?.dataJson).toStrictEqual(jsonDataEntity3); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities by dataId in asc order', async () => { + const items = await dataRepository.findAllByDataJsonBetween(jsonDataEntity1, jsonDataEntity3, Sort.by('dataJson.name')); + expect(items).toHaveLength(2); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + expect(items[1]?.dataId).toBe(dataEntityId3); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities by dataId in desc order', async () => { + const items = await dataRepository.findAllByDataJsonBetween(jsonDataEntity2, jsonDataEntity3, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toHaveLength(2); + expect(items[0]?.dataId).toBe(dataEntityId3); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity3); + expect(items[1]?.dataId).toBe(dataEntityId2); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + }); + + describe('#findByDataJsonBetween', () => { + + it('can find an entity between times in unsorted order', async () => { + const item = await dataRepository.findByDataJsonBetween(jsonDataBetween3And4, jsonDataAfter4); + expect(item?.dataId).toBe(dataEntityId4); + expect(item?.dataJson).toStrictEqual(jsonDataEntity4); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity between times in asc order', async () => { + const item = await dataRepository.findByDataJsonBetween(jsonDataBefore1, jsonDataBetween1And2, Sort.by('dataJson.name')); + expect(item?.dataId).toBe(dataEntityId1); + expect(item?.dataJson).toStrictEqual(jsonDataEntity1); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity between times in desc order', async () => { + const item = await dataRepository.findByDataJsonBetween(jsonDataBetween1And2, jsonDataBetween2And3, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + }); + + describe('#deleteAllByDataJsonBetween', () => { + + it('can delete all entities by barDate between range', async () => { + await dataRepository.deleteAllByDataJsonBetween(jsonDataBefore1, jsonDataBetween3And4); + const item = await dataRepository.count(); + expect(item).toBe(1); + }); + + }); + + describe('#existsByDataJsonBetween', () => { + + it('can find if entities exist between range', async () => { + expect( await dataRepository.existsByDataJsonBetween(jsonDataBetween1And2, jsonDataBetween3And4) ).toBe(true); + await dataRepository.deleteAllByDataJsonBetween(jsonDataEntity2, jsonDataEntity3); + expect( await dataRepository.existsByDataJsonBetween(jsonDataBetween1And2, jsonDataBetween3And4) ).toBe(false); + }); + + }); + + describe('#countByDataJsonBetween', () => { + + it('can count entities by dataId', async () => { + expect( await dataRepository.countByDataJsonBetween(jsonDataBetween1And2, jsonDataAfter4) ).toBe(3); + await dataRepository.deleteAllByDataJsonBetween(jsonDataBetween1And2, jsonDataAfter4); + expect( await dataRepository.countByDataJsonBetween(jsonDataBefore1, jsonDataBetween3And4) ).toBe(1); + }); + + }); + + }); + + // TODO: Skipped: We don't have reliable solution to implement before query for the whole JSON object or array. + describe.skip('Before', () => { + + describe('#findAllByDataJsonBefore', () => { + + it('can find all entities before time, unordered', async () => { + + // Matches 1 and 2 ...OR... 2 and 1 (because unordered) + const items = await dataRepository.findAllByDataJsonBefore(jsonDataBetween2And3); + expect(items).toHaveLength(2); + + const item1 = find(items, (item) => item.dataId === dataEntityId1); + const item2 = find(items, (item) => item.dataId === dataEntityId2); + + expect(item1).toBeDefined(); + expect(item2).toBeDefined(); + + expect(item1?.dataId).toBe(dataEntityId1); + expect(item1?.dataJson).toStrictEqual(jsonDataEntity1); + + expect(item2?.dataId).toBe(dataEntityId2); + expect(item2?.dataJson).toStrictEqual(jsonDataEntity2); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities before time, in asc order', async () => { + // Matches 1 and 2, in asc order + const items = await dataRepository.findAllByDataJsonBefore(jsonDataBetween2And3, Sort.by('dataJson.name')); + expect(items).toHaveLength(2); + + expect(items[0]?.dataId).toBe(dataEntityId1); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity1); + + expect(items[1]?.dataId).toBe(dataEntityId2); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity2); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities before time, in desc order', async () => { + + // Matches 2 and 1, in desc order + const items = await dataRepository.findAllByDataJsonBefore(jsonDataBetween2And3, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toHaveLength(2); + + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(items[1]?.dataId).toBe(dataEntityId1); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity1); + }); + + }); + + describe('#findByDataJsonBefore', () => { + + it('cannot find an entity before there was any', async () => { + const item = await dataRepository.findByDataJsonBefore(jsonDataBefore1); + expect(item).toBeUndefined(); + }); + + it('can find an entity before time in unsorted order', async () => { + + // Matches entity 1 + const item = await dataRepository.findByDataJsonBefore(jsonDataBetween1And2); + expect(item?.dataId).toBe(dataEntityId1); + expect(item?.dataJson).toStrictEqual(jsonDataEntity1); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity before time in asc order', async () => { + // Matches entity 1 + const item = await dataRepository.findByDataJsonBefore(jsonDataBetween3And4, Sort.by('dataJson.name')); + expect(item?.dataId).toBe(dataEntityId1); + expect(item?.dataJson).toStrictEqual(jsonDataEntity1); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity before time in desc order', async () => { + // Matches entity 3 + const item = await dataRepository.findByDataJsonBefore(jsonDataBetween3And4, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(item?.dataId).toBe(dataEntityId3); + expect(item?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + }); + + describe('#deleteAllByDataJsonBefore', () => { + + it('can delete all entities before time', async () => { + + // Deletes entities 1, 2 and 3 + await dataRepository.deleteAllByDataJsonBefore(jsonDataBetween3And4); + + // Matches entity 4 + expect( await dataRepository.count() ).toBe(1); + + }); + + }); + + describe('#existsByDataJsonBefore', () => { + + it('can find if entities exist before time', async () => { + expect( await dataRepository.existsByDataJsonBefore(jsonDataBetween1And2) ).toBe(true); + await dataRepository.deleteAllByDataJsonBefore(jsonDataEntity2); + expect( await dataRepository.existsByDataJsonBefore(jsonDataBetween1And2) ).toBe(false); + }); + + }); + + describe('#countByDataJsonBefore', () => { + + it('can count entities before time', async () => { + + // Matches entities 1, 2 and 3 + expect( await dataRepository.countByDataJsonBefore(jsonDataBetween3And4) ).toBe(3); + + // Deletes entity 1 + await dataRepository.deleteAllByDataJsonBefore(jsonDataBetween1And2); + + // Matches entities 2 and 3 + expect( await dataRepository.countByDataJsonBefore(jsonDataBetween3And4) ).toBe(2); + + }); + + }); + + }); + + // TODO: Skipped: We don't have reliable solution to implement after query for the whole JSON object or array. + describe.skip('After', () => { + + describe('#findAllByDataJsonAfter', () => { + + it('can find all entities after time, unordered', async () => { + + // Finds entities 3 and 4 ... OR .. 4 and 3 + const items = await dataRepository.findAllByDataJsonAfter(jsonDataBetween2And3); + expect(items).toHaveLength(2); + + const item3 = find(items, (item) => item.dataId === dataEntityId3); + const item4 = find(items, (item) => item.dataId === dataEntityId4); + + expect(item3).toBeDefined(); + expect(item4).toBeDefined(); + + expect(item4?.dataId).toBe(dataEntityId4); + expect(item4?.dataJson).toStrictEqual(jsonDataEntity4); + + expect(item3?.dataId).toBe(dataEntityId3); + expect(item3?.dataJson).toStrictEqual(jsonDataEntity3); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities after time in asc order', async () => { + + // Finds entities 3 and 4 in asc order + const items = await dataRepository.findAllByDataJsonAfter(jsonDataBetween2And3, Sort.by('dataJson.name')); + expect(items).toHaveLength(2); + expect(items[0]?.dataId).toBe(dataEntityId3); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity3); + expect(items[1]?.dataId).toBe(dataEntityId4); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity4); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities after time in desc order', async () => { + // Finds entities 4 and 3 + const items = await dataRepository.findAllByDataJsonAfter(jsonDataBetween2And3, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toHaveLength(2); + expect(items[0]?.dataId).toBe(dataEntityId4); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity4); + expect(items[1]?.dataId).toBe(dataEntityId3); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + }); + + describe('#findByDataJsonAfter', () => { + + it('can find an entity after time in unsorted order', async () => { + // Matches 4 + const item = await dataRepository.findByDataJsonAfter(jsonDataBetween3And4); + expect(item?.dataId).toBe(dataEntityId4); + expect(item?.dataJson).toStrictEqual(jsonDataEntity4); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity after time in asc order', async () => { + // Matches entities 2, 3 and 4, in asc order 2 will be first + const item = await dataRepository.findByDataJsonAfter(jsonDataBetween1And2, Sort.by('dataJson.name')); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity after time in DESC order', async () => { + // Matches entities 3 and 4, in desc order 4 fill be first one + const item = await dataRepository.findByDataJsonAfter(jsonDataBetween1And2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(item?.dataId).toBe(dataEntityId4); + expect(item?.dataJson).toStrictEqual(jsonDataEntity4); + }); + + }); + + describe('#deleteAllByDataJsonAfter', () => { + + it('can delete all entities after barDate', async () => { + await dataRepository.deleteAllByDataJsonAfter(jsonDataBetween3And4); // Deletes item 4 + const item = await dataRepository.count(); + expect(item).toBe(3); + }); + + }); + + describe('#existsByDataJsonAfter', () => { + + it('can find if entities exist after barDate', async () => { + expect( await dataRepository.existsByDataJsonAfter(jsonDataBetween1And2) ).toBe(true); + await dataRepository.deleteAllByDataJsonBetween(jsonDataBetween1And2, jsonDataAfter4); // Deletes items 2, 3 and 4 + expect( await dataRepository.existsByDataJsonAfter(jsonDataBetween1And2) ).toBe(false); + }); + + }); + + describe('#countByDataJsonAfter', () => { + + it('can count entities after barDate', async () => { + expect( await dataRepository.countByDataJsonAfter(jsonDataBetween1And2) ).toBe(3); + await dataRepository.deleteAllByDataJsonAfter(jsonDataBetween1And2); + expect( await dataRepository.countByDataJsonAfter(jsonDataBefore1) ).toBe(1); + }); + + }); + + }); + +}; diff --git a/data/tests/typeNativeJsonTests.ts b/data/tests/typeNativeJsonTests.ts new file mode 100644 index 0000000..814fb05 --- /dev/null +++ b/data/tests/typeNativeJsonTests.ts @@ -0,0 +1,1394 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../../testing/jest/matchers/index"; +import { find } from "../../functions/find"; +import { Repository } from "../types/Repository"; +import { RepositoryTestContext } from "./types/types/RepositoryTestContext"; +import { Persister } from "../types/Persister"; +import { createCrudRepositoryWithPersister } from "../types/CrudRepository"; +import { Sort } from "../Sort"; +import { Table } from "../Table"; +import { Entity } from "../Entity"; +import { Id } from "../Id"; +import { Column } from "../Column"; +import { ReadonlyJsonObject } from "../../Json"; +import { isDeepStrictEqual } from "util"; +import { PersisterType } from "../persisters/types/PersisterType"; + +export const typeNativeJsonTests = (context : RepositoryTestContext) : void => { + + let persister : Persister; + const persisterType = context.getPersisterType(); + const isMemory = persisterType === PersisterType.MEMORY; + const isPg = persisterType === PersisterType.POSTGRESQL; + const isMySql = persisterType === PersisterType.MYSQL; + + interface MyJsonData extends ReadonlyJsonObject { + readonly name : string; + } + + /** + * Test json entities + */ + @Table('type_test_jsonb_data') + class DataEntity extends Entity { + + constructor (dto ?: {dataJson: MyJsonData}) { + super() + this.dataJson = dto?.dataJson; + } + + @Id() + @Column('data_id', 'BIGINT') + public dataId ?: string; + + @Column('data_json', 'JSONB') + public dataJson ?: MyJsonData; + + } + + interface DataRepository extends Repository { + + findAllByDataJson (ids: readonly string[] | string, sort?: Sort) : Promise; + findByDataJson (id: string, sort?: Sort): Promise; + deleteAllByDataJson (id: string): Promise; + existsByDataJson (id : string): Promise; + countByDataJson (id : string) : Promise; + + findAllByDataJson(name: MyJsonData, sort?: Sort) : Promise; + findByDataJson (name: MyJsonData, sort?: Sort): Promise; + deleteAllByDataJson (name: MyJsonData): Promise; + existsByDataJson (name : MyJsonData): Promise; + countByDataJson (name: MyJsonData) : Promise; + + findAllByDataJsonBetween (start: MyJsonData, end: MyJsonData, sort?: Sort) : Promise; + findByDataJsonBetween (start: MyJsonData, end: MyJsonData, sort?: Sort): Promise; + deleteAllByDataJsonBetween (start: MyJsonData, end: MyJsonData): Promise; + existsByDataJsonBetween (start: MyJsonData, end: MyJsonData): Promise; + countByDataJsonBetween (start: MyJsonData, end: MyJsonData) : Promise; + + findAllByDataJsonBefore (value: MyJsonData, sort?: Sort) : Promise; + findByDataJsonBefore (value: MyJsonData, sort?: Sort): Promise; + deleteAllByDataJsonBefore (value: MyJsonData): Promise; + existsByDataJsonBefore (value: MyJsonData): Promise; + countByDataJsonBefore (value: MyJsonData) : Promise; + + findAllByDataJsonAfter (value: MyJsonData, sort?: Sort) : Promise; + findByDataJsonAfter (value: MyJsonData, sort?: Sort): Promise; + deleteAllByDataJsonAfter (value: MyJsonData): Promise; + existsByDataJsonAfter (value: MyJsonData): Promise; + countByDataJsonAfter (value: MyJsonData) : Promise; + + } + + /** + * This repository will have four items + */ + let dataRepository : DataRepository; + let dataEntity1 : DataEntity; + let dataEntity2 : DataEntity; + let dataEntity3 : DataEntity; + let dataEntity4 : DataEntity; + let dataEntityId1 : string; + let dataEntityId2 : string; + let dataEntityId3 : string; + let dataEntityId4 : string; + + // Entity 5 is duplicate of json in dataEntityId1. This is not initialized by default + let dataEntity5 : DataEntity; + let dataEntityId5 : string; + + const jsonDataNameBefore1 : string = 'Bar 1'; + const jsonDataName1 : string = 'Bar 12'; + const jsonDataNameBetween1And2 : string = 'Bar 124'; + const jsonDataName2 : string = 'Bar 456'; + const jsonDataNameBetween2And3 : string = 'Bar 567'; + const jsonDataName3 : string = 'Bar 789'; + const jsonDataNameBetween3And4 : string = 'Bar 900'; + const jsonDataName4 : string = 'Bar 1200'; + const jsonDataNameAfter4 : string = 'Bar 2900'; + + const jsonDataBefore1 : MyJsonData = { name: jsonDataNameBefore1 }; + const jsonDataEntity1 : MyJsonData = { name: jsonDataName1 }; + const jsonDataBetween1And2 : MyJsonData = { name: jsonDataNameBetween1And2 }; + const jsonDataEntity2 : MyJsonData = { name: jsonDataName2 }; + const jsonDataBetween2And3 : MyJsonData = { name: jsonDataNameBetween2And3 }; + const jsonDataEntity3 : MyJsonData = { name: jsonDataName3 }; + const jsonDataBetween3And4 : MyJsonData = { name: jsonDataNameBetween3And4 }; + const jsonDataEntity4 : MyJsonData = { name: jsonDataName4 }; + const jsonDataAfter4 : MyJsonData = { name: jsonDataNameAfter4 }; + + // Entity 5 Must be same as dataEntityId1, but not initialized by default + const jsonDataName5 : string = jsonDataName1; + const jsonDataEntity5 : MyJsonData = { name: jsonDataName5 }; + + beforeEach( async () => { + + persister = context.getPersister(); + + // LOG.info(`Persister: ${persisterType}`); + + // Will be initialized with four entities + dataRepository = createCrudRepositoryWithPersister( + new DataEntity(), + persister + ); + await dataRepository.deleteAll(); + + dataEntity1 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity1}), + ); + + dataEntityId1 = dataEntity1?.dataId as string; + if (!dataEntityId1) throw new TypeError('barEntity1 failed to initialize'); + if (!isDeepStrictEqual(dataEntity1.dataJson, jsonDataEntity1)) throw new TypeError(`barEntity1 data did not initialize correctly: ${dataEntity1.dataJson}`); + + dataEntity2 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity2}), + ); + dataEntityId2 = dataEntity2?.dataId as string; + if (!dataEntityId2) throw new TypeError('barEntity2 failed to initialize'); + if (dataEntityId1 === dataEntityId2) throw new TypeError(`barEntity2 failed to initialize (not unique ID with barEntityId1 and barEntityId2): ${dataEntityId1}`); + if (!isDeepStrictEqual(dataEntity2.dataJson, jsonDataEntity2)) throw new TypeError(`barEntity2 data did not initialize correctly: ${dataEntity2.dataJson}`); + + dataEntity3 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity3}), + ); + dataEntityId3 = dataEntity3?.dataId as string; + if (!dataEntityId3) throw new TypeError('barEntity3 failed to initialize'); + if (dataEntityId1 === dataEntityId3) throw new TypeError(`barEntityId3 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId3) throw new TypeError(`barEntityId3 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (!isDeepStrictEqual(dataEntity3.dataJson, jsonDataEntity3)) throw new TypeError(`barEntity3 data did not initialize correctly: ${dataEntity3.dataJson}`); + + dataEntity4 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity4}), + ); + dataEntityId4 = dataEntity4?.dataId as string; + if (!dataEntityId4) throw new TypeError('barEntity4 failed to initialize'); + if (dataEntityId1 === dataEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (dataEntityId3 === dataEntityId4) throw new TypeError(`barEntityId4 failed to initialize (not unique ID with entity 3): ${dataEntityId3}`); + if (!isDeepStrictEqual(dataEntity4.dataJson, jsonDataEntity4)) throw new TypeError(`barEntity4 data did not initialize correctly: ${dataEntity4.dataJson}`); + + }); + + describe('Create', () => { + + describe('#save', () => { + + it('can save fresh entity', async () => { + + expect( await dataRepository.count() ).toBe(4); + + const newEntity = new DataEntity({dataJson: { name: 'Hello world' }}); + + const savedItem = await dataRepository.save(newEntity); + expect(savedItem).toBeDefined(); + expect(savedItem.dataId).toBeDefined(); + expect(savedItem.dataJson).toStrictEqual( { name: 'Hello world' }); + + const addedId : string = savedItem?.dataId as string; + + expect( await dataRepository.count() ).toBe(5); + + const foundItem = await dataRepository.findById(addedId); + expect(foundItem).toBeDefined(); + expect(foundItem?.dataId).toBe(addedId); + expect(foundItem?.dataJson).toStrictEqual( { name: 'Hello world' }); + + }); + + }); + + describe('#saveAll', () => { + + it('can save multiple fresh entities', async () => { + + expect( await dataRepository.count() ).toBe(4); + + const newEntity1 = new DataEntity({dataJson: { name: 'Hello world 1' }}); + const newEntity2 = new DataEntity({dataJson: { name: 'Hello world 2' }}); + + const savedItems = await dataRepository.saveAll([newEntity1, newEntity2]); + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(2); + + expect(savedItems[0]?.dataId).toBeDefined(); + expect(savedItems[0]?.dataJson).toStrictEqual( { name: 'Hello world 1' }); + + expect(savedItems[1]?.dataId).toBeDefined(); + expect(savedItems[1]?.dataJson).toStrictEqual( { name: 'Hello world 2' }); + + const addedId1 : string = savedItems[0]?.dataId as string; + const addedId2 : string = savedItems[1]?.dataId as string; + + expect( await dataRepository.count() ).toBe(6); + + const foundItem1 = await dataRepository.findById(addedId1); + expect(foundItem1).toBeDefined(); + expect(foundItem1?.dataId).toBe(addedId1); + expect(foundItem1?.dataJson).toStrictEqual( {name: 'Hello world 1' }); + + const foundItem2 = await dataRepository.findById(addedId2); + expect(foundItem2).toBeDefined(); + expect(foundItem2?.dataId).toBe(addedId2); + expect(foundItem2?.dataJson).toStrictEqual( { name: 'Hello world 2' }); + + }); + + }); + + }); + + describe('Read', () => { + + describe('#count', () => { + + it('can count entities', async () => { + expect( await dataRepository.count() ).toBe(4); + }); + + }); + + describe('#countByDataJson', () => { + + it('can count entities by dataJson', async () => { + expect( await dataRepository.countByDataJson(jsonDataEntity2) ).toBe(1); + await dataRepository.deleteAllByDataJson(jsonDataEntity2); + expect( await dataRepository.countByDataJson(jsonDataEntity2) ).toBe(0); + }); + + }); + + describe('#existsByDataJson', () => { + + it('can find if entity exists by dataJson', async () => { + expect( await dataRepository.existsByDataJson(jsonDataEntity2) ).toBe(true); + await dataRepository.deleteAllByDataJson(jsonDataEntity2); + expect( await dataRepository.existsByDataJson(jsonDataEntity2) ).toBe(false); + }); + + }); + + describe('#existsById', () => { + + it('can find if entity exists', async () => { + expect( await dataRepository.existsById( dataEntityId2 ) ).toBe(true); + await dataRepository.deleteAllById( [dataEntityId2] ); + expect( await dataRepository.existsById( dataEntityId2 ) ).toBe(false); + }); + + }); + + + describe('#findAll', () => { + + it('can find all entities unsorted', async () => { + const items = await dataRepository.findAll(); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + // Order may be different + const item1 = find(items, (item) => item.dataId === dataEntityId1); + const item2 = find(items, (item) => item.dataId === dataEntityId2); + const item3 = find(items, (item) => item.dataId === dataEntityId3); + + expect(item1).toBeDefined(); + expect(item1?.dataId).toBe(dataEntityId1); + expect(item1?.dataJson).toStrictEqual(jsonDataEntity1); + + expect(item2).toBeDefined(); + expect(item2?.dataId).toBe(dataEntityId2); + expect(item2?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(item3).toBeDefined(); + expect(item3?.dataId).toBe(dataEntityId3); + expect(item3?.dataJson).toStrictEqual(jsonDataEntity3); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (sorting not implemented) + it.skip('can find all entities sorted by name and id in ascending order', async () => { + + const items = await dataRepository.findAll( Sort.by('dataJson.name', 'dataId') ); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId1); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity1); + + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId4); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity4); + + expect(items[2]).toBeDefined(); + expect(items[2]?.dataId).toBe(dataEntityId2); + expect(items[2]?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(items[3]).toBeDefined(); + expect(items[3]?.dataId).toBe(dataEntityId3); + expect(items[3]?.dataJson).toStrictEqual(jsonDataEntity3); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (sorting not implemented) + it.skip('can find all entities sorted by name and id in desc order', async () => { + + const items = await dataRepository.findAll( Sort.by(Sort.Direction.DESC, 'dataJson.name', 'dataId') ); + expect(items).toBeArray(); + expect(items?.length).toBe(4); + + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId3); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity3); + + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId2); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(items[2]).toBeDefined(); + expect(items[2]?.dataId).toBe(dataEntityId4); + expect(items[2]?.dataJson).toStrictEqual(jsonDataEntity4); + + expect(items[3]).toBeDefined(); + expect(items[3]?.dataId).toBe(dataEntityId1); + expect(items[3]?.dataJson).toStrictEqual(jsonDataEntity1); + + }); + + }); + + describe('#findAllByDataJson', () => { + + it('can fetch single entity by dataJson property unsorted', async () => { + const items = await dataRepository.findAllByDataJson(jsonDataEntity2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // TODO: We don't have implementation for matching JSON child + // properties in SQL persisters. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + // + (isMemory ? it : it.skip)('can fetch single entity by dataJson property in asc order', async () => { + const items = await dataRepository.findAllByDataJson(jsonDataEntity2, Sort.by('dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // TODO: We don't have implementation for matching JSON child + // properties in SQL persisters. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + // + (isMemory ? it : it.skip)('can fetch single entity by dataJson property in desc order', async () => { + const items = await dataRepository.findAllByDataJson(jsonDataEntity2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + it('can fetch multiple entities by dataJson property unsorted', async () => { + + // Initialize as same as entity 1 + dataEntity5 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity5}), + ); + dataEntityId5 = dataEntity5?.dataId as string; + if (!dataEntityId5) throw new TypeError('barEntity5 failed to initialize'); + if (dataEntityId1 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (dataEntityId3 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 3): ${dataEntityId3}`); + if (!isDeepStrictEqual(dataEntity5?.dataJson, jsonDataEntity5)) throw new TypeError(`barEntity5 data did not initialize correctly: ${dataEntity5.dataJson}`); + + const items = await dataRepository.findAllByDataJson(jsonDataEntity1); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + + const item1 = find(items, (item) => item.dataId === dataEntity1.dataId); + const item5 = find(items, (item) => item.dataId === dataEntity5.dataId); + + expect(item1).toBeDefined(); + expect(item5).toBeDefined(); + expect(item1?.dataId).toBe(dataEntityId1); + expect(item1?.dataJson).toStrictEqual(jsonDataEntity1); + expect(item5?.dataId).toBe(dataEntityId5); + expect(item5?.dataJson).toStrictEqual(jsonDataEntity5); + + }); + + // TODO: We don't have reliable implementation to sorting + // query for all persisters right now. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Passed + // - MySQL: Failed + // + (isMySql ? it.skip : it)('can fetch multiple entities by dataId property in asc order', async () => { + + // Initialize as same as entity 1 + dataEntity5 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity5}), + ); + dataEntityId5 = dataEntity5?.dataId as string; + if (!dataEntityId5) throw new TypeError('barEntity5 failed to initialize'); + if (dataEntityId1 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (dataEntityId3 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 3): ${dataEntityId3}`); + if (!isDeepStrictEqual(dataEntity5?.dataJson, jsonDataEntity5)) throw new TypeError(`barEntity5 data did not initialize correctly: ${dataEntity5.dataJson}`); + + const items = await dataRepository.findAllByDataJson(jsonDataEntity1, Sort.by('dataJson')); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId1); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity1); + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId5); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity5); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + // + (isMemory ? it : it.skip)('can fetch multiple entities by dataId property in desc order', async () => { + + // Initialize as same as entity 1 + dataEntity5 = await persister.insert( + new DataEntity().getMetadata(), + new DataEntity({dataJson: jsonDataEntity5}), + ); + dataEntityId5 = dataEntity5?.dataId as string; + if (!dataEntityId5) throw new TypeError('barEntity5 failed to initialize'); + if (dataEntityId1 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 1): ${dataEntityId1}`); + if (dataEntityId2 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 2): ${dataEntityId2}`); + if (dataEntityId3 === dataEntityId5) throw new TypeError(`barEntityId5 failed to initialize (not unique ID with entity 3): ${dataEntityId3}`); + if (!isDeepStrictEqual(dataEntity5?.dataJson, jsonDataEntity5)) throw new TypeError(`barEntity5 data did not initialize correctly: ${dataEntity5.dataJson}`); + + const items = await dataRepository.findAllByDataJson(jsonDataEntity1, Sort.by(Sort.Direction.DESC,'dataJson')); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId5); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity5); + + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId1); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity1); + }); + + }); + + describe('#findAllById', () => { + + it('can find all entities by id unsorted', async () => { + const items = await dataRepository.findAllById([dataEntityId2, dataEntityId3]); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + expect(items[1]?.dataId).toBe(dataEntityId3); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find all entities by id in ascending order', async () => { + const items = await dataRepository.findAllById([dataEntityId2, dataEntityId3], Sort.by('dataJson.name') ); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId3); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find all entities by id in desc order', async () => { + const items = await dataRepository.findAllById([dataEntityId2, dataEntityId3], Sort.by(Sort.Direction.DESC,'dataJson.name') ); + expect(items).toBeArray(); + expect(items?.length).toBe(2); + expect(items[1]).toBeDefined(); + expect(items[1]?.dataId).toBe(dataEntityId2); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity2); + expect(items[0]).toBeDefined(); + expect(items[0]?.dataId).toBe(dataEntityId3); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + }); + + + describe('#find', () => { + + it('can find entities by property unsorted', async () => { + const items = await dataRepository.find("dataJson", jsonDataEntity2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find entities by property in asc order', async () => { + const items = await dataRepository.find("dataJson", jsonDataEntity2, Sort.by('dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find entities by property in desc order', async () => { + const items = await dataRepository.find("dataJson", jsonDataEntity2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find entities by child property unsorted', async () => { + const items = await dataRepository.find("dataJson.name", jsonDataName2); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson?.name).toStrictEqual(jsonDataName2); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find entities by child property in asc order', async () => { + const items = await dataRepository.find("dataJson.name", jsonDataName2, Sort.by('dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson?.name).toStrictEqual(jsonDataName2); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find entities by child property in desc order', async () => { + const items = await dataRepository.find("dataJson.name", jsonDataName2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toBeArray(); + expect(items?.length).toBe(1); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson?.name).toStrictEqual(jsonDataName2); + }); + + }); + + describe('#findByDataJson', () => { + + it('can find entity by dataJson property unsorted', async () => { + const entity : DataEntity | undefined = await dataRepository.findByDataJson(jsonDataEntity2); + expect(entity).toBeDefined(); + expect(entity?.dataId).toBe(dataEntityId2); + expect(entity?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find entity by dataJson property in asc order', async () => { + const entity : DataEntity | undefined = await dataRepository.findByDataJson(jsonDataEntity2, Sort.by('dataJson.name')); + expect(entity).toBeDefined(); + expect(entity?.dataId).toBe(dataEntityId2); + expect(entity?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Failed + // - MySQL: Failed + (isMemory ? it : it.skip)('can find entity by dataJson property in desc order', async () => { + const entity : DataEntity | undefined = await dataRepository.findByDataJson(jsonDataEntity2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(entity).toBeDefined(); + expect(entity?.dataId).toBe(dataEntityId2); + expect(entity?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + }); + + describe('#findById', () => { + + it('can find entity by id unsorted', async () => { + const item = await dataRepository.findById(dataEntityId2); + expect(item).toBeDefined(); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find entity by id by asc order', async () => { + const item = await dataRepository.findById(dataEntityId2, Sort.by('dataJson.name')); + expect(item).toBeDefined(); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find entity by id by desc order', async () => { + const item = await dataRepository.findById(dataEntityId2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(item).toBeDefined(); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + }); + + }); + + describe('Update', () => { + + describe('#save', () => { + + it('can save older entity', async () => { + + expect( await dataRepository.count() ).toBe(4); + + dataEntity2.dataJson = { + ...(dataEntity2.dataJson ? dataEntity2.dataJson : {}), + name: 'Hello world' + }; + + const savedItem = await dataRepository.save(dataEntity2); + expect(savedItem).toBeDefined(); + expect(savedItem.dataId).toBe(dataEntityId2); + expect(savedItem?.dataJson).toStrictEqual( { name: 'Hello world' }); + + expect( await dataRepository.count() ).toBe(4); + + const foundItem = await dataRepository.findById(dataEntityId2); + expect(foundItem).toBeDefined(); + expect(foundItem?.dataId).toBe(dataEntityId2); + expect(foundItem?.dataJson).toStrictEqual( { name: 'Hello world' }); + + }); + + }); + + describe('#saveAll', () => { + + it('can save multiple older entities', async () => { + + expect( await dataRepository.count() ).toBe(4); + + dataEntity2.dataJson = { name : 'Hello world 1' }; + dataEntity3.dataJson = { name : 'Hello world 2' }; + + const savedItems = await dataRepository.saveAll([dataEntity2, dataEntity3]); + expect(savedItems).toBeArray(); + expect(savedItems?.length).toBe(2); + + expect(savedItems[0].dataId).toBe(dataEntityId2); + expect(savedItems[0].dataJson).toStrictEqual( { name: 'Hello world 1' }); + + expect(savedItems[1].dataId).toBe(dataEntityId3); + expect(savedItems[1].dataJson).toStrictEqual( { name: 'Hello world 2' }); + + expect( await dataRepository.count() ).toBe(4); + + const foundItem2 = await dataRepository.findById(dataEntityId2); + expect(foundItem2).toBeDefined(); + expect(foundItem2?.dataId).toBe(dataEntityId2); + expect(foundItem2?.dataJson).toStrictEqual( { name: 'Hello world 1' }); + + const foundItem3 = await dataRepository.findById(dataEntityId3); + expect(foundItem3).toBeDefined(); + expect(foundItem3?.dataId).toBe(dataEntityId3); + expect(foundItem3?.dataJson).toStrictEqual( { name: 'Hello world 2' }); + + }); + + }); + + }); + + describe('Delete', () => { + + describe('#delete', () => { + + it('can delete entity by entity object', async () => { + + expect( await dataRepository.count() ).toBe(4); + await dataRepository.delete(dataEntity2); + expect( await dataRepository.count() ).toBe(3); + + let entity : DataEntity | undefined = await dataRepository.findById(dataEntityId2); + expect(entity).not.toBeDefined(); + + }); + + }); + + describe('#deleteById', () => { + + it('can delete entity by id', async () => { + + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteById(dataEntityId2); + expect( await dataRepository.count() ).toBe(3); + + let entity : DataEntity | undefined = await dataRepository.findById(dataEntityId2); + expect(entity).not.toBeDefined(); + + }); + + }); + + describe('#deleteAll', () => { + + it('can delete all entities', async () => { + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteAll(); + expect( await dataRepository.count() ).toBe(0); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Passed + // - MySQL: Failed + (isMySql ? it.skip : it)('can delete few entities with an array of entities', async () => { + + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteAll( + [ + dataEntity2, + dataEntity3 + ] + ); + expect( await dataRepository.count() ).toBe(2); + + let entity1 : DataEntity | undefined = await dataRepository.findById(dataEntityId1); + expect(entity1).toBeDefined(); + + let entity2 : DataEntity | undefined = await dataRepository.findById(dataEntityId2); + expect(entity2).not.toBeDefined(); + + let entity3 : DataEntity | undefined = await dataRepository.findById(dataEntityId3); + expect(entity3).not.toBeDefined(); + + let entity4 : DataEntity | undefined = await dataRepository.findById(dataEntityId4); + expect(entity4).toBeDefined(); + + }); + + }); + + describe('#deleteAllById', () => { + + it('can delete all entities by id', async () => { + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteAllById( [dataEntityId2] ); + expect( await dataRepository.count() ).toBe(3); + }); + + it('can delete all entities by few ids', async () => { + expect( await dataRepository.count() ).toBe(4); + await dataRepository.deleteAllById( + [ + dataEntityId2, + dataEntityId3 + ] + ); + expect( await dataRepository.count() ).toBe(2); + + let entity1 : DataEntity | undefined = await dataRepository.findById(dataEntityId1); + expect(entity1).toBeDefined(); + + let entity2 : DataEntity | undefined = await dataRepository.findById(dataEntityId2); + expect(entity2).not.toBeDefined(); + + let entity3 : DataEntity | undefined = await dataRepository.findById(dataEntityId3); + expect(entity3).not.toBeDefined(); + + let entity4 : DataEntity | undefined = await dataRepository.findById(dataEntityId4); + expect(entity4).toBeDefined(); + + }); + + }); + + describe('#deleteAllByDataJson', () => { + + it('can delete all properties by dataJson', async () => { + await dataRepository.deleteAllByDataJson(jsonDataEntity2); + const entity : DataEntity | undefined = await dataRepository.findByDataJson(jsonDataEntity2); + expect(entity).not.toBeDefined(); + }); + + }); + + }); + + // TODO: Skipped: We don't have reliable solution to implement range query for the whole JSON object or array. + describe('Between', () => { + + describe('#findAllByDataJsonBetween', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: Failed + // - PostgreSQL: Failed + // - MySQL: Failed + it.skip('can find all entities between values by date unordered', async () => { + const items = await dataRepository.findAllByDataJsonBetween( { name: '1' }, { name: '2' }); + expect(items).toHaveLength(2); + + const item2 = find(items, (item) => item.dataId === dataEntityId2); + const item3 = find(items, (item) => item.dataId === dataEntityId3); + + expect(item2).toBeDefined(); + expect(item3).toBeDefined(); + + expect(item2?.dataId).toBe(dataEntityId2); + expect(item2?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(item3?.dataId).toBe(dataEntityId3); + expect(item3?.dataJson).toStrictEqual(jsonDataEntity3); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities by dataId in asc order', async () => { + const items = await dataRepository.findAllByDataJsonBetween(jsonDataEntity1, jsonDataEntity3, Sort.by('dataJson.name')); + expect(items).toHaveLength(2); + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + expect(items[1]?.dataId).toBe(dataEntityId3); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities by dataId in desc order', async () => { + const items = await dataRepository.findAllByDataJsonBetween(jsonDataEntity2, jsonDataEntity3, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toHaveLength(2); + expect(items[0]?.dataId).toBe(dataEntityId3); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity3); + expect(items[1]?.dataId).toBe(dataEntityId2); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + }); + + describe('#findByDataJsonBetween', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: Failed + // - PostgreSQL: Failed + // - MySQL: Failed + it.skip('can find an entity between times in unsorted order', async () => { + const item = await dataRepository.findByDataJsonBetween(jsonDataBetween3And4, jsonDataAfter4); + expect(item?.dataId).toBe(dataEntityId4); + expect(item?.dataJson).toStrictEqual(jsonDataEntity4); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity between times in asc order', async () => { + const item = await dataRepository.findByDataJsonBetween(jsonDataBefore1, jsonDataBetween1And2, Sort.by('dataJson.name')); + expect(item?.dataId).toBe(dataEntityId1); + expect(item?.dataJson).toStrictEqual(jsonDataEntity1); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity between times in desc order', async () => { + const item = await dataRepository.findByDataJsonBetween(jsonDataBetween1And2, jsonDataBetween2And3, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + }); + + describe('#deleteAllByDataJsonBetween', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: Failed + // - PostgreSQL: Failed + // - MySQL: Failed + it.skip('can delete all entities by barDate between range', async () => { + await dataRepository.deleteAllByDataJsonBetween(jsonDataBefore1, jsonDataBetween3And4); + const item = await dataRepository.count(); + expect(item).toBe(1); + }); + + }); + + describe('#existsByDataJsonBetween', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. This could be done + // though. + // . + // Status: + // - Memory: Passed + // - PostgreSQL: Passed + // - MySQL: Failed + (isMySql ? it.skip : it)('can find if entities exist between range', async () => { + expect( await dataRepository.existsByDataJsonBetween(jsonDataBetween1And2, jsonDataBetween3And4) ).toBe(true); + await dataRepository.deleteAllByDataJsonBetween(jsonDataEntity2, jsonDataEntity3); + expect( await dataRepository.existsByDataJsonBetween(jsonDataBetween1And2, jsonDataBetween3And4) ).toBe(false); + }); + + }); + + describe('#countByDataJsonBetween', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: Failed + // - PostgreSQL: Failed + // - MySQL: Failed + it.skip('can count entities by dataId', async () => { + expect( await dataRepository.countByDataJsonBetween(jsonDataBetween1And2, jsonDataAfter4) ).toBe(3); + await dataRepository.deleteAllByDataJsonBetween(jsonDataBetween1And2, jsonDataAfter4); + expect( await dataRepository.countByDataJsonBetween(jsonDataBefore1, jsonDataBetween3And4) ).toBe(1); + }); + + }); + + }); + + describe('Before', () => { + + describe('#findAllByDataJsonBefore', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can find all entities before time, unordered', async () => { + + // Matches 1 and 2 ...OR... 2 and 1 (because unordered) + const items = await dataRepository.findAllByDataJsonBefore(jsonDataBetween2And3); + expect(items).toHaveLength(2); + + const item1 = find(items, (item) => item.dataId === dataEntityId1); + const item2 = find(items, (item) => item.dataId === dataEntityId2); + + expect(item1).toBeDefined(); + expect(item2).toBeDefined(); + + expect(item1?.dataId).toBe(dataEntityId1); + expect(item1?.dataJson).toStrictEqual(jsonDataEntity1); + + expect(item2?.dataId).toBe(dataEntityId2); + expect(item2?.dataJson).toStrictEqual(jsonDataEntity2); + + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can find all entities before time, in asc order', async () => { + // Matches 1 and 2, in asc order + const items = await dataRepository.findAllByDataJsonBefore(jsonDataBetween2And3, Sort.by('dataJson.name')); + expect(items).toHaveLength(2); + + expect(items[0]?.dataId).toBe(dataEntityId1); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity1); + + expect(items[1]?.dataId).toBe(dataEntityId2); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity2); + + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can find all entities before time, in desc order', async () => { + + // Matches 2 and 1, in desc order + const items = await dataRepository.findAllByDataJsonBefore(jsonDataBetween2And3, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toHaveLength(2); + + expect(items[0]?.dataId).toBe(dataEntityId2); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity2); + + expect(items[1]?.dataId).toBe(dataEntityId1); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity1); + }); + + }); + + describe('#findByDataJsonBefore', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Passed + // - MySQL: Failed + (isMySql ? it.skip : it)('cannot find an entity before there was any', async () => { + const item = await dataRepository.findByDataJsonBefore(jsonDataBefore1); + expect(item).toBeUndefined(); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: Failed + // - PostgreSQL: Passed + // - MySQL: Failed + (isPg ? it : it.skip)('can find an entity before time in unsorted order', async () => { + + // Matches entity 1 + const item = await dataRepository.findByDataJsonBefore(jsonDataBetween1And2); + expect(item?.dataId).toBe(dataEntityId1); + expect(item?.dataJson).toStrictEqual(jsonDataEntity1); + + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can find an entity before time in asc order', async () => { + // Matches entity 1 + const item = await dataRepository.findByDataJsonBefore(jsonDataBetween3And4, Sort.by('dataJson.name')); + expect(item?.dataId).toBe(dataEntityId1); + expect(item?.dataJson).toStrictEqual(jsonDataEntity1); + }); + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can find an entity before time in desc order', async () => { + // Matches entity 3 + const item = await dataRepository.findByDataJsonBefore(jsonDataBetween3And4, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(item?.dataId).toBe(dataEntityId3); + expect(item?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + }); + + describe('#deleteAllByDataJsonBefore', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can delete all entities before time', async () => { + + // Deletes entities 1, 2 and 3 + await dataRepository.deleteAllByDataJsonBefore(jsonDataBetween3And4); + + // Matches entity 4 + expect( await dataRepository.count() ).toBe(1); + + }); + + }); + + describe('#existsByDataJsonBefore', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: Failed + // - PostgreSQL: Passed + // - MySQL: Failed + (isPg ? it : it.skip)('can find if entities exist before time', async () => { + expect( await dataRepository.existsByDataJsonBefore(jsonDataBetween1And2) ).toBe(true); + await dataRepository.deleteAllByDataJsonBefore(jsonDataEntity2); + expect( await dataRepository.existsByDataJsonBefore(jsonDataBetween1And2) ).toBe(false); + }); + + }); + + describe('#countByDataJsonBefore', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can count entities before time', async () => { + + // Matches entities 1, 2 and 3 + expect( await dataRepository.countByDataJsonBefore(jsonDataBetween3And4) ).toBe(3); + + // Deletes entity 1 + await dataRepository.deleteAllByDataJsonBefore(jsonDataBetween1And2); + + // Matches entities 2 and 3 + expect( await dataRepository.countByDataJsonBefore(jsonDataBetween3And4) ).toBe(2); + + }); + + }); + + }); + + // TODO: Skipped: Test which persister works + describe('After', () => { + + describe('#findAllByDataJsonAfter', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can find all entities after time, unordered', async () => { + + // Finds entities 3 and 4 ... OR .. 4 and 3 + const items = await dataRepository.findAllByDataJsonAfter(jsonDataBetween2And3); + expect(items).toHaveLength(2); + + const item3 = find(items, (item) => item.dataId === dataEntityId3); + const item4 = find(items, (item) => item.dataId === dataEntityId4); + + expect(item3).toBeDefined(); + expect(item4).toBeDefined(); + + expect(item4?.dataId).toBe(dataEntityId4); + expect(item4?.dataJson).toStrictEqual(jsonDataEntity4); + + expect(item3?.dataId).toBe(dataEntityId3); + expect(item3?.dataJson).toStrictEqual(jsonDataEntity3); + + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities after time in asc order', async () => { + + // Finds entities 3 and 4 in asc order + const items = await dataRepository.findAllByDataJsonAfter(jsonDataBetween2And3, Sort.by('dataJson.name')); + expect(items).toHaveLength(2); + expect(items[0]?.dataId).toBe(dataEntityId3); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity3); + expect(items[1]?.dataId).toBe(dataEntityId4); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity4); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find all entities after time in desc order', async () => { + // Finds entities 4 and 3 + const items = await dataRepository.findAllByDataJsonAfter(jsonDataBetween2And3, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(items).toHaveLength(2); + expect(items[0]?.dataId).toBe(dataEntityId4); + expect(items[0]?.dataJson).toStrictEqual(jsonDataEntity4); + expect(items[1]?.dataId).toBe(dataEntityId3); + expect(items[1]?.dataJson).toStrictEqual(jsonDataEntity3); + }); + + }); + + describe('#findByDataJsonAfter', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can find an entity after time in unsorted order', async () => { + // Matches 4 + const item = await dataRepository.findByDataJsonAfter(jsonDataBetween3And4); + expect(item?.dataId).toBe(dataEntityId4); + expect(item?.dataJson).toStrictEqual(jsonDataEntity4); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity after time in asc order', async () => { + // Matches entities 2, 3 and 4, in asc order 2 will be first + const item = await dataRepository.findByDataJsonAfter(jsonDataBetween1And2, Sort.by('dataJson.name')); + expect(item?.dataId).toBe(dataEntityId2); + expect(item?.dataJson).toStrictEqual(jsonDataEntity2); + }); + + // FIXME: This would be nice, but fails on MySQL and PostgreSQL still (not implemented) + it.skip('can find an entity after time in DESC order', async () => { + // Matches entities 3 and 4, in desc order 4 fill be first one + const item = await dataRepository.findByDataJsonAfter(jsonDataBetween1And2, Sort.by(Sort.Direction.DESC,'dataJson.name')); + expect(item?.dataId).toBe(dataEntityId4); + expect(item?.dataJson).toStrictEqual(jsonDataEntity4); + }); + + }); + + describe('#deleteAllByDataJsonAfter', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can delete all entities after barDate', async () => { + await dataRepository.deleteAllByDataJsonAfter(jsonDataBetween3And4); // Deletes item 4 + const item = await dataRepository.count(); + expect(item).toBe(3); + }); + + }); + + describe('#existsByDataJsonAfter', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can find if entities exist after barDate', async () => { + expect( await dataRepository.existsByDataJsonAfter(jsonDataBetween1And2) ).toBe(true); + await dataRepository.deleteAllByDataJsonBetween(jsonDataBetween1And2, jsonDataAfter4); // Deletes items 2, 3 and 4 + expect( await dataRepository.existsByDataJsonAfter(jsonDataBetween1And2) ).toBe(false); + }); + + }); + + describe('#countByDataJsonAfter', () => { + + // TODO: We don't have reliable implementation to equality + // query for all persisters right now. + // . + // Status: + // - Memory: + // - PostgreSQL: Failed + // - MySQL: + it.skip('can count entities after barDate', async () => { + expect( await dataRepository.countByDataJsonAfter(jsonDataBetween1And2) ).toBe(3); + await dataRepository.deleteAllByDataJsonAfter(jsonDataBetween1And2); + expect( await dataRepository.countByDataJsonAfter(jsonDataBefore1) ).toBe(1); + }); + + }); + + }); + +}; diff --git a/data/tests/types/types/RepositoryTestContext.ts b/data/tests/types/types/RepositoryTestContext.ts new file mode 100644 index 0000000..9482d9e --- /dev/null +++ b/data/tests/types/types/RepositoryTestContext.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Persister } from "../../../types/Persister"; +import { PersisterType } from "../../../persisters/types/PersisterType"; + +export interface RepositoryTestContext { + + persister ?: Persister; + + getPersister() : Persister; + getPersisterType() : PersisterType; + +} + +export function createRepositoryTestContext ( + persisterType : PersisterType, + persister ?: Persister | undefined +) : RepositoryTestContext { + const context = { + persister, + getPersister (): Persister { + let persisterOrNot = context.persister; + if (!persisterOrNot) throw new TypeError(`The persister must be initialized first`); + return persisterOrNot; + }, + getPersisterType (): PersisterType { + return persisterType; + } + }; + return context; +} diff --git a/data/types/ColumnDefinition.ts b/data/types/ColumnDefinition.ts new file mode 100644 index 0000000..a124149 --- /dev/null +++ b/data/types/ColumnDefinition.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; + +export enum ColumnDefinition { + JSON = "JSON", + JSONB = "JSONB", + + BIGINT = "BIGINT", + + TIMESTAMP = "TIMESTAMP", + TIMESTAMPTZ = "TIMESTAMPTZ", + DATE = "DATE", + DATETZ = "DATETZ", + DATETIME = "DATETIME", + DATETIMETZ = "DATETIMETZ", + TIME = "TIME", + TIMETZ = "TIMETZ", + +} + +const JSON_COLUMN_DEFINITIONS : readonly ColumnDefinition[] = [ + ColumnDefinition.JSON, + ColumnDefinition.JSONB +]; + +const TIME_COLUMN_DEFINITIONS : readonly ColumnDefinition[] = [ + ColumnDefinition.TIMESTAMP, + ColumnDefinition.TIMESTAMPTZ, + ColumnDefinition.DATE, + ColumnDefinition.DATETZ, + ColumnDefinition.DATETIME, + ColumnDefinition.DATETIMETZ, + ColumnDefinition.TIME, + ColumnDefinition.TIMETZ, +]; + +export function isJsonColumnDefinition (value: unknown) : value is ColumnDefinition.JSON | ColumnDefinition.JSONB { + return isColumnDefinition(value) && JSON_COLUMN_DEFINITIONS.includes(value); +} + +export function isTimeColumnDefinition (value: unknown) : value is ( + ColumnDefinition.TIMESTAMP + | ColumnDefinition.TIMESTAMPTZ + | ColumnDefinition.DATE + | ColumnDefinition.DATETZ + | ColumnDefinition.DATETIME + | ColumnDefinition.DATETIMETZ + | ColumnDefinition.TIME + | ColumnDefinition.TIMETZ +) { + return isColumnDefinition(value) && TIME_COLUMN_DEFINITIONS.includes(value); +} + +export function isColumnDefinition (value: unknown) : value is ColumnDefinition { + return isEnum(ColumnDefinition, value); +} + +export function explainColumnDefinition (value : unknown) : string { + return explainEnum("ColumnDefinition", ColumnDefinition, isColumnDefinition, value); +} + +export function stringifyColumnDefinition (value : ColumnDefinition) : string { + return stringifyEnum(ColumnDefinition, value); +} + +export function parseColumnDefinition (value: any) : ColumnDefinition | undefined { + return parseEnum(ColumnDefinition, value) as ColumnDefinition | undefined; +} + +export function isColumnDefinitionOrUndefined (value: unknown): value is ColumnDefinition | undefined { + return isUndefined(ColumnDefinition) || isColumnDefinition(value); +} + +export function explainColumnDefinitionOrUndefined (value: unknown): string { + return isColumnDefinitionOrUndefined(value) ? explainOk() : explainNot(explainOr(['ColumnDefinition', 'undefined'])); +} diff --git a/data/types/CrudRepository.test.ts b/data/types/CrudRepository.test.ts new file mode 100644 index 0000000..ed78dfb --- /dev/null +++ b/data/types/CrudRepository.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../../testing/jest/matchers/index"; +import { Repository } from "./Repository"; +import { MockPersister } from "../persisters/mock/MockPersister"; +import { Persister } from "./Persister"; +import { RepositoryUtils } from "../utils/RepositoryUtils"; +import { LogLevel } from "../../types/LogLevel"; +import { CrudRepositoryImpl } from "./CrudRepositoryImpl"; +import { createCrudRepositoryWithPersister, setCrudRepositoryLogLevel } from "./CrudRepository"; +import { Table } from "../Table"; +import { Entity } from "../Entity"; +import { Id } from "../Id"; +import { Column } from "../Column"; + +describe('CrudRepository', () => { + + beforeAll(() => { + RepositoryUtils.setLogLevel(LogLevel.NONE); + setCrudRepositoryLogLevel(LogLevel.NONE); + CrudRepositoryImpl.setLogLevel(LogLevel.NONE); + }); + + describe('createCrudRepositoryWithPersister', () => { + + interface FooRepository extends Repository { + + findAllByName(name: string) : Promise; + findByName (name: string): Promise; + deleteAllByName (ids: string[]): Promise; + existsByName (id : string): Promise; + countByName () : Promise; + + findAllById() : Promise; + findById (name: string): Promise; + deleteAllById (ids: string[]): Promise; + existsById (id : string): Promise; + countById () : Promise; + + } + + let persister : Persister; + let repository : FooRepository; + + @Table('foo') + class FooEntity extends Entity { + constructor (dto ?: {name: string}) { + super() + this.name = dto?.name; + } + + @Id() + @Column('foo_id') + public id ?: string; + + @Column('foo_name') + public name ?: string; + + } + + beforeEach( () => { + persister = new MockPersister(); + repository = createCrudRepositoryWithPersister( + new FooEntity(), + persister + ); + }); + + it('can implement findAllByName() method', () => { + expect(repository?.findAllByName).toBeFunction(); + }); + + it('can implement findByName() method', () => { + expect(repository?.findByName).toBeFunction(); + }); + + it('can implement deleteAllByName() method', () => { + expect(repository?.deleteAllByName).toBeFunction(); + }); + + it('can implement existsByName() method', () => { + expect(repository?.existsByName).toBeFunction(); + }); + + it('can implement countByName() method', () => { + expect(repository?.countByName).toBeFunction(); + }); + + it('can implement findAllById() method', () => { + expect(repository?.findAllById).toBeFunction(); + }); + + it('can implement findById() method', () => { + expect(repository?.findById).toBeFunction(); + }); + + it('can implement deleteAllById() method', () => { + expect(repository?.deleteAllById).toBeFunction(); + }); + + it('can implement existsById() method', () => { + expect(repository?.existsById).toBeFunction(); + }); + + it('can implement countById() method', () => { + expect(repository?.countById).toBeFunction(); + }); + + }); + +}); diff --git a/data/types/CrudRepository.ts b/data/types/CrudRepository.ts new file mode 100644 index 0000000..640c70f --- /dev/null +++ b/data/types/CrudRepository.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +import { Entity, EntityIdTypes } from "../Entity"; +import { Repository } from "./Repository"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { Persister } from "./Persister"; +import { EntityMetadata } from "./EntityMetadata"; +import { CrudRepositoryImpl } from "./CrudRepositoryImpl"; +import { RepositoryUtils } from "../utils/RepositoryUtils"; + +const LOG = LogService.createLogger('CrudRepository'); + +export interface CrudRepository + extends Repository +{ + +} + +export function setCrudRepositoryLogLevel (level: LogLevel) { + LOG.setLogLevel(level); +} + +export function createCrudRepositoryWithPersister< + T extends Entity, + ID extends EntityIdTypes, + RepositoryType extends CrudRepository +> ( + emptyEntity: T, + persister: Persister +): RepositoryType { + const entityMetadata: EntityMetadata = emptyEntity.getMetadata(); + LOG.debug(`entityMetadata = `, entityMetadata); + + persister.setupEntityMetadata(entityMetadata); + + class FinalCrudRepositoryImpl + extends CrudRepositoryImpl { + + constructor ( + persister: Persister + ) { + super(entityMetadata, persister); + } + + } + + const newImpl = new FinalCrudRepositoryImpl(persister); + RepositoryUtils.generateDefaultMethods(FinalCrudRepositoryImpl.prototype, entityMetadata); + return newImpl as unknown as RepositoryType; +} diff --git a/data/types/CrudRepositoryImpl.ts b/data/types/CrudRepositoryImpl.ts new file mode 100644 index 0000000..908992c --- /dev/null +++ b/data/types/CrudRepositoryImpl.ts @@ -0,0 +1,254 @@ +// Copyright (c) 2022-2023 Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { map } from "../../functions/map"; +import { reduce } from "../../functions/reduce"; +import { Entity, EntityIdTypes } from "../Entity"; +import { Persister } from "./Persister"; +import { EntityUtils } from "../utils/EntityUtils"; +import { EntityMetadata } from "./EntityMetadata"; +import { RepositoryEntityError } from "./RepositoryEntityError"; +import { CrudRepository } from "./CrudRepository"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { KeyValuePairs } from "./KeyValuePairs"; +import { isSort, Sort } from "../Sort"; +import { isWhere, Where } from "../Where"; +import { isArray } from "../../types/Array"; + +const LOG = LogService.createLogger('CrudRepositoryImpl'); + +export class CrudRepositoryImpl + implements CrudRepository { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _persister : Persister; + private readonly _entityMetadata : EntityMetadata; + + public constructor ( + emptyMetadata : EntityMetadata, + persister : Persister + ) { + this._entityMetadata = emptyMetadata; + this._persister = persister; + LOG.debug(`constructor: emptyMetadata = `, emptyMetadata); + } + + public async setup () : Promise { + await this._persister.setupEntityMetadata(this._entityMetadata); + } + + /** + * You shouldn't use Persister directly through this API. + * + * This interface is exposed to public access for our own internal implementation, because TypeScript doesn't + * support a concept like "friends" found from other languages. + */ + public __getPersister () : Persister { + return this._persister; + } + + public async delete (entity: T): Promise { + LOG.debug(`delete: entity = `, entity); + const id = EntityUtils.getId(entity, this._entityMetadata); + const propertyName = EntityUtils.getIdPropertyName(this._entityMetadata); + LOG.debug(`delete: id = `, id); + return await this._persister.deleteAll( + this._entityMetadata, + Where.propertyEquals(propertyName, id) + ); + } + + public async findAll ( + arg1 ?: Where | Sort | undefined, + arg2 ?: Sort | Where | undefined + ): Promise { + LOG.debug(`findAll = `, arg1, arg2); + const [where, sort] = this._parseArgs(arg1, arg2); + return await this._persister.findAll( + this._entityMetadata, + where, + sort + ); + } + + public async findAllById ( + ids: any[] | any, + sort ?: Sort + ) : Promise { + LOG.debug(`findAllById = `, ids, sort); + ids = isArray(ids) ? ids : [ids]; + const propertyName = EntityUtils.getIdPropertyName(this._entityMetadata); + return await this._persister.findAll( + this._entityMetadata, + Where.propertyListEquals(propertyName, ids), + sort + ); + } + + public async findById ( + id: any, + sort ?: Sort + ): Promise { + LOG.debug(`findById = `, id); + const propertyName = EntityUtils.getIdPropertyName(this._entityMetadata); + return await this._persister.findBy( + this._entityMetadata, + Where.propertyEquals(propertyName, id), + sort + ); + } + + /** + * @deprecated Use .findAll(Where...) + * @param propertyName + * @param value + * @param sort + */ + public async find ( + propertyName: string, + value: any, + sort ?: Sort + ): Promise { + LOG.debug(`find = `, propertyName, value); + return await this._persister.findAll( + this._entityMetadata, + Where.propertyEquals(propertyName, value), + sort + ); + } + + public async count ( + where ?: Where + ): Promise { + return await this._persister.count(this._entityMetadata, where); + } + + public async existsBy ( + where : Where + ): Promise { + return await this._persister.existsBy(this._entityMetadata, where); + } + + public async deleteAll ( + entities ?: readonly T[] | Where | undefined + ): Promise { + + if (entities === undefined) { + LOG.debug(`deleteAll`); + return await this._persister.deleteAll( + this._entityMetadata, + undefined + ); + } + + if (isWhere(entities)) { + LOG.debug(`deleteAll: where = `, entities); + return await this._persister.deleteAll( + this._entityMetadata, + entities + ); + } + + LOG.debug(`deleteAll: entities = `, entities); + const propertyName = this._entityMetadata.idPropertyName; + const ids = map( + entities, + (item : T) : ID => EntityUtils.getId(item, this._entityMetadata) + ); + LOG.debug(`deleteAll: ids = `, ids); + return await this._persister.deleteAll( + this._entityMetadata, + Where.propertyListEquals( + propertyName, + ids + ), + ); + + } + + public async deleteById (id: ID): Promise { + LOG.debug(`deleteById: id = `, id); + return await this._persister.deleteAll( + this._entityMetadata, + Where.propertyEquals(this._entityMetadata.idPropertyName, id) + ); + } + + public async deleteAllById (ids: readonly ID[] | ID): Promise { + ids = isArray(ids) ? ids : [ids]; + LOG.debug(`deleteAllById: ids = `, ids); + const idPropertyName : string = EntityUtils.getIdPropertyName(this._entityMetadata); + return await this._persister.deleteAll( + this._entityMetadata, + Where.propertyListEquals(idPropertyName, ids) + ); + } + + public async existsById (id: ID): Promise { + LOG.debug(`existsById: id = `, id); + const idPropertyName : string = EntityUtils.getIdPropertyName(this._entityMetadata); + LOG.debug(`existsById: idPropertyName = `, idPropertyName); + return await this._persister.existsBy( + this._entityMetadata, + Where.propertyEquals(idPropertyName, id), + ); + } + + public async saveAll ( + entities: T[] + ): Promise { + LOG.debug(`saveAll: entities = `, entities); + const results : T[] = []; + await reduce( + entities, + async (p : Promise, item : T) => { + await p; + const savedItem = await this.save(item); + results.push(savedItem); + }, + Promise.resolve() + ); + LOG.debug(`saveAll: results = `, results); + return results; + } + + public async save (entity: T): Promise { + LOG.debug(`save: entity = `, entity); + const metadata = this._entityMetadata; + LOG.debug(`save: metadata = `, metadata); + const id = (entity as KeyValuePairs)[metadata.idPropertyName]; + LOG.debug(`save: id = `, id); + if ( !id ) return await this._persister.insert(metadata, entity); + const current = await this.existsById(id); + LOG.debug(`save: current = `, current); + if (!current) { + throw new RepositoryEntityError(id, RepositoryEntityError.Code.ENTITY_NOT_FOUND, `Entity "${id}" not found in table: ${metadata.tableName}`); + } + return await this._persister.update(metadata, entity); + } + + private _parseArgs ( + arg1 ?: Where | Sort | undefined, + arg2 ?: Sort | Where | undefined + ) : [Where | undefined, Sort | undefined] { + if (isSort(arg1)) { + if (isWhere(arg2)) return [arg2, arg1]; + // if (isSort(arg2)) return [undefined, arg1.and(arg2)]; + if (arg2 !== undefined) throw new TypeError(`Argument 2 is unknown type: ${arg2}`); + return [undefined, arg1]; + } else if (isWhere(arg1)) { + if (isSort(arg2)) return [arg1, arg2]; + if (isWhere(arg2)) return [arg1.and(arg2), undefined]; + if (arg2 !== undefined) throw new TypeError(`Argument 2 is unknown type: ${arg2}`); + return [arg1, undefined]; + } if (arg1 === undefined && arg2 === undefined) { + return [undefined, undefined]; + } + throw new TypeError(`Both arguments are unknown type: ${arg1} ${arg2}`); + } + +} diff --git a/data/types/EntityCallback.ts b/data/types/EntityCallback.ts new file mode 100644 index 0000000..8127ce6 --- /dev/null +++ b/data/types/EntityCallback.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainStringOrSymbol, isStringOrSymbol } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; +import { EntityCallbackType, explainEntityCallbackType, isEntityCallbackType } from "./EntityCallbackType"; + +export interface EntityCallback { + readonly propertyName : string | symbol; + readonly callbackType : EntityCallbackType; +} + +export function createEntityCallback ( + propertyName : string | symbol, + callbackType : EntityCallbackType +) : EntityCallback { + return { + propertyName, + callbackType + }; +} + +export function isEntityCallback (value: unknown) : value is EntityCallback { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'propertyName', + 'callbackType', + ]) + && isStringOrSymbol(value?.propertyName) + && isEntityCallbackType(value?.callbackType) + ); +} + +export function explainEntityCallback (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'propertyName', + 'callbackType', + ]) + , explainProperty("propertyName", explainStringOrSymbol(value?.propertyName)) + , explainProperty("callbackType", explainEntityCallbackType(value?.callbackType)) + ] + ); +} + +export function stringifyEntityCallback (value : EntityCallback) : string { + return `EntityCallback(${value})`; +} + +export function parseEntityCallback (value: unknown) : EntityCallback | undefined { + if (isEntityCallback(value)) return value; + return undefined; +} diff --git a/data/types/EntityCallbackType.ts b/data/types/EntityCallbackType.ts new file mode 100644 index 0000000..e8195dc --- /dev/null +++ b/data/types/EntityCallbackType.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +export enum EntityCallbackType { + PRE_PERSIST = "PRE_PERSIST", + POST_PERSIST = "POST_PERSIST", + PRE_REMOVE = "PRE_REMOVE", + POST_REMOVE = "POST_REMOVE", + PRE_UPDATE = "PRE_UPDATE", + POST_UPDATE = "POST_UPDATE", + POST_LOAD = "POST_LOAD", +} + +export function isEntityCallbackType (value: unknown) : value is EntityCallbackType { + return isEnum(EntityCallbackType, value); +} + +export function explainEntityCallbackType (value : unknown) : string { + return explainEnum("EntityCallbackType", EntityCallbackType, isEntityCallbackType, value); +} + +export function stringifyEntityCallbackType (value : EntityCallbackType) : string { + return stringifyEnum(EntityCallbackType, value); +} + +export function parseEntityCallbackType (value: any) : EntityCallbackType | undefined { + return parseEnum(EntityCallbackType, value) as EntityCallbackType | undefined; +} + +export function isEntityCallbackTypeOrUndefined (value: unknown): value is EntityCallbackType | undefined { + return isUndefined(EntityCallbackType) || isEntityCallbackType(value); +} + +export function explainEntityCallbackTypeOrUndefined (value: unknown): string { + return isEntityCallbackTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['EntityCallbackType', 'undefined'])); +} diff --git a/data/types/EntityField.ts b/data/types/EntityField.ts new file mode 100644 index 0000000..fc0ae09 --- /dev/null +++ b/data/types/EntityField.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2022-2023. Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +import { EntityFieldType, parseEntityFieldType } from "./EntityFieldType"; +import { EntityMetadata } from "./EntityMetadata"; +import { ColumnDefinition, parseColumnDefinition } from "./ColumnDefinition"; + +/** + * Entity field information + */ +export interface EntityField { + + /** + * The type of field + */ + fieldType : EntityFieldType; + + /** + * The property name on the class + */ + propertyName : string; + + /** + * The field name in the database table + */ + columnName : string; + + /** + * The database column definition. + * + * E.g. `BIGINT`. + */ + columnDefinition ?: ColumnDefinition; + + /** + * If enabled, this field can be left undefined. + * + * Default is `true`. + */ + nullable : boolean; + + /** + * If enabled, this field will be included in INSERT queries. + * + * Default is `true`. + */ + insertable : boolean; + + /** + * If enabled, this field will be included in UPDATE queries. + * + * Default is `true`. + */ + updatable : boolean; + + /** + * The field metadata if this field is an entity + */ + metadata ?: EntityMetadata | undefined; + +} + +/** + * + * @param propertyName + * @param columnName + * @param columnDefinition + * @param nullable + * @param fieldType + * @param metadata + * @param insertable + * @param updatable + * @returns {{nullable: boolean, propertyName: string, updatable: boolean, fieldType: EntityFieldType, columnName: string, insertable: boolean}} + */ +export function createEntityField ( + propertyName : string, + columnName : string, + columnDefinition ?: ColumnDefinition, + nullable ?: boolean | undefined, + fieldType ?: EntityFieldType | undefined, + metadata ?: EntityMetadata | undefined, + insertable ?: boolean | undefined, + updatable ?: boolean | undefined, +) : EntityField { + return { + propertyName, + columnName, + ...(columnDefinition ? {columnDefinition: parseColumnDefinition(columnDefinition)} : {}), + nullable : nullable ?? true, + insertable : insertable ?? true, + updatable : updatable ?? true, + fieldType : parseEntityFieldType(fieldType) ?? EntityFieldType.UNKNOWN, + ...(metadata ? {metadata} : {}) + }; +} diff --git a/data/types/EntityFieldType.ts b/data/types/EntityFieldType.ts new file mode 100644 index 0000000..e729029 --- /dev/null +++ b/data/types/EntityFieldType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum EntityFieldType { + UNKNOWN = "UNKNOWN", + STRING = "STRING", + NUMBER = "NUMBER", + BIGINT = "BIGINT", + BOOLEAN = "BOOLEAN", + DATE_TIME = "DATE_TIME", + TIME = "TIME", + DATE = "DATE", + + /** + * This is another entity which has been joined from another table using + * `@JoinColumn` annotation. + */ + JOINED_ENTITY = "JOINED_ENTITY" + +} + +export function isEntityFieldType (value: unknown) : value is EntityFieldType { + return isEnum(EntityFieldType, value); +} + +export function explainEntityFieldType (value : unknown) : string { + return explainEnum("EntityFieldType", EntityFieldType, isEntityFieldType, value); +} + +export function stringifyEntityFieldType (value : EntityFieldType) : string { + return stringifyEnum(EntityFieldType, value); +} + +export function parseEntityFieldType (value: any) : EntityFieldType | undefined { + return parseEnum(EntityFieldType, value) as EntityFieldType | undefined; +} + +export function isEntityFieldTypeOrUndefined (value: unknown): value is EntityFieldType | undefined { + return isUndefined(EntityFieldType) || isEntityFieldType(value); +} + +export function explainEntityFieldTypeOrUndefined (value: unknown): string { + return isEntityFieldTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['EntityFieldType', 'undefined'])); +} diff --git a/data/types/EntityLike.ts b/data/types/EntityLike.ts new file mode 100644 index 0000000..28b7f3a --- /dev/null +++ b/data/types/EntityLike.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityMetadata } from "./EntityMetadata"; +import { ReadonlyJsonObject } from "../../Json"; + +export interface CreateEntityLikeCallback { + (dto ?: any) : EntityLike; +} + +export interface EntityLike { + + /** + * Get the metadata for this entity + */ + getMetadata (): EntityMetadata; + + /** + * Make a copy of this entity + */ + clone (): EntityLike; + + /** + * Get JSON presentation of this entity. + */ + toJSON (): ReadonlyJsonObject; + +} diff --git a/data/types/EntityMetadata.ts b/data/types/EntityMetadata.ts new file mode 100644 index 0000000..96cce46 --- /dev/null +++ b/data/types/EntityMetadata.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2022-2023. Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +import { CreateEntityLikeCallback } from "./EntityLike"; +import { EntityField } from "./EntityField"; +import { EntityRelationOneToMany } from "./EntityRelationOneToMany"; +import { EntityRelationManyToOne } from "./EntityRelationManyToOne"; +import { TemporalProperty } from "./TemporalProperty"; +import { EntityCallback } from "./EntityCallback"; +import { map } from "../../functions/map"; + +export interface EntityMetadata { + + /** + * The SQL table name + */ + tableName : string; + + /** + * The property name of the primary key + */ + idPropertyName : string; + + /** + * Metadata for fields + */ + fields : EntityField[]; + + /** + * Metadata for temporal annotation + */ + temporalProperties : TemporalProperty[]; + + oneToManyRelations : EntityRelationOneToMany[]; + manyToOneRelations : EntityRelationManyToOne[]; + + /** + * Property names for @CreationTimestamp annotations + */ + creationTimestamps : string[]; + + /** + * Property names for @UpdateTimestamp annotations + */ + updateTimestamps : string[]; + + createEntity : CreateEntityLikeCallback | undefined; + + callbacks : EntityCallback[]; + +} + +export function createEntityMetadata ( + tableName : string, + idPropertyName : string, + fields : readonly EntityField[], + oneToManyRelations : readonly EntityRelationOneToMany[], + manyToOneRelations : readonly EntityRelationManyToOne[], + temporalProperties : readonly TemporalProperty[], + createEntity : CreateEntityLikeCallback | undefined, + callbacks : readonly EntityCallback[], + creationTimestamps : readonly string[], + updateTimestamps : readonly string[] +) : EntityMetadata { + return { + tableName, + idPropertyName, + fields: map(fields, item => item), + oneToManyRelations: map(oneToManyRelations, item => item), + manyToOneRelations: map(manyToOneRelations, item => item), + temporalProperties: map(temporalProperties, item => item), + createEntity, + callbacks: map(callbacks, item => item), + creationTimestamps: map(creationTimestamps, item => item), + updateTimestamps: map(updateTimestamps, item => item) + }; +} diff --git a/data/types/EntityRelationManyToOne.ts b/data/types/EntityRelationManyToOne.ts new file mode 100644 index 0000000..d88879f --- /dev/null +++ b/data/types/EntityRelationManyToOne.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2022-2023. Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +export interface EntityRelationManyToOne { + + /** + * The property name on the class + */ + readonly propertyName : string; + + /** + * The remote table in which this entity is mapped to. + * + * @See {@link ManyToOne} + */ + readonly mappedTable : string; + +} + +export function createEntityRelationManyToOne ( + propertyName : string, + mappedTable : string +) : EntityRelationManyToOne { + return { + propertyName, + mappedTable + }; +} diff --git a/data/types/EntityRelationOneToMany.ts b/data/types/EntityRelationOneToMany.ts new file mode 100644 index 0000000..a61fb25 --- /dev/null +++ b/data/types/EntityRelationOneToMany.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2022-2023. Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +export interface EntityRelationOneToMany { + + /** + * The property name of the field in the entity + */ + readonly propertyName : string; + + /** + * The property name in which this relation is mapped to in the remote entity + */ + readonly mappedBy : string; + + /** + * The remote table in which this entity is mapped to. + * + * @See {@link OneToMany} + */ + readonly mappedTable : string; + +} + +/** + * + * @param propertyName The property name of the field in the entity. See {@link EntityRelationOneToMany.propertyName} + * @param mappedBy The property name in which this relation is mapped to in the remote entity. See {@link EntityRelationOneToMany.mappedBy} + * @param mappedTable The remote table in which this entity is mapped to, if known. See {@link EntityRelationOneToMany.mappedTable} + */ +export function createEntityRelationOneToMany ( + propertyName : string, + mappedBy : string, + mappedTable : string +) : EntityRelationOneToMany { + return { + propertyName, + mappedBy, + mappedTable + }; +} diff --git a/data/types/KeyValuePairs.ts b/data/types/KeyValuePairs.ts new file mode 100644 index 0000000..9abc8c8 --- /dev/null +++ b/data/types/KeyValuePairs.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export interface KeyValuePairs { + [key: string]: any; +} diff --git a/data/types/Persister.ts b/data/types/Persister.ts new file mode 100644 index 0000000..5fde471 --- /dev/null +++ b/data/types/Persister.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +import { EntityMetadata } from "./EntityMetadata"; +import { Entity } from "../Entity"; +import { Sort } from "../Sort"; +import { Where } from "../Where"; +import { PersisterType } from "../persisters/types/PersisterType"; +import { Disposable } from "../../types/Disposable"; + +/** + * Implements interface for relational database entity persister. + * + * Our persister interface is intended to be only used by persister developers + * to implement new Persisters -- and by our internal relational database + * framework. + * + * Because of this, we have intentionally left the API as simple as possible. + * Our method signatures also intentionally do not leave out any optional + * parameters. + * + * For example, we want it to be an syntax error if you call + * `.deleteAll(metadata)` without the where clause. This is because we could be + * adding more options later, and we don't want future changes to accidentally + * add bugs like deleting something you didn't want to, or leaving some new + * feature not implemented in your implementation. + * + * The API may also change from time to time without notice, so if you want your + * persister to be refactored to support all of our future improvements, you + * should contact us and make it officially part of our framework. For + * commercial closed source Persisters our company can provide commercial + * support to upgrade these changes with a support contract. + * + * @see {@link MySqlPersister} + * @see {@link PgPersister} + * @see {@link MemoryPersister} + * @see {@link MockPersister} + */ +export interface Persister extends Disposable { + + /** + * Get the type of the persister. + */ + getPersisterType () : PersisterType; + + /** + * Destroys the persister. + * + * This will free up any used resources. You should not use the persister + * after calling this method, and it's advised to delete any references to it. + */ + destroy () : void; + + /** + * This method is used by our annotation framework to inform the persister + * about a new kind of entity. + * + * @see {@link PersisterMetadataManager.setupEntityMetadata} + * @param metadata + */ + setupEntityMetadata (metadata: EntityMetadata) : void; + + /** + * Get count of matching entities in the persister. + * + * @param metadata Entity metadata + * @param where Criteria to filter entities, otherwise all entities will be + * counted. + */ + count ( + metadata : EntityMetadata, + where : Where | undefined, + ) : Promise; + + /** + * Check if matching entities exist in the persister. + * + * @param metadata Entity metadata + * @param where Criteria to filter entities + * @returns `true` if entities matching the criteria exist, otherwise `false` + */ + existsBy ( + metadata : EntityMetadata, + where : Where, + ) : Promise; + + /** + * Delete matching entities from the persister. + * + * @param metadata Entity metadata + * @param where Criteria to filter entities, otherwise all entities will be + * removed. + */ + deleteAll ( + metadata : EntityMetadata, + where : Where | undefined, + ): Promise; + + /** + * Find matching entities stored in the persister. + * + * @param metadata Entity metadata + * @param where Criteria to filter entities, otherwise all entities found. + * @param sort Criteria how to sort entities. If undefined, it will be + * unspecified and may be random. + * @returns all matching entities + */ + findAll ( + metadata : EntityMetadata, + where : Where | undefined, + sort : Sort | undefined + ): Promise; + + /** + * Find a matching entity stored in the persister. + * + * @param metadata Entity metadata + * @param where Criteria to filter entities + * @param sort Criteria how to sort entities. Note, this will affect which + * entity will be found if there are more than one. If undefined, + * the order will be unspecified and may be random. + * @returns the matching entity, otherwise `undefined` + */ + findBy ( + metadata : EntityMetadata, + where : Where, + sort : Sort | undefined + ): Promise; + + /** + * Insert an entity or more to the persister. + * + * @param metadata Entity metadata + * @param entity + */ + insert ( + metadata : EntityMetadata, + entity : T | readonly T[], + ): Promise; + + /** + * Update an entity stored in the persister. + * + * @param metadata Entity metadata + * @param entity + */ + update ( + metadata : EntityMetadata, + entity : T, + ): Promise; + +} diff --git a/data/types/Repository.ts b/data/types/Repository.ts new file mode 100644 index 0000000..bccf63a --- /dev/null +++ b/data/types/Repository.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2020, 2021 Sendanor. All rights reserved. + +import { Entity, EntityIdTypes } from "../Entity"; +import { Persister } from "./Persister"; +import { EntityMetadata } from "./EntityMetadata"; +import { Sort } from "../Sort"; +import { Where } from "../Where"; + +export interface StaticRepository { + + new ( + emptyMetadata : EntityMetadata, + persister : Persister + ) : Repository; + +} + +const RESERVED_REPOSITORY_METHOD_NAMES : readonly string[] = [ + "setup", + "count", + "existsBy", + "delete", + "deleteById", + "deleteAll", + "deleteAllById", + "existsById", + "findAll", + "findAllById", + "findById", + "find", + "save", + "saveAll", + "__getPersister", +]; + +export function isReservedRepositoryMethodName (name: string) : boolean { + return RESERVED_REPOSITORY_METHOD_NAMES.includes(name); +} + +export interface Repository { + + setup () : Promise; + + count (where ?: Where) : Promise; + + existsBy (where : Where) : Promise; + + delete (entity: T): Promise; + + deleteById (id : ID): Promise; + + deleteAll (where ?: Where): Promise; + deleteAll (entities ?: readonly T[]): Promise; + + deleteAllById (ids: readonly ID[]): Promise; + + existsById (id : ID): Promise; + + findAll ( + where ?: Where | Sort | undefined, + sort ?: Where | Sort | undefined, + ): Promise; + + findAllById (ids: readonly ID[] | ID, sort?: Sort): Promise; + + findById (id: ID, sort?: Sort): Promise; + + /** + * @deprecated Use .findAll(Where.propertyExists(propertyName, value)) + * @param propertyName + * @param value + * @param sort + */ + find ( + propertyName: string, + value: any, + sort?: Sort + ): Promise; + + save (entity: T): Promise; + + saveAll (entities: readonly T[]): Promise; + + + /** + * Warning! You shouldn't use Persister directly through this API. + * + * This interface is exposed as public for our own internal implementation, because TypeScript doesn't + * support a concept like "friends" found from other languages. + */ + __getPersister () : Persister; + +} + +export function createRepository ( + ctor : StaticRepository, + entityMetadata : EntityMetadata, + persister : Persister +) : Repository { + return new ctor(entityMetadata, persister) +} diff --git a/data/types/RepositoryEntityError.ts b/data/types/RepositoryEntityError.ts new file mode 100644 index 0000000..8da27db --- /dev/null +++ b/data/types/RepositoryEntityError.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2020, 2021 Sendanor. All rights reserved. + +import { RepositoryError } from "./RepositoryError"; +import { RepositoryErrorCode } from "./RepositoryErrorCode"; + +export class RepositoryEntityError extends RepositoryError { + + public readonly entityId: string | number; + + public constructor ( + entityId: string | number, + code: RepositoryErrorCode, + message: string | undefined = undefined + ) { + + super(code, message); + + this.entityId = entityId; + + } + +} diff --git a/data/types/RepositoryError.ts b/data/types/RepositoryError.ts new file mode 100644 index 0000000..294d35f --- /dev/null +++ b/data/types/RepositoryError.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2020, 2021 Sendanor. All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { RepositoryErrorCode, stringifyRepositoryErrorCode } from "./RepositoryErrorCode"; + +export class RepositoryError extends Error { + + static Code = RepositoryErrorCode; + + public readonly code: RepositoryErrorCode; + + // @ts-ignore + private readonly __proto__: any; + + public constructor ( + code: RepositoryErrorCode, + message: string | undefined = undefined + ) { + + super(message ? message : stringifyRepositoryErrorCode(code)); + + const actualProto = new.target.prototype; + + if ( Object.setPrototypeOf ) { + Object.setPrototypeOf(this, actualProto); + } else { + this.__proto__ = actualProto; + } + + this.code = code; + + } + + public valueOf (): RepositoryErrorCode { + return this.code; + } + + public toString (): string { + return `${this.message} (#${this.code})`; + } + + public toJSON (): ReadonlyJsonObject { + return { + error: this.message, + code: this.code + }; + } + + public getCode (): number { + return this.code; + } + +} + +export function isRepositoryError (value: any): value is RepositoryError { + return ( + !!value + && value instanceof RepositoryError + ); +} diff --git a/data/types/RepositoryErrorCode.ts b/data/types/RepositoryErrorCode.ts new file mode 100644 index 0000000..a8e9b26 --- /dev/null +++ b/data/types/RepositoryErrorCode.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2020, 2021 Sendanor. All rights reserved. + +export enum RepositoryErrorCode { + + ID_NOT_FOUND_FOR_TABLE = 1001, + ENTITY_NOT_FOUND = 1002, + CREATED_ENTITY_ID_NOT_FOUND = 2001, + COLUMN_NAME_NOT_FOUND = 3001, + PROPERTY_NAME_NOT_FOUND = 3002, + COUNT_INCORRECT_ROW_AMOUNT = 4001, + EXISTS_INCORRECT_ROW_AMOUNT = 4002, + +} + +export function stringifyRepositoryErrorCode (value: RepositoryErrorCode): string { + + switch (value) { + case RepositoryErrorCode.ID_NOT_FOUND_FOR_TABLE : + return 'ID_NOT_FOUND_FOR_TABLE'; + case RepositoryErrorCode.ENTITY_NOT_FOUND : + return 'ENTITY_NOT_FOUND'; + case RepositoryErrorCode.CREATED_ENTITY_ID_NOT_FOUND : + return 'CREATED_ENTITY_ID_NOT_FOUND'; + case RepositoryErrorCode.COLUMN_NAME_NOT_FOUND : + return 'COLUMN_NAME_NOT_FOUND'; + case RepositoryErrorCode.COUNT_INCORRECT_ROW_AMOUNT : + return 'COUNT_INCORRECT_ROW_AMOUNT'; + } + + return `RepositoryErrorCode#${value}`; + +} diff --git a/data/types/SortDirection.ts b/data/types/SortDirection.ts new file mode 100644 index 0000000..43b8841 --- /dev/null +++ b/data/types/SortDirection.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; + +export enum SortDirection { + ASC = 1, + DESC = 2 +} + +export function isSortDirection (value: unknown) : value is SortDirection { + return isEnum(SortDirection, value); +} + +export function explainSortDirection (value : unknown) : string { + return explainEnum("SortDirection", SortDirection, isSortDirection, value); +} + +export function stringifySortDirection (value : SortDirection) : string { + return stringifyEnum(SortDirection, value); +} + +export function parseSortDirection (value: any) : SortDirection | undefined { + return parseEnum(SortDirection, value) as SortDirection | undefined; +} diff --git a/data/types/SortOrder.ts b/data/types/SortOrder.ts new file mode 100644 index 0000000..59fae9f --- /dev/null +++ b/data/types/SortOrder.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SortDirection } from "./SortDirection"; +import { get } from "../../functions/get"; + +/** + * @see https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Sort.Order.html + */ +export class SortOrder { + + private readonly _direction : SortDirection; + private readonly _property : string; + + public constructor ( + direction : SortDirection, + property : string + ) { + this._direction = direction; + this._property = property; + } + + public getProperty () : string { + return this._property; + } + + public getDirection () : SortDirection { + return this._direction; + } + + public static createSortFunction ( + sortOrder: readonly SortOrder[] + ): (a: T, b: T) => number { + return (a: T, b: T) => { + for (const order of sortOrder) { + + const property = order.getProperty(); + const direction = order.getDirection(); + + const aValue = a && get(a, property); + const bValue = b && get(b, property); + + if (aValue === bValue) continue; + + if (aValue === undefined) return direction === SortDirection.ASC ? -1 : 1; + if (bValue === undefined) return direction === SortDirection.ASC ? 1 : -1; + + if (aValue < bValue) return direction === SortDirection.ASC ? -1 : 1; + else return direction === SortDirection.ASC ? 1 : -1; + + } + return 0; + }; + } + +} + +export function isSortOrder (value : unknown) : value is SortOrder { + return !!value && value instanceof SortOrder; +} diff --git a/data/types/TemporalProperty.ts b/data/types/TemporalProperty.ts new file mode 100644 index 0000000..646a927 --- /dev/null +++ b/data/types/TemporalProperty.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainTemporalType, isTemporalType, TemporalType } from "./TemporalType"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, isString } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; + +export interface TemporalProperty { + readonly propertyName : string; + readonly temporalType : TemporalType; +} + +export function createTemporalProperty ( + propertyName: string, + temporalType: TemporalType +) : TemporalProperty { + return { + propertyName, + temporalType + }; +} + +export function isTemporalProperty (value: unknown) : value is TemporalProperty { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'propertyName', + 'temporalType' + ]) + && isString(value?.propertyName) + && isTemporalType(value?.temporalType) + ); +} + +export function explainTemporalProperty (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'propertyName', + 'temporalType' + ]) + , explainProperty("propertyName", explainString(value?.propertyName)) + , explainProperty("temporalType", explainTemporalType(value?.temporalType)) + ] + ); +} + +export function stringifyTemporalProperty (value : TemporalProperty) : string { + return `TemporalProperty(${value})`; +} + +export function parseTemporalProperty (value: unknown) : TemporalProperty | undefined { + if (isTemporalProperty(value)) return value; + return undefined; +} diff --git a/data/types/TemporalType.ts b/data/types/TemporalType.ts new file mode 100644 index 0000000..0ea7405 --- /dev/null +++ b/data/types/TemporalType.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +export enum TemporalType { + DATE = "DATE", + TIME = "TIME", + TIMESTAMP = "TIMESTAMP" +} + +export function isTemporalType (value: unknown) : value is TemporalType { + return isEnum(TemporalType, value); +} + +export function explainTemporalType (value : unknown) : string { + return explainEnum("TemporalType", TemporalType, isTemporalType, value); +} + +export function stringifyTemporalType (value : TemporalType) : string { + return stringifyEnum(TemporalType, value); +} + +export function parseTemporalType (value: any) : TemporalType | undefined { + return parseEnum(TemporalType, value) as TemporalType | undefined; +} + +export function isTemporalTypeOrUndefined (value: unknown): value is TemporalType | undefined { + return isUndefined(TemporalType) || isTemporalType(value); +} + +export function explainTemporalTypeOrUndefined (value: unknown): string { + return isTemporalTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['TemporalType', 'undefined'])); +} diff --git a/data/utils/EntityBuilderUtils.test.ts b/data/utils/EntityBuilderUtils.test.ts new file mode 100644 index 0000000..6235122 --- /dev/null +++ b/data/utils/EntityBuilderUtils.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { ColumnSelectorCallback, EntityBuilderUtils } from "./EntityBuilderUtils"; +import { EntityField } from "../types/EntityField"; +import { EntityFieldType } from "../types/EntityFieldType"; +import { TemporalProperty } from "../types/TemporalProperty"; +import { TemporalType } from "../types/TemporalType"; +import { ColumnDefinition } from "../types/ColumnDefinition"; + +describe('EntityBuilderUtils', () => { + + describe('#includeFields', () => { + + const tableName = 'testTable'; + + let asTimestamp: ColumnSelectorCallback; + let asTime: ColumnSelectorCallback; + let asDate: ColumnSelectorCallback; + let asBigint: ColumnSelectorCallback; + let asSelf: ColumnSelectorCallback; + + beforeEach( () => { + asTimestamp = jest.fn(); + asTime = jest.fn(); + asDate = jest.fn(); + asBigint = jest.fn(); + asSelf = jest.fn(); + + }); + + it('calls the correct callback based on the UNKNOWN field without columnDefinition', () => { + + const fields: EntityField[] = [ + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'dateTime', + columnName: 'date_time', + nullable: false, + insertable: true, + updatable: true + }, + ]; + + const temporalProperties: TemporalProperty[] = [ + ]; + + EntityBuilderUtils.includeFields( + tableName, + fields, + temporalProperties, + asTimestamp, + asTime, + asDate, + asBigint, + asSelf, + ); + + expect(asSelf).toHaveBeenCalledWith(tableName, 'date_time', 'dateTime'); + expect(asSelf).toHaveBeenCalledTimes(1); + + expect(asTimestamp).toHaveBeenCalledTimes(0); + expect(asBigint).toHaveBeenCalledTimes(0); + expect(asTime).toHaveBeenCalledTimes(0); + expect(asDate).toHaveBeenCalledTimes(0); + + // Add more expect calls as needed + + }); + + it('calls the correct callback based on the UNKNOWN field with columnDefinition as TIMESTAMP', () => { + + const fields: EntityField[] = [ + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'dateTime', + columnName: 'date_time', + nullable: false, + insertable: true, + updatable: true, + columnDefinition: ColumnDefinition.TIMESTAMP + }, + ]; + + const temporalProperties: TemporalProperty[] = [ + ]; + + EntityBuilderUtils.includeFields( + tableName, + fields, + temporalProperties, + asTimestamp, + asTime, + asDate, + asBigint, + asSelf, + ); + + expect(asTimestamp).toHaveBeenCalledWith(tableName, 'date_time', 'dateTime'); + expect(asTimestamp).toHaveBeenCalledTimes(1); + + expect(asSelf).toHaveBeenCalledTimes(0); + expect(asBigint).toHaveBeenCalledTimes(0); + expect(asTime).toHaveBeenCalledTimes(0); + expect(asDate).toHaveBeenCalledTimes(0); + + }); + + it('calls the correct callback based on the UNKNOWN field with temporal property as TIMESTAMP', () => { + + const fields: EntityField[] = [ + { + fieldType: EntityFieldType.UNKNOWN, + propertyName: 'dateTime', + columnName: 'date_time', + nullable: false, + updatable: true, + insertable: true + }, + ]; + + const temporalProperties: TemporalProperty[] = [ + {propertyName: 'dateTime', temporalType: TemporalType.TIMESTAMP} + ]; + + EntityBuilderUtils.includeFields( + tableName, + fields, + temporalProperties, + asTimestamp, + asTime, + asDate, + asBigint, + asSelf, + ); + + expect(asTimestamp).toHaveBeenCalledWith(tableName, 'date_time', 'dateTime'); + expect(asTimestamp).toHaveBeenCalledTimes(1); + + expect(asSelf).toHaveBeenCalledTimes(0); + expect(asBigint).toHaveBeenCalledTimes(0); + expect(asTime).toHaveBeenCalledTimes(0); + expect(asDate).toHaveBeenCalledTimes(0); + + // Add more expect calls as needed + + }); + + it('calls the correct callback based on the BIGINT column definition', () => { + + const fields: EntityField[] = [ + { fieldType: EntityFieldType.UNKNOWN, propertyName: 'fooId', columnName: 'foo_id', nullable: false, columnDefinition: ColumnDefinition.BIGINT, updatable: true, insertable: true }, + ]; + + const temporalProperties: TemporalProperty[] = [ + ]; + + EntityBuilderUtils.includeFields( + tableName, + fields, + temporalProperties, + asTimestamp, + asTime, + asDate, + asBigint, + asSelf, + ); + + expect(asBigint).toHaveBeenCalledWith(tableName, 'foo_id', 'fooId'); + expect(asBigint).toHaveBeenCalledTimes(1); + + expect(asSelf).toHaveBeenCalledTimes(0); + expect(asTimestamp).toHaveBeenCalledTimes(0); + expect(asTime).toHaveBeenCalledTimes(0); + expect(asDate).toHaveBeenCalledTimes(0); + + // Add more expect calls as needed + + }); + + it('calls multiple correct callbacks based on the field and temporal properties', () => { + + const fields: EntityField[] = [ + { fieldType: EntityFieldType.DATE_TIME, propertyName: 'dateTime', columnName: 'date_time', nullable: false, columnDefinition: ColumnDefinition.TIMESTAMP, updatable: true, insertable: true }, + { fieldType: EntityFieldType.STRING, propertyName: 'string', columnName: 'string_column', nullable: false, columnDefinition: ColumnDefinition.BIGINT, updatable: true, insertable: true }, + { fieldType: EntityFieldType.NUMBER, propertyName: 'number', columnName: 'number_column', nullable: false, updatable: true, insertable: true }, + // Add more field types as needed + ]; + + const temporalProperties: TemporalProperty[] = [ + { propertyName: 'time', temporalType: TemporalType.TIME }, + { propertyName: 'date', temporalType: TemporalType.DATE }, + // Add more temporal properties as needed + ]; + + EntityBuilderUtils.includeFields( + tableName, + fields, + temporalProperties, + asTimestamp, + asTime, + asDate, + asBigint, + asSelf, + ); + + expect(asTimestamp).toHaveBeenCalledWith(tableName, 'date_time', 'dateTime'); + expect(asTimestamp).toHaveBeenCalledTimes(1); + + expect(asBigint).toHaveBeenCalledWith(tableName, 'string_column', 'string'); + expect(asBigint).toHaveBeenCalledTimes(1); + + expect(asSelf).toHaveBeenCalledWith(tableName, 'number_column', 'number'); + expect(asSelf).toHaveBeenCalledTimes(1); + + expect(asTime).toHaveBeenCalledTimes(0); + expect(asDate).toHaveBeenCalledTimes(0); + + }); + + }); + +}); diff --git a/data/utils/EntityBuilderUtils.ts b/data/utils/EntityBuilderUtils.ts new file mode 100644 index 0000000..bb07d14 --- /dev/null +++ b/data/utils/EntityBuilderUtils.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { forEach } from "../../functions/forEach"; +import { find } from "../../functions/find"; +import { EntityField } from "../types/EntityField"; +import { TemporalProperty } from "../types/TemporalProperty"; +import { TemporalType } from "../types/TemporalType"; +import { EntityFieldType } from "../types/EntityFieldType"; +import { ColumnDefinition } from "../types/ColumnDefinition"; + +export type ColumnSelectorCallback = (tableName: string, columnName: string, propertyName : string) => void; + +export class EntityBuilderUtils { + + /** + * + * @param tableName The table name, without the prefix. + * @param fields + * @param temporalProperties + * @param asTimestamp + * @param asTime + * @param asDate + * @param asBigint + * @param asSelf + */ + public static includeFields ( + tableName : string, + fields : readonly EntityField[], + temporalProperties : readonly TemporalProperty[], + asTimestamp : ColumnSelectorCallback, + asTime : ColumnSelectorCallback, + asDate : ColumnSelectorCallback, + asBigint : ColumnSelectorCallback, + asSelf : ColumnSelectorCallback, + ): void { + forEach( + fields, + (field: EntityField) => { + const { propertyName, columnName, fieldType, columnDefinition } = field; + if ( columnName && fieldType !== EntityFieldType.JOINED_ENTITY ) { + + if ( fieldType === EntityFieldType.BIGINT || columnDefinition === ColumnDefinition.BIGINT ) { + return asBigint( tableName, columnName, propertyName ); + } + + const temporalProperty : TemporalProperty | undefined = find( + temporalProperties, + (item) => item.propertyName === propertyName + ); + const temporalType = temporalProperty?.temporalType; + + if ( fieldType === EntityFieldType.DATE_TIME || temporalType === TemporalType.TIMESTAMP || columnDefinition === ColumnDefinition.TIMESTAMP || columnDefinition === ColumnDefinition.TIMESTAMPTZ || columnDefinition === ColumnDefinition.DATETIME || columnDefinition === ColumnDefinition.DATETIMETZ ) { + return asTimestamp( tableName, columnName, propertyName ); + } + + if ( fieldType === EntityFieldType.TIME || temporalType === TemporalType.TIME || columnDefinition === ColumnDefinition.TIME || columnDefinition === ColumnDefinition.TIMETZ ) { + return asTime( tableName, columnName, propertyName ); + } + + if ( fieldType === EntityFieldType.DATE || temporalType === TemporalType.DATE || columnDefinition === ColumnDefinition.DATE || columnDefinition === ColumnDefinition.DATETZ ) { + return asDate( tableName, columnName, propertyName ); + } + + return asSelf( tableName, columnName, propertyName ); + } + } + ); + } + +} diff --git a/data/utils/EntityCallbackUtils.ts b/data/utils/EntityCallbackUtils.ts new file mode 100644 index 0000000..96dfd5d --- /dev/null +++ b/data/utils/EntityCallbackUtils.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EntityCallback } from "../types/EntityCallback"; +import { Entity } from "../Entity"; +import { reduce } from "../../functions/reduce"; +import { EntityCallbackType } from "../types/EntityCallbackType"; +import { filter } from "../../functions/filter"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { some } from "../../functions/some"; + +const LOG = LogService.createLogger( 'EntityCallbackUtils' ); + +export class EntityCallbackUtils { + + public static setLogLevel (level: LogLevel) : void { + LOG.setLogLevel(level); + } + + public static hasCallbacks ( + callbacks : readonly EntityCallback[], + type : EntityCallbackType + ) : boolean { + return some( + callbacks, + (callback: EntityCallback) : boolean => callback.callbackType === type + ); + } + + /** + * These callbacks are executed before the operation to persist + * a new entity into the database. This is usually `INSERT` operation. + * + * @param entityList + * @param callbacks + */ + public static async runPrePersistCallbacks ( + entityList: readonly T[], + callbacks : readonly EntityCallback[] + ) : Promise { + return this._runCallbacks( + entityList, + callbacks, + EntityCallbackType.PRE_PERSIST + ); + } + + /** + * These callbacks are executed after the operation to persist + * a new entity into the database. This is usually `INSERT` operation. + * + * @param entityList + * @param callbacks + */ + public static async runPostPersistCallbacks ( + entityList: readonly T[], + callbacks : readonly EntityCallback[] + ) : Promise { + return this._runCallbacks( + entityList, + callbacks, + EntityCallbackType.POST_PERSIST + ); + } + + /** + * These callbacks are executed before the operation to remove + * an entity from the database. This is usually `DELETE` operation. + * + * @param entityList + * @param callbacks + */ + public static async runPreRemoveCallbacks ( + entityList: readonly T[], + callbacks : readonly EntityCallback[] + ) : Promise { + return this._runCallbacks( + entityList, + callbacks, + EntityCallbackType.PRE_REMOVE + ); + } + + /** + * These callbacks are executed after the operation to remove + * an entity from the database. This is usually `DELETE` operation. + * + * @param entityList + * @param callbacks + */ + public static async runPostRemoveCallbacks ( + entityList: readonly T[], + callbacks : readonly EntityCallback[] + ) : Promise { + return this._runCallbacks( + entityList, + callbacks, + EntityCallbackType.POST_REMOVE + ); + } + + /** + * These callbacks are executed before the operation to update + * an entity in the database. This is usually `UPDATE` operation. + * + * @param entityList + * @param callbacks + */ + public static async runPreUpdateCallbacks ( + entityList: readonly T[], + callbacks : readonly EntityCallback[] + ) : Promise { + return this._runCallbacks( + entityList, + callbacks, + EntityCallbackType.PRE_UPDATE + ); + } + + /** + * These callbacks are executed after the operation to update + * an entity in the database. This is usually `UPDATE` operation. + * + * @param entityList + * @param callbacks + */ + public static async runPostUpdateCallbacks ( + entityList: readonly T[], + callbacks : readonly EntityCallback[] + ) : Promise { + return this._runCallbacks( + entityList, + callbacks, + EntityCallbackType.POST_UPDATE + ); + } + + /** + * These callbacks are executed after the operation to fetch + * an entity from the database. This is usually `SELECT` operation. + * + * @param entityList + * @param callbacks + */ + public static async runPostLoadCallbacks ( + entityList: readonly T[], + callbacks : readonly EntityCallback[] + ) : Promise { + return this._runCallbacks( + entityList, + callbacks, + EntityCallbackType.POST_LOAD + ); + } + + /** + * + * @param entityList + * @param callbacks + * @param type + * @private + */ + private static async _runCallbacks ( + entityList : readonly T[], + callbacks : readonly EntityCallback[], + type : EntityCallbackType, + ) : Promise { + + callbacks = filter( + callbacks, + (callback) => callback?.callbackType === type + ); + + await reduce( + entityList, + async (entityPrevPromise: Promise, entity: T): Promise => { + + await entityPrevPromise; + + await reduce( + callbacks, + async (ret: Promise, callback: EntityCallback): Promise => { + await ret; + const { propertyName } = callback; + + if ( !( entity && (entity as any)[propertyName] ) ) { + LOG.warn( `The entity did not have callback method defined with name "${propertyName.toString()}": `, entity ); + throw new TypeError( `The entity did not have callback method defined with name "${propertyName.toString()}"` ); + } + + try { + await (entity as any)[propertyName](); + } catch ( err ) { + LOG.warn( `The callback named "${propertyName.toString()}" for ${type} life cycle method resolved in to an error: `, err ); + throw new TypeError( `Callback function "${propertyName.toString()}" failed for ${type} life cycle event handler: ${err}` ); + } + + }, + Promise.resolve() + ); + + }, + Promise.resolve() + ); + + } +} diff --git a/data/utils/EntityMetadataUtils.ts b/data/utils/EntityMetadataUtils.ts new file mode 100644 index 0000000..076a5ef --- /dev/null +++ b/data/utils/EntityMetadataUtils.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "reflect-metadata"; +import { createEntityMetadata, EntityMetadata } from "../types/EntityMetadata"; + +const METADATA_KEY = Symbol("metadata"); + +export class EntityMetadataUtils { + + public static getMetadata (value: any) : EntityMetadata { + return Reflect.getMetadata(METADATA_KEY, value); + } + + public static updateMetadata ( + target: any, + setValue: (metadata: EntityMetadata) => void + ) : void { + const metadata: EntityMetadata = Reflect.getMetadata(METADATA_KEY, target) || createEntityMetadata( + "", + "", + [], + [], + [], + [], + undefined, + [], + [], + [] + ); + setValue(metadata); + Reflect.defineMetadata(METADATA_KEY, metadata, target); + } + +} diff --git a/data/utils/EntityUtils.test.ts b/data/utils/EntityUtils.test.ts new file mode 100644 index 0000000..b94e8fa --- /dev/null +++ b/data/utils/EntityUtils.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../../testing/jest/matchers/index"; +import { EntityUtils } from "./EntityUtils"; +import { EntityFieldType } from "../types/EntityFieldType"; + +describe('EntityUtils', () => { + + // @Table('foos') + // class FooEntity extends Entity { + // constructor (dto ?: {fooName: string}) { + // super() + // this.fooId = undefined; + // this.fooName = dto?.fooName; + // } + // + // @Id() + // @Column('foo_id') + // public fooId ?: string; + // + // @Column('foo_name') + // public fooName ?: string; + // + // } + + // @Table('bars') + // class BarEntity extends Entity { + // constructor (dto ?: {barName: string}) { + // super() + // this.barId = undefined; + // this.barName = undefined; + // } + // + // @Id() + // @Column('bar_id') + // public barId ?: string; + // + // @Column('bar_name') + // public barName ?: string; + // + // } + + describe('#getColumnName', () => { + + it('can get column name', () => { + expect( EntityUtils.getColumnName('fooBar', [{fieldType: EntityFieldType.UNKNOWN, propertyName: 'fooBar', columnName: 'foo_bar', nullable: false, updatable: true, insertable: true}])).toBe('foo_bar'); + }) + + }); + +}); diff --git a/data/utils/EntityUtils.ts b/data/utils/EntityUtils.ts new file mode 100644 index 0000000..c950861 --- /dev/null +++ b/data/utils/EntityUtils.ts @@ -0,0 +1,348 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2023 Sendanor. All rights reserved. + +import { map } from "../../functions/map"; +import { find } from "../../functions/find"; +import { filter } from "../../functions/filter"; +import { trim } from "../../functions/trim"; +import { forEach } from "../../functions/forEach"; +import { has } from "../../functions/has"; +import { Entity, EntityIdTypes, isEntity } from "../Entity"; +import { EntityMetadata } from "../types/EntityMetadata"; +import { RepositoryError } from "../types/RepositoryError"; +import { isString } from "../../types/String"; +import { MySqlDateTime } from "../persisters/mysql/types/MySqlDateTime"; +import { parseJson, parseReadonlyJsonObject, ReadonlyJsonObject } from "../../Json"; +import { isNumber } from "../../types/Number"; +import { LogService } from "../../LogService"; +import { isNull } from "../../types/Null"; +import { isArray } from "../../types/Array"; +import { EntityField } from "../types/EntityField"; +import { KeyValuePairs } from "../types/KeyValuePairs"; +import { EntityRelationOneToMany } from "../types/EntityRelationOneToMany"; +import { EntityRelationManyToOne } from "../types/EntityRelationManyToOne"; +import { EntityFieldType } from "../types/EntityFieldType"; +import { every } from "../../functions/every"; +import { PersisterMetadataManager } from "../persisters/types/PersisterMetadataManager"; +import { LogLevel } from "../../types/LogLevel"; +import { TemporalProperty } from "../types/TemporalProperty"; +import { isJsonColumnDefinition, isTimeColumnDefinition } from "../types/ColumnDefinition"; +import { parseIsoDateStringWithMilliseconds } from "../../types/Date"; + +const LOG = LogService.createLogger('EntityUtils'); + +export class EntityUtils { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static getColumnName ( + propertyName : string, + fields : readonly EntityField[] + ): string { + const field = fields.find((x: EntityField) => x.propertyName === propertyName); + if ( !field ) throw new RepositoryError(RepositoryError.Code.COLUMN_NAME_NOT_FOUND, `Column name not found for property: "${propertyName}"`); + return field.columnName; + } + + public static getPropertyName ( + columnName : string, + fields : readonly EntityField[] + ): string { + const field = fields.find((x: EntityField) => x.columnName === columnName); + if (field) { + return field.propertyName; + } + throw new RepositoryError(RepositoryError.Code.PROPERTY_NAME_NOT_FOUND, `Column not found: "${columnName}"`); + } + + /** + * This method goes through all fields in the metadata and copies values + * from columns to properties. + * + * This property does not handle joined entities and will ignore those. + * + * @param dbEntity The data in format where the column name points to the + * property value, e.g. as it is saved in the MySQL row. + * @param parentMetadata + * @param metadataManager + */ + public static toEntity ( + dbEntity: KeyValuePairs, + parentMetadata: EntityMetadata, + metadataManager: PersisterMetadataManager + ): T { + + if (!dbEntity) { + throw new TypeError(`The dbEntity must be defined: ${dbEntity}`); + } + + const { createEntity, fields, manyToOneRelations, oneToManyRelations, temporalProperties } = parentMetadata; + + if (!createEntity) throw new TypeError(`Could not create entity: No create function`); + + const ret : T = createEntity() as unknown as T; + if (!isEntity(ret)) { + LOG.debug(`The created entity was not extended from Entity class: `, ret); + throw new TypeError(`Could not create an entity object. This is probably because your entity class was not extended from the Entity base class. See debug log for extra information.`); + } + + forEach( + fields, + (field: EntityField) : void => { + const { + fieldType, + propertyName, + columnName + /*, metadata*/, + columnDefinition + } = field; + if (fieldType === EntityFieldType.JOINED_ENTITY) { + // This is handled below at @ManyToOne + } else { + + const temporalProperty : TemporalProperty | undefined = find(temporalProperties, item => item.propertyName === propertyName); + const temporalType = temporalProperty?.temporalType; + + const isTime : boolean = !!temporalType || isTimeColumnDefinition(columnDefinition); + if (isTime) { + LOG.debug(`toEntity: "${propertyName}": as string "${dbEntity[columnName]}": `, dbEntity[columnName]); + (ret as any)[propertyName] = EntityUtils.parseDateAsString(dbEntity[columnName]); + return; + } + + const isJson : boolean = isJsonColumnDefinition(columnDefinition); + if (isJson) { + LOG.debug(`toEntity: "${propertyName}": as string "${dbEntity[columnName]}": `, dbEntity[columnName]); + (ret as any)[propertyName] = EntityUtils.parseJsonObject(dbEntity[columnName]); + return; + } + + (ret as any)[propertyName] = dbEntity[columnName]; + + } + } + ); + + // OneToMany relations + forEach( + oneToManyRelations, + (relation: EntityRelationOneToMany) : void => { + const {propertyName} = relation; + // LOG.debug(`oneToMany: propertyName=`, propertyName); + if (!propertyName) return; + const mappedTable = relation?.mappedTable; + if (!mappedTable) { + LOG.warn(`Warning: @oneToMany: The property "${propertyName}" did not have table mapped`); + return; + } + const mappedMetadata = metadataManager.getMetadataByTable(mappedTable); + if (!mappedMetadata) { + LOG.debug(`oneToMany: "${propertyName}": mappedMetadata=`, mappedMetadata); + throw new TypeError(`Could not find metadata for table "${mappedTable}" (for property ${propertyName}) -- Check that your table names in @OneToMany and @Table match exactly`); + } + + let dbValue : any = has(dbEntity, propertyName) ? dbEntity[propertyName] : undefined; + if (dbValue !== undefined) { + if (isString(dbValue)) { + const jsonString = dbValue; + dbValue = parseJson(jsonString); + if (dbValue === undefined) throw new TypeError(`Failed to parse JSON: "${jsonString}"`) + } + if (isNull(dbValue)) { + (ret as any)[propertyName] = []; + return; + } + if (!isArray(dbValue)) { + LOG.debug(`dbValue for property "${propertyName}" = `, dbValue); + throw new TypeError(`Expected the dbValue for property "${propertyName}" to be an array: ${dbValue}`); + } + (ret as any)[propertyName] = map( + filter( + dbValue, + (value) => !isNull(value) + ), + (value: any) => { + if (!value) throw new TypeError(`Unexpected undefined item in an array for property "${propertyName}"`); + return this.toEntity(value, mappedMetadata, metadataManager); + } + ); + } + + } + ); + + // ManyToOne relations + forEach( + manyToOneRelations, + (relation: EntityRelationManyToOne) : void => { + const {propertyName} = relation; + // LOG.debug(`manyToOne: propertyName=`, propertyName); + if (!propertyName) return; + const mappedTable = relation?.mappedTable; + if (!mappedTable) { + LOG.debug(`manyToOne: ${propertyName}: mappedTable=`, mappedTable); + throw new TypeError(`The property "${propertyName}" did not have table configured`); + } + const mappedMetadata = metadataManager.getMetadataByTable(mappedTable); + if (!mappedMetadata) { + LOG.debug(`manyToOne: ${propertyName}: mappedMetadata=`, mappedMetadata); + throw new TypeError(`Could not find metadata for property "${propertyName}" from table "${mappedTable}"`); + } + + let dbValue : any = has(dbEntity, propertyName) ? dbEntity[propertyName] : undefined; + if (dbValue !== undefined) { + if (isString(dbValue)) { + const jsonString = dbValue; + dbValue = parseJson(jsonString); + if (dbValue === undefined) throw new TypeError(`Failed to parse JSON: "${jsonString}"`) + } + if (isNull(dbValue)) { + (ret as any)[propertyName] = undefined; + return; + } + LOG.debug(`manyToOne: db value=`, dbValue); + (ret as any)[propertyName] = this.toEntity(dbValue, mappedMetadata, metadataManager); + } + } + ); + + return ret; + } + + public static getPropertyFromEntity (value : T, propertyName: string) : any { + + // TODO: Some day, we'll use this. If the property is JSON, we could already use. + // This would support things like `"jsonData.name"`. + //return get(value, propertyName); + + return has(value, propertyName) ? (value as any)[propertyName] : undefined; + } + + public static getIdColumnName (metadata: EntityMetadata) : string { + return EntityUtils.getColumnName(metadata.idPropertyName, metadata.fields); + } + + public static getIdPropertyName (metadata: EntityMetadata) : string { + return metadata.idPropertyName; + } + + public static getId ( + entity : T, + metadata : EntityMetadata, + tablePrefix : string = '' + ): ID { + const id = (entity as KeyValuePairs)[metadata.idPropertyName]; + if (id !== undefined) return id; + throw new RepositoryError(RepositoryError.Code.ID_NOT_FOUND_FOR_TABLE, `Id property not found for table: "${tablePrefix}${metadata.tableName}"`); + } + + public static isIdField ( + field: EntityField, + metadata: EntityMetadata + ) { + return field.propertyName === metadata.idPropertyName; + } + + public static parseStringArray ( + input: string | undefined, + separator: string + ) : string[] { + return (input ?? '').split(separator).map(trim).filter((item: string) => !!item); + } + + public static parseBoolean (input : any) : boolean { + return input === true || input === 1; + } + + public static parseString (input : any) : string { + return isString(input) ? input : ''; + } + + public static parseNumber (input : any) : number | undefined { + return isNumber(input) ? input : undefined; + } + + public static parseJsonObject (input : any) : ReadonlyJsonObject | undefined { + return parseReadonlyJsonObject(input); + } + + public static parseIntegerAsString (input : string | number | undefined) : string | undefined { + if ( (isString(input) && trim(input)) === '' || input === undefined ) return undefined; + return `${input}`; + } + + public static parseDateAsString (input : Date | string | undefined) : string | undefined { + if ( (isString(input) && trim(input)) === '' || input === undefined ) return undefined; + return `${parseIsoDateStringWithMilliseconds(input, true)}`; + } + + public static parseMySQLDateAsIsoString (value : any) : string | undefined { + let parsed = MySqlDateTime.parse(EntityUtils.parseDateAsString(value)); + return parsed ? parsed.getISOString() : undefined; + } + + public static parseDateAsIsoString (value : any) : string | undefined { + let parsed = EntityUtils.parseDateAsString(value); + return parsed ? parsed : undefined; + } + + public static toJsonString (value : any) : string | undefined { + return JSON.stringify(value); + } + + public static parseIsoStringAsMySQLDateString (value : any) : string | undefined { + let parsed = MySqlDateTime.parse(value); + return parsed ? parsed.toString() : undefined; + } + + /** + * This function validates that each entity in the list is has the same + * metadata, e.g. are of the same type of entities. + * + * @param list + * @param metadata + */ + public static areEntitiesSameType ( + list : readonly T[], + metadata ?: EntityMetadata + ) : boolean { + if ( list.length < 1 ) throw new TypeError(`Cannot check empty array of entities for their type`); + if ( !metadata ) metadata = list[0].getMetadata(); + return every(list, (entity: T) : boolean => entity.getMetadata() === metadata); + } + + /** + * Removes relations to other entities from an entity, e.g. simplifies it. + * + * @param item + * @param metadata + * @returns Cloned and simplified new entity + */ + public static removeEntityRelations ( + item: T, + metadata: EntityMetadata + ) : T { + const clonedEntity = item.clone() as T; + + forEach( + metadata.manyToOneRelations, + (relation) => { + const {propertyName} = relation; + (clonedEntity as any)[propertyName] = undefined; + } + ); + + forEach( + metadata.oneToManyRelations, + (relation) => { + const {propertyName} = relation; + (clonedEntity as any)[propertyName] = []; + } + ); + + return clonedEntity; + } + +} diff --git a/data/utils/RepositoryUtils.ts b/data/utils/RepositoryUtils.ts new file mode 100644 index 0000000..023cbc9 --- /dev/null +++ b/data/utils/RepositoryUtils.ts @@ -0,0 +1,560 @@ +// Copyright (c) 2022-2023. Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021. Sendanor. All rights reserved. + +import { forEach } from "../../functions/forEach"; +import { EntityMetadata } from "../types/EntityMetadata"; +import { CrudRepository } from "../types/CrudRepository"; +import { Entity, EntityIdTypes } from "../Entity"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { EntityField } from "../types/EntityField"; +import { isSort, Sort } from "../Sort"; +import { isWhere, Where } from "../Where"; +import { isReservedRepositoryMethodName } from "../types/Repository"; +import { ObjectUtils } from "../../ObjectUtils"; + +const LOG = LogService.createLogger('RepositoryUtils'); + +export class RepositoryUtils { + + public static setLogLevel (level : LogLevel) { + LOG.setLogLevel(level); + } + + /** + * Generates default properties using the entity metadata. + * + * This will create methods like: + * + * `UserRepository.findAllByEmail(email: string) : Promise` ...if the entity has `email` property + * + */ + public static generateDefaultMethods< + T extends Entity, + ID extends EntityIdTypes, + > ( + proto : any, + entityMetadata : EntityMetadata, + ) { + forEach(entityMetadata.fields, (item: EntityField) => { + + const { propertyName } = item; + LOG.debug(`propertyName = '${propertyName}'`) + + const camelCasePropertyName = RepositoryUtils._getCamelCaseName(propertyName); + LOG.debug(`camelCasePropertyName = '${camelCasePropertyName}'`) + + // Standard ones + const findAllByMethodName = `findAllBy${camelCasePropertyName}`; + if ( !this._isReservedRepositoryMethodName(proto, findAllByMethodName) ) { + proto[findAllByMethodName] = function findAllByProperty ( + propertyValue: any, + arg2?: Sort | Where | undefined, + arg3?: Sort | Where | undefined, + ): Promise { + return RepositoryUtils._findAllByCondition( + this, + entityMetadata, + Where.propertyEquals(propertyName, propertyValue), + arg2, + arg3 + ); + }; + } else { + LOG.debug(`The property "${findAllByMethodName}" was already defined. Not extending it.`); + } + + const findByMethodName = `findBy${camelCasePropertyName}`; + if (!this._isReservedRepositoryMethodName(proto, findByMethodName)) { + proto[findByMethodName] = function findByProperty ( + propertyValue: any, + arg2?: Sort | Where | undefined, + arg3?: Sort | Where | undefined, + ) : Promise { + return RepositoryUtils._findByCondition( + this, + entityMetadata, + Where.propertyEquals(propertyName, propertyValue), + arg2, + arg3, + ); + }; + } else { + LOG.debug(`The property "${findByMethodName}" was already defined. Not extending it.`); + } + + const deleteAllByMethodName = `deleteAllBy${camelCasePropertyName}`; + if (!this._isReservedRepositoryMethodName(proto, deleteAllByMethodName)) { + proto[deleteAllByMethodName] = function deleteAllByProperty ( + propertyValue: any, + arg2?: Where | undefined, + arg3?: Where | undefined, + ) : Promise { + return RepositoryUtils._deleteAllByCondition( + this, + entityMetadata, + Where.propertyEquals(propertyName, propertyValue), + arg2, + arg3, + ); + }; + } else { + LOG.debug(`The property "${deleteAllByMethodName}" was already defined. Not extending it.`); + } + + const existsByMethodName = `existsBy${camelCasePropertyName}`; + if (!this._isReservedRepositoryMethodName(proto, existsByMethodName)) { + proto[existsByMethodName] = function existsByProperty (propertyValue: any) : Promise { + return RepositoryUtils._existsByCondition( + this, + entityMetadata, + Where.propertyEquals(propertyName, propertyValue), + ); + }; + } else { + LOG.debug(`The property "${existsByMethodName}" was already defined. Not extending it.`); + } + + const countByMethodName = `countBy${camelCasePropertyName}`; + if (!this._isReservedRepositoryMethodName(proto, countByMethodName)) { + proto[countByMethodName] = function countByProperty (propertyValue: any) : Promise { + return RepositoryUtils._countByCondition( + this, + entityMetadata, + Where.propertyEquals(propertyName, propertyValue), + ); + }; + } else { + LOG.debug(`The property "${countByMethodName}" was already defined. Not extending it.`); + } + + + + // Between + + const findAllByMethodNameBetween = `findAllBy${camelCasePropertyName}Between`; + if (!this._isReservedRepositoryMethodName(proto, findAllByMethodNameBetween)) { + proto[findAllByMethodNameBetween] = function findAllByPropertyBetween ( + startValue: any, + endValue: any, + arg2?: Sort | Where | undefined, + arg3?: Sort | Where | undefined, + ) : Promise { + return RepositoryUtils._findAllByCondition( + this, + entityMetadata, + Where.propertyBetween(propertyName, startValue, endValue), + arg2, + arg3 + ); + }; + } else { + LOG.debug(`The property "${findAllByMethodNameBetween}" was already defined. Not extending it.`); + } + + const findByMethodNameBetween = `findBy${camelCasePropertyName}Between`; + if (!this._isReservedRepositoryMethodName(proto, findByMethodNameBetween)) { + proto[findByMethodNameBetween] = function findByPropertyBetween ( + startValue: any, + endValue: any, + arg2?: Sort | Where | undefined, + arg3?: Sort | Where | undefined, + ) : Promise { + return RepositoryUtils._findByCondition( + this, + entityMetadata, + Where.propertyBetween(propertyName, startValue, endValue), + arg2, + arg3 + ); + }; + } else { + LOG.debug(`The property "${findByMethodNameBetween}" was already defined. Not extending it.`); + } + + const deleteAllByMethodNameBetween = `deleteAllBy${camelCasePropertyName}Between`; + if (!this._isReservedRepositoryMethodName(proto, deleteAllByMethodNameBetween)) { + proto[deleteAllByMethodNameBetween] = function deleteAllByPropertyBetween ( + startValue: any, + endValue: any, + arg2?: Where | undefined, + arg3?: Where | undefined, + ) : Promise { + return RepositoryUtils._deleteAllByCondition( + this, + entityMetadata, + Where.propertyBetween(propertyName, startValue, endValue), + arg2, + arg3, + ); + }; + } else { + LOG.debug(`The property "${deleteAllByMethodNameBetween}" was already defined. Not extending it.`); + } + + const existsByMethodNameBetween = `existsBy${camelCasePropertyName}Between`; + if (!this._isReservedRepositoryMethodName(proto, existsByMethodNameBetween)) { + proto[existsByMethodNameBetween] = function existsByPropertyBetween (startValue: any, endValue: any) : Promise { + return RepositoryUtils._existsByCondition( + this, + entityMetadata, + Where.propertyBetween(propertyName, startValue, endValue), + ); + }; + } else { + LOG.debug(`The property "${existsByMethodNameBetween}" was already defined. Not extending it.`); + } + + const countByMethodNameBetween = `countBy${camelCasePropertyName}Between`; + if (!this._isReservedRepositoryMethodName(proto, countByMethodNameBetween)) { + proto[countByMethodNameBetween] = function countByPropertyBetween (startValue: any, endValue: any) : Promise { + return RepositoryUtils._countByCondition( + this, + entityMetadata, + Where.propertyBetween(propertyName, startValue, endValue), + ); + }; + } else { + LOG.debug(`The property "${countByMethodNameBetween}" was already defined. Not extending it.`); + } + + + + + // After + + const findAllByMethodNameAfter = `findAllBy${camelCasePropertyName}After`; + if (!this._isReservedRepositoryMethodName(proto, findAllByMethodNameAfter)) { + proto[findAllByMethodNameAfter] = function findAllByPropertyAfter ( + value: any, + arg2?: Sort | Where | undefined, + arg3?: Sort | Where | undefined, + ) : Promise { + return RepositoryUtils._findAllByCondition( + this, + entityMetadata, + Where.propertyAfter(propertyName, value), + arg2, + arg3 + ); + }; + } else { + LOG.debug(`The property "${findAllByMethodNameAfter}" was already defined. Not extending it.`); + } + + const findByMethodNameAfter = `findBy${camelCasePropertyName}After`; + if (!this._isReservedRepositoryMethodName(proto, findByMethodNameAfter)) { + proto[findByMethodNameAfter] = function findByPropertyAfter ( + value: any, + arg2?: Sort | Where | undefined, + arg3?: Sort | Where | undefined, + ) : Promise { + return RepositoryUtils._findByCondition( + this, + entityMetadata, + Where.propertyAfter(propertyName, value), + arg2, + arg3, + ); + }; + } else { + LOG.debug(`The property "${findByMethodNameAfter}" was already defined. Not extending it.`); + } + + const deleteAllByMethodNameAfter = `deleteAllBy${camelCasePropertyName}After`; + if (!this._isReservedRepositoryMethodName(proto, deleteAllByMethodNameAfter)) { + proto[deleteAllByMethodNameAfter] = function deleteAllByPropertyAfter ( + value : any, + arg2 ?: Where | undefined, + arg3 ?: Where | undefined, + ) : Promise { + return RepositoryUtils._deleteAllByCondition( + this, + entityMetadata, + Where.propertyAfter(propertyName, value), + arg2, + arg3, + ); + }; + } else { + LOG.debug(`The property "${deleteAllByMethodNameAfter}" was already defined. Not extending it.`); + } + + const existsByMethodNameAfter = `existsBy${camelCasePropertyName}After`; + if (!this._isReservedRepositoryMethodName(proto, existsByMethodNameAfter)) { + proto[existsByMethodNameAfter] = function existsByPropertyAfter (value: any) : Promise { + return RepositoryUtils._existsByCondition( + this, + entityMetadata, + Where.propertyAfter(propertyName, value), + ); + }; + } else { + LOG.debug(`The property "${existsByMethodNameAfter}" was already defined. Not extending it.`); + } + + const countByMethodNameAfter = `countBy${camelCasePropertyName}After`; + if (!this._isReservedRepositoryMethodName(proto, countByMethodNameAfter)) { + proto[countByMethodNameAfter] = function countByPropertyAfter (value: any) : Promise { + return RepositoryUtils._countByCondition( + this, + entityMetadata, + Where.propertyAfter(propertyName, value), + ); + }; + } else { + LOG.debug(`The property "${countByMethodNameAfter}" was already defined. Not extending it.`); + } + + + + + + // Before + + const findAllByMethodNameBefore = `findAllBy${camelCasePropertyName}Before`; + if (!this._isReservedRepositoryMethodName(proto, findAllByMethodNameBefore)) { + proto[findAllByMethodNameBefore] = function findAllByPropertyBefore ( + value : any, + arg2 ?: Sort | Where | undefined, + arg3 ?: Sort | Where | undefined, + ) : Promise { + return RepositoryUtils._findAllByCondition( + this, + entityMetadata, + Where.propertyBefore(propertyName, value), + arg2, + arg3, + ); + }; + } else { + LOG.debug(`The property "${findAllByMethodNameBefore}" was already defined. Not extending it.`); + } + + const findByMethodNameBefore = `findBy${camelCasePropertyName}Before`; + if (!this._isReservedRepositoryMethodName(proto, findByMethodNameBefore)) { + proto[findByMethodNameBefore] = function findByPropertyBefore ( + value: any, + arg2 ?: Sort | Where | undefined, + arg3 ?: Sort | Where | undefined, + ) : Promise { + return RepositoryUtils._findByCondition( + this, + entityMetadata, + Where.propertyBefore(propertyName, value), + arg2, + arg3, + ); + }; + } else { + LOG.debug(`The property "${findByMethodNameBefore}" was already defined. Not extending it.`); + } + + const deleteAllByMethodNameBefore = `deleteAllBy${camelCasePropertyName}Before`; + if (!this._isReservedRepositoryMethodName(proto, deleteAllByMethodNameBefore)) { + proto[deleteAllByMethodNameBefore] = function deleteAllByPropertyBefore ( + value: any, + arg2 ?: Where | undefined, + arg3 ?: Where | undefined, + ) : Promise { + return RepositoryUtils._deleteAllByCondition( + this, + entityMetadata, + Where.propertyBefore(propertyName, value), + arg2, + arg3, + ); + }; + } else { + LOG.debug(`The property "${deleteAllByMethodNameBefore}" was already defined. Not extending it.`); + } + + const existsByMethodNameBefore = `existsBy${camelCasePropertyName}Before`; + if (!this._isReservedRepositoryMethodName(proto, existsByMethodNameBefore)) { + proto[existsByMethodNameBefore] = function existsByPropertyBefore (value: any) : Promise { + return RepositoryUtils._existsByCondition( + this, + entityMetadata, + Where.propertyBefore(propertyName, value), + ); + }; + } else { + LOG.debug(`The property "${existsByMethodNameBefore}" was already defined. Not extending it.`); + } + + const countByMethodNameBefore = `countBy${camelCasePropertyName}Before`; + if (!this._isReservedRepositoryMethodName(proto, countByMethodNameBefore)) { + proto[countByMethodNameBefore] = function countByPropertyBefore (value: any) : Promise { + return RepositoryUtils._countByCondition( + this, + entityMetadata, + Where.propertyBefore(propertyName, value), + ); + }; + } else { + LOG.debug(`The property "${countByMethodNameBefore}" was already defined. Not extending it.`); + } + + }); + } + + private static _getCamelCaseName (propertyName : string) : string { + return `${propertyName.substr(0, 1).toUpperCase()}${propertyName.substr(1)}`; + } + + /** + * The implementation for `Repository.findAllBy{PropertyName} : T[]`. + * + * @param self + * @param where + * @param entityMetadata + * @param arg2 + * @param arg3 + */ + private static async _findAllByCondition ( + self : CrudRepository, + entityMetadata : EntityMetadata, + where : Where, + arg2 : Sort | Where | undefined, + arg3 : Sort | Where | undefined, + ) : Promise { + const persister = self.__getPersister(); + const [where2, sort] : [Where | undefined, Sort | undefined] = this._parseSortAndWhereArgs(arg2, arg3); + return await persister.findAll( + entityMetadata, + where2 ? where.and(where2) : where, + sort + ); + } + + /** + * + * Note! Only one sort argument supported. + * + * @param arg2 Sort or where condition + * @param arg3 Sort or where condition + * @throws TypeError if multiple sort objects are provided + * @private + */ + private static _parseSortAndWhereArgs ( + arg2 : Sort | Where | undefined, + arg3 : Sort | Where | undefined, + ) : [Where | undefined, Sort | undefined] { + let sort : Sort | undefined = undefined; + let where : Where | undefined = undefined; + if (isSort(arg2)) { + if (isSort(arg3)) { + throw new TypeError('Only one Sort option supported'); + } + sort = arg2; + if (isWhere(arg3)) { + where = arg3; + } + } else { + if (isWhere(arg2)) { + where = arg2; + } + if (isSort(arg3)) { + sort = arg3; + } else if (isWhere(arg3)) { + where = where ? where.and(arg3) : arg3; + } + } + return [where, sort]; + } + + /** + * The implementation for `Repository.findBy{PropertyName} : Promise`. + * + * @param self + * @param where + * @param entityMetadata + * @param arg2 Optional sort or where condition. If this is sort, arg3 must be Where or undefined. + * @param arg3 Optional sort or where condition. If this is sort, arg2 must be Sort or undefined. + */ + private static async _findByCondition ( + self : CrudRepository, + entityMetadata : EntityMetadata, + where : Where, + arg2 : Sort | Where | undefined, + arg3 : Sort | Where | undefined, + ) : Promise { + const persister = self.__getPersister(); + const [where2, sort]: [Where | undefined, Sort | undefined] = this._parseSortAndWhereArgs(arg2, arg3); + return await persister.findBy( + entityMetadata, + where2 ? where.and(where2) : where, + sort + ); + } + + /** + * The implementation for `Repository.deleteAllBy{PropertyName} : Promise`. + * + * @param self + * @param where + * @param arg2 + * @param arg3 + * @param entityMetadata + */ + private static async _deleteAllByCondition ( + self : CrudRepository, + entityMetadata : EntityMetadata, + where : Where, + arg2 : Where | undefined, + arg3 : Where | undefined, + ) : Promise { + const persister = self.__getPersister(); + const [where2, ]: [Where | undefined, Sort | undefined] = this._parseSortAndWhereArgs(arg2, arg3); + return await persister.deleteAll( + entityMetadata, + where2 ? where.and(where2) : where + ); + } + + /** + * The implementation for `Repository.existsBy{PropertyName} : Promise`. + * + * @param self + * @param where + * @param entityMetadata + */ + private static async _existsByCondition ( + self : CrudRepository, + entityMetadata : EntityMetadata, + where : Where, + ) : Promise { + const persister = self.__getPersister(); + return await persister.existsBy( + entityMetadata, + where + ); + } + + /** + * The implementation for `Repository.countBy{PropertyName} : Promise`. + * + * @param self + * @param where + * @param entityMetadata + */ + private static async _countByCondition ( + self : CrudRepository, + entityMetadata : EntityMetadata, + where : Where, + ) : Promise { + const persister = self.__getPersister(); + return await persister.count( + entityMetadata, + where + ); + } + + private static _isReservedRepositoryMethodName ( + proto : any, + name : string + ) : boolean { + return ObjectUtils.isReservedPropertyName(proto, name) || isReservedRepositoryMethodName(name); + } + +} diff --git a/decorators/createClassDecorator.ts b/decorators/createClassDecorator.ts new file mode 100644 index 0000000..b8e0319 --- /dev/null +++ b/decorators/createClassDecorator.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isFunction } from "../types/Function"; +import { LogService } from "../LogService"; +import { ClassDecoratorFunction } from "./types/ClassDecoratorFunction"; + +const LOG = LogService.createLogger( 'createClassDecorator' ); + +export type ClassDecorator = ( + value: Function, + context: { + kind: 'class'; + name: string | undefined; + addInitializer(initializer: () => void): void; + } +) => Function | void; + +/** + * Takes (future) standard ES method decorator and implements it as + * TypeScript's older experimental decorator. + * + * Even though TypeScript 5 supports this format already, it doesn't support + * parameter decorators or experimental metadata support, so we need to have + * some way to use both to move forward. + * + * This call makes it possible to use the legacy decorators at the same time. + * + * @param esDecorator + */ +export function createClassDecorator ( + esDecorator: ClassDecorator +) : ClassDecoratorFunction { + return ( + target : any + ) : void | Function => { + if (!isFunction(target)) { + throw new TypeError(`The constructor is not a function: ${typeof target}`); + } + const overrideConstructor : void | Function = esDecorator( + target, + { + kind: 'class', + name: undefined, + addInitializer: ( + // @ts-ignore + initializer: () => void): void => { + LOG.warn(`Warning! "addInitializer" not yet implemented.`); + } + } + ); + if (isFunction(overrideConstructor)) { + return overrideConstructor; + } else if (overrideConstructor !== undefined) { + LOG.warn(`The return value was not void or Function: ${typeof overrideConstructor}`); + } + }; +} diff --git a/decorators/createMethodDecorator.ts b/decorators/createMethodDecorator.ts new file mode 100644 index 0000000..2304d2d --- /dev/null +++ b/decorators/createMethodDecorator.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { MethodDecoratorFunction } from "./types/MethodDecoratorFunction"; +import { isFunction } from "../types/Function"; +import { LogService } from "../LogService"; + +const LOG = LogService.createLogger( 'createMethodDecorator' ); + +export type ClassMethodDecorator = ( + value: Function, + context: ClassMethodDecoratorContext +) => Function | void; + +/** + * Takes (future) standard ES method decorator and implements it as TypeScript's + * older experimental decorator. + * + * Even though TypeScript 5 supports this format already, it doesn't support + * parameter decorators or experimental metadata support, so we need to have + * some way to use both to move forward. + * + * This call makes it possible to use the legacy decorators at the same time. + * + * @param esDecorator + */ +export function createMethodDecorator ( + esDecorator: ClassMethodDecorator +) : MethodDecoratorFunction { + return function methodDecorator ( + // @ts-ignore + target : any | Function, + propertyName ?: string, + descriptor ?: TypedPropertyDescriptor, + ) : void { + + if (!propertyName) { + throw new TypeError(`createMethodDecorator: Property name missing`); + } + + if (!descriptor) { + throw new TypeError(`createMethodDecorator: descriptor missing from the property "${propertyName}"`); + } + + let method = descriptor.value!; + if (!isFunction(method)) { + throw new TypeError(`createMethodDecorator: The property "${propertyName}" is not a function: ${typeof method}`); + } + + const overrideCallback = esDecorator( + method, + { + kind: 'method', + name: propertyName, + static: false, + private: false, + access: { + has: () : boolean => descriptor.value !== undefined, + get: () => descriptor.value + }, + addInitializer: ( + // @ts-ignore + initializer: () => void): void => { + LOG.warn(`Warning! "addInitializer" not yet implemented.`); + } + } as unknown as ClassMethodDecoratorContext + ); + + if (isFunction(overrideCallback)) { + descriptor.value = function (this: T, ...args: any) : any { + return overrideCallback.apply(this, args); + }; + } else if (overrideCallback !== undefined) { + LOG.warn(`The return value was not void or Function: ${typeof overrideCallback}`); + } + + }; +} diff --git a/decorators/types/ClassDecoratorFunction.ts b/decorators/types/ClassDecoratorFunction.ts new file mode 100644 index 0000000..0dcf7df --- /dev/null +++ b/decorators/types/ClassDecoratorFunction.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +/** + * This is the decorator type for TypeScript's experimental stage 2 decorators. + * + * TypeScript 5 introduced the standard ES style by default, however it does not + * yet support parameter decorators, so we still need to use the old API. + */ +export interface ClassDecoratorFunction { + + /** + * Class decorator function. + * + * @param constructor + */ + ( + constructor : Function, + ): void | any; + +} diff --git a/decorators/types/ClassOrMethodDecoratorFunction.ts b/decorators/types/ClassOrMethodDecoratorFunction.ts new file mode 100644 index 0000000..c277a89 --- /dev/null +++ b/decorators/types/ClassOrMethodDecoratorFunction.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { ClassDecoratorFunction } from "./ClassDecoratorFunction"; +import { MethodDecoratorFunction } from "./MethodDecoratorFunction"; + +/** + * This is the decorator type for TypeScript's experimental stage 2 decorators. + * + * TypeScript 5 introduced the standard ES style by default, however it does not + * yet support parameter decorators, so we still need to use the old API. + */ +export interface ClassOrMethodDecoratorFunction + extends ClassDecoratorFunction, + MethodDecoratorFunction { + + /** + * @inheritDoc + */ + ( + target: any | Function + ): void; + + /** + * @inheritDoc + */ + ( + target: any | Function, + propertyKey: string, + descriptor: TypedPropertyDescriptor + ): void; + +} diff --git a/decorators/types/MethodDecoratorFunction.ts b/decorators/types/MethodDecoratorFunction.ts new file mode 100644 index 0000000..5a666fe --- /dev/null +++ b/decorators/types/MethodDecoratorFunction.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +/** + * This is the decorator type for TypeScript's experimental stage 2 decorators. + * + * TypeScript 5 introduced the standard ES style by default, however it does not + * yet support parameter decorators, so we still need to use the old API. + */ +export interface MethodDecoratorFunction { + + /** + * Method decorator function + * + * @param target + * @param propertyKey + * @param descriptor + */ + ( + target : any | Function, + propertyKey : string, + descriptor : TypedPropertyDescriptor + ): void; +} diff --git a/decorators/types/ParameterDecoratorFunction.ts b/decorators/types/ParameterDecoratorFunction.ts new file mode 100644 index 0000000..b9fce9d --- /dev/null +++ b/decorators/types/ParameterDecoratorFunction.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +/** + * This is the decorator type for TypeScript's experimental stage 2 decorators. + * + * TypeScript 5 introduced the standard ES style by default, however it does not + * yet support these parameter decorators. + */ +export interface ParameterDecoratorFunction { + + /** + * Parameter decorator function. + * + * @param target + * @param propertyKey + * @param paramIndex + */ + ( + target : any | Function, + propertyKey : string, + paramIndex : number + ): void; + +} diff --git a/decorators/types/ParameterOrMethodDecoratorFunction.ts b/decorators/types/ParameterOrMethodDecoratorFunction.ts new file mode 100644 index 0000000..e48f644 --- /dev/null +++ b/decorators/types/ParameterOrMethodDecoratorFunction.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { ParameterDecoratorFunction } from "./ParameterDecoratorFunction"; +import { MethodDecoratorFunction } from "./MethodDecoratorFunction"; + +/** + * This is the decorator type for TypeScript's experimental stage 2 decorators. + * + * TypeScript 5 introduced the standard ES style by default, however it does not + * yet support parameter decorators, so we still need to use the old API. + */ +export interface ParameterOrMethodDecoratorFunction + extends + ParameterDecoratorFunction, + MethodDecoratorFunction +{ + + /** + * @inheritDoc + */ + ( + target : any | Function, + propertyKey : string, + paramIndex : number + ): void; + + /** + * @inheritDoc + */ + ( + target : any | Function, + propertyKey : string, + descriptor : TypedPropertyDescriptor + ): void; + +} diff --git a/discord/discord/.github/FUNDING.yml b/discord/discord/.github/FUNDING.yml new file mode 100644 index 0000000..831a77b --- /dev/null +++ b/discord/discord/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [heusalagroup] diff --git a/discord/discord/.gitignore b/discord/discord/.gitignore new file mode 100644 index 0000000..01896fd --- /dev/null +++ b/discord/discord/.gitignore @@ -0,0 +1,106 @@ +.idea + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port diff --git a/discord/discord/LICENSE b/discord/discord/LICENSE new file mode 100644 index 0000000..8d4b4a0 --- /dev/null +++ b/discord/discord/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Sendanor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/discord/discord/README.md b/discord/discord/README.md new file mode 100644 index 0000000..64cdaca --- /dev/null +++ b/discord/discord/README.md @@ -0,0 +1,49 @@ +**Join our [Discord](https://discord.gg/UBTrHxA78f) to discuss about our software!** + +# @heusalagroup/fi.hg.discord + +Lightweight Discord API and Gateway Library for TypeScript and NodeJS. + +It's still quite experimental and mostly intended for our internal use in our gateway product. + +### It doesn't have many runtime dependencies + + * [NodeJS v14](https://nodejs.org) + * [Lodash](https://lodash.com) + * [WebSocket library `ws`](https://github.com/websockets/ws) -- It's only required for [the `DiscordGateway` implementation](https://github.com/heusalagroup/fi.hg.discord/blob/main/src/DiscordGateway.ts) + +### We don't have traditional releases + +This project evolves directly to our git repository in an agile software development format. + +### We use this library internally as a git submodule + +``` +mkdir -p src/fi/hg +git submodule add git@github.com:heusalagroup/fi.hg.discord.git src/fi/hg/discord +git config -f .gitmodules submodule.src/fi/hg/discord.branch main +``` + +You may want to do that, too, in order to take full advance of the TypeScript language. + +### Documentation + +There isn't much, but look at the source code. *It should be quite readable.* + +There's two main files to start from: + + * [`DiscordService`](https://github.com/heusalagroup/fi.hg.discord/blob/main/src/DiscordService.ts) is a simple API for Discord's REST calls + * [`DiscordGateway`](https://github.com/heusalagroup/fi.hg.discord/blob/main/src/DiscordGateway.ts) is a [Discord Gateway](https://discord.com/developers/docs/topics/gateway) implementation + +Since this project is experimental, we might change things later. + +### We can make stable releases for a commercial customer + +One stable release is 8000 € + taxes. + +The payment includes a month of agile development with the customer, and a year of +support for that release branch. + +### License + +Copyright (c) Heusala Group. All rights reserved. Licensed under the MIT License (the "[License](LICENSE)"); diff --git a/discord/discord/jest.config.js b/discord/discord/jest.config.js new file mode 100644 index 0000000..4ebbfc3 --- /dev/null +++ b/discord/discord/jest.config.js @@ -0,0 +1,10 @@ +// See also https://github.com/heusalagroup/test or project specific test folder +/** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + // testTimeout: 30000, + globals: { + window: {} + } +}; diff --git a/discord/discord/package-lock.json b/discord/discord/package-lock.json new file mode 100644 index 0000000..d89f29d --- /dev/null +++ b/discord/discord/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "discord", + "version": "0.0.2", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/lodash": { + "version": "4.14.171", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.171.tgz", + "integrity": "sha512-7eQ2xYLLI/LsicL2nejW9Wyko3lcpN6O/z0ZLHrEQsg280zIdCv1t/0m6UtBjUHokCGBQ3gYTbHzDkZ1xOBwwg==", + "dev": true + }, + "@types/node": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.4.2.tgz", + "integrity": "sha512-vxyhOzFCm+jC/T5KugbVsYy1DbQM0h3NCFUrVbu0+pYa/nr+heeucpqxpa8j4pUmIGLPYzboY9zIdOF0niFAjQ==", + "dev": true + }, + "@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "typescript": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", + "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==", + "dev": true + }, + "ws": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", + "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==" + } + } +} diff --git a/discord/discord/package.json b/discord/discord/package.json new file mode 100644 index 0000000..a61f9b7 --- /dev/null +++ b/discord/discord/package.json @@ -0,0 +1,37 @@ +{ + "name": "discord", + "version": "0.0.2", + "description": "Lightweight Discord API and Gateway Library for TypeScript and NodeJS", + "main": "build/index.js", + "scripts": { + "build": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sendanor/discord.git" + }, + "keywords": [ + "discord", + "typescript", + "lightweight", + "gateway", + "api" + ], + "author": "Jaakko Heusala ", + "license": "MIT", + "bugs": { + "url": "https://github.com/sendanor/discord/issues" + }, + "homepage": "https://github.com/sendanor/discord#readme", + "dependencies": { + "lodash": "^4.17.21", + "ws": "^7.5.3" + }, + "devDependencies": { + "@types/lodash": "^4.14.171", + "@types/node": "^16.4.2", + "@types/ws": "^7.4.7", + "typescript": "^4.3.5" + } +} diff --git a/discord/discord/src/DiscordGateway.ts b/discord/discord/src/DiscordGateway.ts new file mode 100644 index 0000000..86cce5b --- /dev/null +++ b/discord/discord/src/DiscordGateway.ts @@ -0,0 +1,516 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import WebSocket from "ws"; +import {Observer, ObserverCallback, ObserverDestructor} from "../../../Observer"; +import { map } from "../../../functions/map"; +import {DiscordService} from "./DiscordService"; +import {DiscordGatewayState} from "./types/DiscordGatewayState"; +import {DiscordUserDTO} from "./types/DiscordUserDTO"; +import {DiscordBotGatewayDTO} from "./types/DiscordBotGatewayDTO"; +import { + DiscordGatewayOpHelloPayload, + isDiscordGatewayOpHelloDTO +} from "./types/DiscordGatewayOpHelloDTO"; +import {isDiscordGatewayHeartbeatAckDTO} from "./types/DiscordGatewayHeartbeatAckDTO"; +import {isDiscordGatewayHeartbeatDTO} from "./types/DiscordGatewayHeartbeatDTO"; +import {isDiscordGatewayOpInvalidSessionDTO} from "./types/DiscordGatewayOpInvalidSessionDTO"; +import {isDiscordGatewayOpReconnectDTO} from "./types/DiscordGatewayOpReconnectDTO"; +import {DiscordGatewayOp} from "./types/DiscordGatewayOp"; +import { + DiscordGatewayDispatchReadyPayload, + isDiscordGatewayDispatchReadyDTO +} from "./types/DiscordGatewayDispatchReadyDTO"; +import {isDiscordGatewayDispatchMessageCreateDTO} from "./types/DiscordGatewayDispatchMessageCreateDTO"; +import {isDiscordGatewayDispatchMessageUpdateDTO} from "./types/DiscordGatewayDispatchMessageUpdateDTO"; +import {isDiscordGatewayDispatchMessageDeleteDTO} from "./types/DiscordGatewayDispatchMessageDeleteDTO"; +import {isDiscordGatewayDispatchMessageDeleteBulkDTO} from "./types/DiscordGatewayDispatchMessageDeleteBulkDTO"; +import {DiscordGatewayOpIdentifyPayload} from "./types/DiscordGatewayOpIdentifyDTO"; +import {DiscordGatewayOpResumePayload} from "./types/DiscordGatewayOpResumeDTO"; +import {DiscordMessageDTO} from "./types/DiscordMessageDTO"; +import {DiscordMessageUpdateDTO} from "./types/DiscordMessageUpdateDTO"; +import {DiscordMessageDeleteDTO} from "./types/DiscordMessageDeleteDTO"; +import {DiscordMessageDeleteBulkDTO} from "./types/DiscordMessageDeleteBulkDTO"; +import { LogService } from "../../../LogService"; + +const LOG = LogService.createLogger('DiscordGateway'); + +export enum DiscordGatewayEvent { + + /** + * These are messages which our bot user did not send. + */ + NEW_MESSAGE, + + /** + * Any new message, including ones that the bot user created. + */ + CREATE_MESSAGE, + UPDATE_MESSAGE, + DELETE_MESSAGE, + BULK_DELETE_MESSAGE, + +} + +export class DiscordGateway { + + public static Event = DiscordGatewayEvent; + public static State = DiscordGatewayState; + + private readonly _botToken : string; + private readonly _wsHeartbeatCallback : () => void; + private readonly _wsOpenCallback : () => void; + private readonly _wsCloseCallback : () => void; + private readonly _wsMessageCallback : (dataString : string) => void; + + private _ws : WebSocket | undefined; + private _observer : Observer; + private _wsHeartbeatInterval : any; + private _wsHeartbeatTimeout : any; + private _wsLastSequence : number | null; + private _wsState : DiscordGatewayState; + private _wsSessionId : string | undefined; + private _wsSessionUser : DiscordUserDTO | undefined; + private _previousHeartBeatAckTime : number | undefined; + private _heartBeatAckTime : number | undefined; + + constructor ( + botToken : string, + sessionKey ?: string | undefined + ) { + + this._botToken = botToken; + this._wsSessionId = sessionKey; + + this._observer = new Observer("DiscordGateway"); + this._ws = undefined; + this._wsHeartbeatInterval = undefined; + this._wsHeartbeatTimeout = undefined; + this._wsLastSequence = null; + this._wsState = DiscordGatewayState.UNINITIALIZED; + this._previousHeartBeatAckTime = undefined; + this._heartBeatAckTime = undefined; + + this._wsHeartbeatCallback = this._onHeartbeatInterval.bind(this); + this._wsMessageCallback = this._onWsMessage.bind(this); + this._wsCloseCallback = this._onWsClose.bind(this); + this._wsOpenCallback = this._onWsOpen.bind(this); + + } + + public getUser () : DiscordUserDTO | undefined { + return this._wsSessionUser; + } + + public getState () : DiscordGatewayState { + return this._wsState; + } + + public destroy () { + + this._disconnect(); + + this._wsState = DiscordGatewayState.DESTROYED; + + } + + public connect () { + this._connect().catch(err => { + + LOG.error('Could not connect: ', err); + + // FIXME: Implement automatic reply + + }); + } + + public disconnect () { + this._disconnect(); + } + + public on (name : DiscordGatewayEvent, callback: ObserverCallback) : ObserverDestructor { + return this._observer.listenEvent(name, callback); + } + + + private _disconnect () { + + if (this._wsHeartbeatInterval) { + clearInterval(this._wsHeartbeatInterval); + this._wsHeartbeatInterval = undefined; + } + + if (this._wsHeartbeatTimeout) { + clearTimeout(this._wsHeartbeatTimeout); + this._wsHeartbeatTimeout = undefined; + } + + if (this._ws) { + + this._ws.off('open' , this._wsOpenCallback); + this._ws.off('close' , this._wsCloseCallback); + this._ws.off('message', this._wsMessageCallback); + + if (this._wsState === DiscordGatewayState.INITIALIZED || this._wsState === DiscordGatewayState.UNINITIALIZED) { + + } else { + this._ws.close(); + } + + this._ws = undefined; + + } + + this._wsSessionUser = undefined; + this._wsState = DiscordGatewayState.UNINITIALIZED; + + } + + private async _connect () { + + if (this._ws) { + this._disconnect(); + } + + const discordGateway : DiscordBotGatewayDTO = await DiscordService.getDiscordBotGatewayDTO(this._botToken); + + this._ws = new WebSocket(`${discordGateway.url}?v=9&encoding=json`); + + this._ws.on('open' , this._wsOpenCallback) + this._ws.on('close' , this._wsCloseCallback) + this._ws.on('message', this._wsMessageCallback); + + this._wsState = DiscordGatewayState.INITIALIZED; + + } + + private async _reconnect () : Promise { + + this._disconnect(); + + await this._connect(); + + } + + private _onWsOpen () { + + LOG.debug('ws open'); + + if ( this._wsState === DiscordGatewayState.INITIALIZED || this._wsState === DiscordGatewayState.UNINITIALIZED ) { + this._wsState = DiscordGatewayState.OPEN; + } + + } + + private _onWsClose () { + + LOG.debug('ws close'); + + this._wsState = DiscordGatewayState.INITIALIZED; + + this._disconnect(); + + } + + private _onWsMessage (dataString : string) { + + LOG.debug('ws message', dataString); + + const data = JSON.parse(dataString); + + this._wsLastSequence = data.s; + + if (isDiscordGatewayOpHelloDTO(data)) { + this._onOpHello(data.d); + + } else if (isDiscordGatewayHeartbeatAckDTO(data)) { + this._onOpHeartbeatAck(); + + } else if (isDiscordGatewayHeartbeatDTO(data)) { + this._onOpHeartbeat(data.d); + + } else if (isDiscordGatewayOpInvalidSessionDTO(data)) { + this._onOpInvalidSession(data.d); + + } else if (isDiscordGatewayOpReconnectDTO(data)) { + this._onOpReconnect(); + + } else if (data.op === DiscordGatewayOp.DISPATCH) { + + if (isDiscordGatewayDispatchReadyDTO(data)) { + this._onDispatchReady(data.d); + + } else if (isDiscordGatewayDispatchMessageCreateDTO(data)) { + this._onDispatchMessageCreate(data.d); + + } else if (isDiscordGatewayDispatchMessageUpdateDTO(data)) { + this._onDispatchMessageUpdate(data.d); + + } else if (isDiscordGatewayDispatchMessageDeleteDTO(data)) { + this._onDispatchMessageDelete(data.d); + + } else if (isDiscordGatewayDispatchMessageDeleteBulkDTO(data)) { + this._onDispatchMessageDeleteBulk(data.d); + + } else { + LOG.debug('unsupported dispatch: ', data.t, data.op, data.d); + } + + } else { + LOG.debug('unsupported op: ', data.op, data.d); + } + + } + + private _sendHeartbeat (value: number | null) { + + LOG.debug('_sendHeartbeat', value); + + this._sendOp(DiscordGatewayOp.HEARTBEAT, value); + + } + + private _sendIdentify () { + + this._sendIdentifyOp({ + token: this._botToken, + intents: 1 << 9, + properties: { + $os: "linux", + $browser: "nor_library", + $device: "nor_library" + } + }); + + } + + private _sendResume (sessionId: string, sequence: number) { + + this._sendResumeOp({ + token: this._botToken, + session_id: sessionId, + seq: sequence + }); + + } + + private _sendIdentifyOp (data: DiscordGatewayOpIdentifyPayload) { + + LOG.debug('_sendIdentify', data); + + this._sendOp(DiscordGatewayOp.IDENTIFY, data); + + } + + private _sendResumeOp (data: DiscordGatewayOpResumePayload) { + + LOG.debug('_sendResume', data); + + this._sendOp(DiscordGatewayOp.RESUME, data); + + } + + private _sendOp (op: DiscordGatewayOp, d: any) { + + if (!this._ws) { + LOG.warn(`Warning! WebSocket was not initialized.`); + return; + } + + LOG.debug('_sendOp', op, d); + + const data = { + op, + d + }; + + const dataString : string = JSON.stringify(data); + + this._ws.send(dataString); + + } + + private _onOpHello (data: DiscordGatewayOpHelloPayload) { + + const hbInterval = data.heartbeat_interval; + + LOG.debug('_onOpHello: ', hbInterval); + + this._wsState = DiscordGatewayState.HELLO; + + this._wsHeartbeatTimeout = setTimeout( () => { + + this._wsHeartbeatTimeout = undefined; + + try { + this._wsHeartbeatCallback(); + } catch (err) { + LOG.error('Error while initial heartbeat interval: ', err); + } + + this._wsHeartbeatInterval = setInterval( + this._wsHeartbeatCallback + , hbInterval + ); + + this._wsState = DiscordGatewayState.HEARTBEATING; + + }, Math.round(Math.random() * hbInterval)); + + // Send resume or identify + if ( this._wsSessionId && this._wsLastSequence ) { + this._sendResume(this._wsSessionId, this._wsLastSequence); + } else { + this._sendIdentify(); + } + + } + + private _onOpHeartbeatAck () { + + LOG.debug('_onOpHeartbeatAck'); + + this._heartBeatAckTime = Date.now(); + + } + + private _onOpHeartbeat (data: number | null) { + + LOG.debug('_onOpHeartbeat: ', data); + + this._wsHeartbeatCallback(); + + } + + private _onOpInvalidSession (isResumable: boolean) { + + LOG.debug('_onOpInvalidSession: ', isResumable); + + if (!isResumable) { + this._wsSessionId = ""; + this._wsLastSequence = null; + } + + this._reconnect().catch(err => { + LOG.error('Reconnect failed: ', err); + }); + + } + + private _onOpReconnect () { + + LOG.debug('_onOpReconnect'); + this._reconnect().catch(err => { + LOG.error('Reconnect failed: ', err); + }); + + } + + private _onDispatchReady (data: DiscordGatewayDispatchReadyPayload) { + + LOG.debug(`_onDispatchReady: ${data?.user?.bot ? 'bot ' : ''}user ${data?.user?.username}#${data?.user?.id}, session_id=${data?.session_id}, ${data?.guilds?.length ?? 0} guilds, application#${data?.application?.id}`); + + this._wsSessionId = data.session_id; + + this._wsSessionUser = data?.user; + + this._wsState = DiscordGatewayState.CONNECTED; + + } + + private _onDispatchMessageCreate (data : DiscordMessageDTO) { + + const msgId = data?.id; + + const hasCreateMessageListeners = this._observer.hasCallbacks(DiscordGatewayEvent.CREATE_MESSAGE); + if (hasCreateMessageListeners) { + LOG.debug(`Triggering CREATE_MESSAGE for #${msgId}`); + this._observer.triggerEvent(DiscordGatewayEvent.CREATE_MESSAGE, data); + } + + const hasNewMessageListeners = this._observer.hasCallbacks(DiscordGatewayEvent.NEW_MESSAGE); + if (hasNewMessageListeners) { + + const myUserId: string | undefined = this._wsSessionUser?.id; + const msgAuthorId = data?.author?.id; + + if (!myUserId) { + LOG.warn(`Warning! We could not detect our own id. Skipped sending NEW_MESSAGE event for #${msgId}.`); + } else if (!msgAuthorId) { + LOG.warn(`Warning! We could not detect ID from message. Skipped sending NEW_MESSAGE event for #${msgId}.`); + } else if (myUserId === msgAuthorId) { + LOG.debug(`It was our own message. Not sending NEW_MESSAGE event for #${msgId}.`); + } else { + LOG.debug(`Triggering NEW_MESSAGE for #${msgId}`); + this._observer.triggerEvent(DiscordGatewayEvent.NEW_MESSAGE, data); + } + } + + if (!hasCreateMessageListeners && !hasNewMessageListeners) { + LOG.debug(`No CREATE_MESSAGE nor NEW_MESSAGE listeners for #${msgId}`); + } + + } + + private _onDispatchMessageUpdate (data : DiscordMessageUpdateDTO) { + const msgId = data?.id; + if (this._observer.hasCallbacks(DiscordGatewayEvent.UPDATE_MESSAGE)) { + LOG.debug(`Triggering UPDATE_MESSAGE for #${msgId}`); + this._observer.triggerEvent(DiscordGatewayEvent.UPDATE_MESSAGE, data); + } else { + LOG.debug(`No UPDATE_MESSAGE listeners for #${msgId}`); + } + } + + private _onDispatchMessageDelete (data : DiscordMessageDeleteDTO) { + const msgId = data?.id; + if (this._observer.hasCallbacks(DiscordGatewayEvent.DELETE_MESSAGE)) { + LOG.debug(`Triggering DELETE_MESSAGE for #${msgId}`); + this._observer.triggerEvent(DiscordGatewayEvent.DELETE_MESSAGE, data); + } else { + LOG.debug(`No DELETE_MESSAGE listeners for #${msgId}`); + } + } + + private _onDispatchMessageDeleteBulk (data : DiscordMessageDeleteBulkDTO) { + const msgIds = data?.ids; + if (this._observer.hasCallbacks(DiscordGatewayEvent.BULK_DELETE_MESSAGE)) { + LOG.debug(`Triggering BULK_DELETE_MESSAGE for ${map(msgIds, (id: string) => `#${id}`).join(' ')}`); + this._observer.triggerEvent(DiscordGatewayEvent.BULK_DELETE_MESSAGE, data); + } else { + LOG.debug(`No BULK_DELETE_MESSAGE listeners ${map(msgIds, (id: string) => `#${id}`).join(' ')}`); + } + } + + /** + * Called periodically to trigger heartbeat for Discord + * + * @private + */ + private _onHeartbeatInterval () { + + try { + + LOG.debug('_onHeartbeatInterval'); + + if ( this._heartBeatAckTime !== undefined && this._heartBeatAckTime === this._previousHeartBeatAckTime ) { + + this._reconnect().catch(err => { + LOG.error('Reconnect failed: ', err); + }); + + } else { + + this._sendHeartbeat(this._wsLastSequence); + + this._previousHeartBeatAckTime = this._heartBeatAckTime; + + } + + } catch (err) { + LOG.error('Error while sending heartbeat interval: ', err); + } + + } + + +} diff --git a/discord/discord/src/DiscordService.ts b/discord/discord/src/DiscordService.ts new file mode 100644 index 0000000..59448ea --- /dev/null +++ b/discord/discord/src/DiscordService.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {Observer, ObserverCallback, ObserverDestructor} from "../../../Observer"; + +import {DiscordApplicationDTO, isDiscordApplicationDTO} from "./types/DiscordApplicationDTO"; +import {DiscordCreateMessageDTO} from "./types/DiscordCreateMessageDTO"; +import {DiscordMessageDTO, isDiscordMessageDTO} from "./types/DiscordMessageDTO"; +import {DiscordBotGatewayDTO, isDiscordBotGatewayDTO} from "./types/DiscordBotGatewayDTO"; + +import {DISCORD_API_ENDPOINT, LIBRARY_NAME, LIBRARY_URL, LIBRARY_VERSION} from "./discord-constants"; +import { LogService } from "../../../LogService"; +import { RequestClientImpl } from "../../../RequestClientImpl"; +import { JsonAny } from "../../../Json"; + +const LOG = LogService.createLogger('DiscordService'); + +export enum DiscordBotServiceEvent { + +} + +export class DiscordService { + + public static Event = DiscordBotServiceEvent; + + private readonly _botToken : string; + private readonly _guildId : string; + + private _observer : Observer; + private _me : DiscordApplicationDTO | undefined; + + public constructor ( + botToken : string, + discordGuildId : string + ) { + + this._botToken = botToken; + this._observer = new Observer("DiscordBotService"); + this._me = undefined; + this._guildId = discordGuildId; + + } + + public destroy () { + + } + + public getMe () : DiscordApplicationDTO | undefined { + return this._me; + } + + public on (name : DiscordBotServiceEvent, callback: ObserverCallback) : ObserverDestructor { + return this._observer.listenEvent(name, callback); + } + + public async initialize () { + + this._me = await DiscordService.getMe(this._botToken); + + } + + public async changeMyNick (newNick : string) : Promise { + + await DiscordService.changeMyNick(this._botToken, this._guildId, newNick); + + } + + public async createMessage (channelId : string, payload: DiscordCreateMessageDTO) : Promise { + return DiscordService.createMessage(this._botToken, channelId, payload); + } + + public async createMessageWithNick (nick: string, channelId : string, payload: DiscordCreateMessageDTO) : Promise { + return DiscordService.createMessageWithNick(this._botToken, nick, channelId, payload); + } + + + public static async getMe (botToken: string) : Promise { + + return await RequestClientImpl.getJson( + `${DISCORD_API_ENDPOINT}/oauth2/applications/@me`, + DiscordService.generateBotHeadersObject(botToken) + ).then((response: any) => { + + if (!isDiscordApplicationDTO(response)) { + LOG.debug('response = ', response); + throw new TypeError('Response was not DiscordApplicationDTO'); + } + + return response; + + }); + + } + + public static async changeMyNick (botToken: string, guildId: string, newNick : string) : Promise { + + const payload = { + nick : newNick + }; + + return await RequestClientImpl.patchJson( + `${DISCORD_API_ENDPOINT}/guilds/${guildId}/members/@me/nick`, + payload as JsonAny, + DiscordService.generateBotHeadersObject(botToken) + ).then(( + /*response: any*/) : void => { + }); + + } + + public static async createMessageWithNick (botToken: string, nick: string, channelId : string, payload: DiscordCreateMessageDTO) : Promise { + + // await this.changeMyNick(nick); + + const newPayload : DiscordCreateMessageDTO = { + ...payload, + content: `<${nick}> ${payload.content}` + }; + + return await DiscordService.createMessage(botToken, channelId, newPayload); + + } + + public static async getDiscordBotGatewayDTO (botToken: string) : Promise { + + return await RequestClientImpl.getJson( + `${DISCORD_API_ENDPOINT}/gateway/bot`, + DiscordService.generateBotHeadersObject(botToken) + ).then((response: any) => { + + if (!isDiscordBotGatewayDTO(response)) { + LOG.debug('response = ', response); + throw new TypeError('Response was not DiscordBotGatewayDTO'); + } + + return response; + + }); + + } + + public static async createMessage (botToken: string, channelId : string, payload: DiscordCreateMessageDTO) : Promise { + + return await RequestClientImpl.postJson( + `${DISCORD_API_ENDPOINT}/channels/${channelId}/messages`, + payload as JsonAny, + DiscordService.generateBotHeadersObject(botToken) + ).then((response: any) => { + + if (!isDiscordMessageDTO(response)) { + LOG.debug('response = ', response); + throw new TypeError('Response was not DiscordMessageDTO'); + } + + return response; + + }); + + } + + + public static generateBotHeadersObject (botToken: string) : { [key: string]: string } { + return { + 'Authorization': DiscordService.getBotAuthorizationHeader(botToken), + 'User-Agent': DiscordService.getUserAgentString() + }; + } + + public static getUserAgentString () : string { + return `${LIBRARY_NAME} (${LIBRARY_URL}, v${LIBRARY_VERSION})`; + } + + public static getBotAuthorizationHeader (botToken: string) : string { + return `Bot ${botToken}`; + } + + +} + diff --git a/discord/discord/src/discord-constants.ts b/discord/discord/src/discord-constants.ts new file mode 100644 index 0000000..a441d43 --- /dev/null +++ b/discord/discord/src/discord-constants.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +export const LIBRARY_NAME = 'sendanor/discord'; +export const LIBRARY_URL = 'https://github.com/sendanor/discord'; +export const LIBRARY_VERSION = '0.0.2'; + +export const DISCORD_API_ENDPOINT = 'https://discord.com/api/v9'; diff --git a/discord/discord/src/index.ts b/discord/discord/src/index.ts new file mode 100644 index 0000000..a5e6f3d --- /dev/null +++ b/discord/discord/src/index.ts @@ -0,0 +1,7 @@ +import { DiscordGateway } from "./DiscordGateway"; +import { DiscordService } from "./DiscordService"; + +export { + DiscordService, + DiscordGateway +} diff --git a/discord/discord/src/types/DiscordApplicationDTO.ts b/discord/discord/src/types/DiscordApplicationDTO.ts new file mode 100644 index 0000000..aa08ffe --- /dev/null +++ b/discord/discord/src/types/DiscordApplicationDTO.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isBoolean } from "../../../../types/Boolean"; +import { isString , isStringOrUndefined } from "../../../../types/String"; + +/** + * For now, this DTO has only been partially described here. + * + * @see https://discord.com/developers/docs/resources/application#application-object + */ +export interface DiscordApplicationDTO { + + readonly id: string; + readonly name: string; + readonly icon?: string; + readonly description: string; + readonly bot_public: boolean; + +} + +export function isDiscordApplicationDTO(value: any): value is DiscordApplicationDTO { + return ( + !!value + && isString(value?.id) + && isString(value?.name) + && isStringOrUndefined(value?.icon) + && isString(value?.description) + && isBoolean(value?.bot_public) + ); +} diff --git a/discord/discord/src/types/DiscordAuthorDTO.ts b/discord/discord/src/types/DiscordAuthorDTO.ts new file mode 100644 index 0000000..1896e32 --- /dev/null +++ b/discord/discord/src/types/DiscordAuthorDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isStringOrUndefined } from "../../../../types/String"; + +export interface DiscordAuthorDTO { + + readonly name ?: string; + readonly url ?: string; + readonly icon_url ?: string; + readonly proxy_icon_url ?: string; + +} + +export function isDiscordAuthorDTO (value : any) : value is DiscordAuthorDTO { + + return ( + !!value + && isStringOrUndefined(value?.name) + && isStringOrUndefined(value?.url) + && isStringOrUndefined(value?.icon_url) + && isStringOrUndefined(value?.proxy_icon_url) + ); + +} diff --git a/discord/discord/src/types/DiscordBotGatewayDTO.ts b/discord/discord/src/types/DiscordBotGatewayDTO.ts new file mode 100644 index 0000000..984a91e --- /dev/null +++ b/discord/discord/src/types/DiscordBotGatewayDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isString } from "../../../../types/String"; +import { isNumber } from "../../../../types/Number"; +import {DiscordSessionStartLimitDTO, isDiscordSessionStartLimitDTO} from "./DiscordSessionStartLimitDTO"; + +export interface DiscordBotGatewayDTO { + + readonly url : string; + readonly shards : number; + readonly session_start_limit : DiscordSessionStartLimitDTO; + +} + +export function isDiscordBotGatewayDTO (value : any) : value is DiscordBotGatewayDTO { + + return ( + !!value + && isString(value?.url) + && isNumber(value?.shards) + && isDiscordSessionStartLimitDTO(value?.session_start_limit) + ); + +} diff --git a/discord/discord/src/types/DiscordCreateMessageDTO.ts b/discord/discord/src/types/DiscordCreateMessageDTO.ts new file mode 100644 index 0000000..ed7da74 --- /dev/null +++ b/discord/discord/src/types/DiscordCreateMessageDTO.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordEmbedDTO} from "./DiscordEmbedDTO"; + +/** + * @see https://discord.com/developers/docs/resources/channel#create-message + */ +export interface DiscordCreateMessageDTO { + + readonly content ?: string; + readonly tts ?: boolean; + readonly embeds ?: DiscordEmbedDTO[]; + +} diff --git a/discord/discord/src/types/DiscordEmbedDTO.ts b/discord/discord/src/types/DiscordEmbedDTO.ts new file mode 100644 index 0000000..2d117fe --- /dev/null +++ b/discord/discord/src/types/DiscordEmbedDTO.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isNumberOrUndefined } from "../../../../types/Number"; +import { isStringOrUndefined } from "../../../../types/String"; +import {DiscordAuthorDTO, isDiscordAuthorDTO} from "./DiscordAuthorDTO"; +import {DiscordEmbedType, isDiscordEmbedType} from "./DiscordEmbedType"; + +export interface DiscordEmbedDTO { + + readonly title ?: string; + readonly type ?: DiscordEmbedType; + readonly description ?: string; + readonly url ?: string; + readonly timestamp ?: string; + readonly color ?: number; + readonly author ?: DiscordAuthorDTO; + +} + +export function isDiscordEmbedDTO (value: any) : value is DiscordEmbedDTO { + + return ( + !!value + && isStringOrUndefined(value?.title) + && (value?.type === undefined || isDiscordEmbedType(value?.type)) + && isStringOrUndefined(value?.description) + && isStringOrUndefined(value?.url) + && isStringOrUndefined(value?.timestamp) + && isNumberOrUndefined(value?.color) + && ( value?.author === undefined || isDiscordAuthorDTO(value?.author) ) + ) + +} diff --git a/discord/discord/src/types/DiscordEmbedType.ts b/discord/discord/src/types/DiscordEmbedType.ts new file mode 100644 index 0000000..bd3c047 --- /dev/null +++ b/discord/discord/src/types/DiscordEmbedType.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +export enum DiscordEmbedType { + + RICH = "rich", + IMAGE = "image", + VIDEO = "video", + GIFV = "gifv", + ARTICLE = "article", + LINK = "link" + +} + +export function isDiscordEmbedType (value : any ) : value is DiscordEmbedType { + + switch (value) { + case DiscordEmbedType.RICH: + case DiscordEmbedType.IMAGE: + case DiscordEmbedType.VIDEO: + case DiscordEmbedType.GIFV: + case DiscordEmbedType.ARTICLE: + case DiscordEmbedType.LINK: + return true + + default: + return false; + + } + +} diff --git a/discord/discord/src/types/DiscordGatewayCloseEventCode.ts b/discord/discord/src/types/DiscordGatewayCloseEventCode.ts new file mode 100644 index 0000000..5452be0 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayCloseEventCode.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isNumber } from "../../../../types/Number"; + +export enum DiscordGatewayCloseEventCode { + + UNKNOWN_ERROR = 4000, + UNKNOWN_OPCODE = 4001, + DECODE_ERROR = 4002, + NOT_AUTHENTICATED = 4003, + AUTHENTICATION_FAILED = 4004, + ALREADY_AUTHENTICATED = 4005, + INVALID_SEQ = 4007, + RATE_LIMITED = 4008, + SESSION_TIMED_OUT = 4009, + INVALID_SHARD = 4010, + SHARDING_REQUIRED = 4011, + INVALID_API_VERSION = 4012, + INVALID_INTENTS = 4013, + DISALLOWED_INTENTS = 4014, + +} + +export function isDiscordGatewayCloseEventCode (value : any) : value is DiscordGatewayCloseEventCode { + return isNumber(value); +} + +export function stringifyDiscordGatewayCloseEventCode (value : number ) { + + switch (value) { + case DiscordGatewayCloseEventCode.UNKNOWN_ERROR : return 'UNKNOWN_ERROR'; + case DiscordGatewayCloseEventCode.UNKNOWN_OPCODE : return 'UNKNOWN_OPCODE'; + case DiscordGatewayCloseEventCode.DECODE_ERROR : return 'DECODE_ERROR'; + case DiscordGatewayCloseEventCode.NOT_AUTHENTICATED : return 'NOT_AUTHENTICATED'; + case DiscordGatewayCloseEventCode.AUTHENTICATION_FAILED : return 'AUTHENTICATION_FAILED'; + case DiscordGatewayCloseEventCode.ALREADY_AUTHENTICATED : return 'ALREADY_AUTHENTICATED'; + case DiscordGatewayCloseEventCode.INVALID_SEQ : return 'INVALID_SEQ'; + case DiscordGatewayCloseEventCode.RATE_LIMITED : return 'RATE_LIMITED'; + case DiscordGatewayCloseEventCode.SESSION_TIMED_OUT : return 'SESSION_TIMED_OUT'; + case DiscordGatewayCloseEventCode.INVALID_SHARD : return 'INVALID_SHARD'; + case DiscordGatewayCloseEventCode.SHARDING_REQUIRED : return 'SHARDING_REQUIRED'; + case DiscordGatewayCloseEventCode.INVALID_API_VERSION : return 'INVALID_API_VERSION'; + case DiscordGatewayCloseEventCode.INVALID_INTENTS : return 'INVALID_INTENTS'; + case DiscordGatewayCloseEventCode.DISALLOWED_INTENTS : return 'DISALLOWED_INTENTS'; + default : return `CloseEventCode#${value}`; + } + +} diff --git a/discord/discord/src/types/DiscordGatewayDispatchMessageCreateDTO.ts b/discord/discord/src/types/DiscordGatewayDispatchMessageCreateDTO.ts new file mode 100644 index 0000000..9d126a6 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayDispatchMessageCreateDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import {DiscordGatewayEvent} from "./DiscordGatewayEvent"; +import {DiscordMessageDTO, isDiscordMessageDTO} from "./DiscordMessageDTO"; + +export interface DiscordGatewayDispatchMessageCreateDTO extends DiscordGatewayOpDTO { + + readonly t : DiscordGatewayEvent.MESSAGE_CREATE; + readonly op : DiscordGatewayOp.DISPATCH; + +} + +export function isDiscordGatewayDispatchMessageCreateDTO (value : any) : value is DiscordGatewayDispatchMessageCreateDTO { + + return ( + !!value + && value?.t === DiscordGatewayEvent.MESSAGE_CREATE + && value?.op === DiscordGatewayOp.DISPATCH + && isDiscordMessageDTO(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayDispatchMessageDeleteBulkDTO.ts b/discord/discord/src/types/DiscordGatewayDispatchMessageDeleteBulkDTO.ts new file mode 100644 index 0000000..523f1b6 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayDispatchMessageDeleteBulkDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import {DiscordGatewayEvent} from "./DiscordGatewayEvent"; +import {DiscordMessageDeleteBulkDTO, isDiscordMessageDeleteBulkDTO} from "./DiscordMessageDeleteBulkDTO"; + +export interface DiscordGatewayDispatchMessageDeleteBulkDTO extends DiscordGatewayOpDTO { + + readonly t : DiscordGatewayEvent.MESSAGE_DELETE_BULK; + readonly op : DiscordGatewayOp.DISPATCH; + +} + +export function isDiscordGatewayDispatchMessageDeleteBulkDTO (value : any) : value is DiscordGatewayDispatchMessageDeleteBulkDTO { + + return ( + !!value + && value?.t === DiscordGatewayEvent.MESSAGE_DELETE_BULK + && value?.op === DiscordGatewayOp.DISPATCH + && isDiscordMessageDeleteBulkDTO(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayDispatchMessageDeleteDTO.ts b/discord/discord/src/types/DiscordGatewayDispatchMessageDeleteDTO.ts new file mode 100644 index 0000000..2f48f22 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayDispatchMessageDeleteDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import {DiscordGatewayEvent} from "./DiscordGatewayEvent"; +import {DiscordMessageDeleteDTO, isDiscordMessageDeleteDTO} from "./DiscordMessageDeleteDTO"; + +export interface DiscordGatewayDispatchMessageDeleteDTO extends DiscordGatewayOpDTO { + + readonly t : DiscordGatewayEvent.MESSAGE_DELETE; + readonly op : DiscordGatewayOp.DISPATCH; + +} + +export function isDiscordGatewayDispatchMessageDeleteDTO (value : any) : value is DiscordGatewayDispatchMessageDeleteDTO { + + return ( + !!value + && value?.t === DiscordGatewayEvent.MESSAGE_DELETE + && value?.op === DiscordGatewayOp.DISPATCH + && isDiscordMessageDeleteDTO(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayDispatchMessageUpdateDTO.ts b/discord/discord/src/types/DiscordGatewayDispatchMessageUpdateDTO.ts new file mode 100644 index 0000000..89f9c57 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayDispatchMessageUpdateDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import {DiscordGatewayEvent} from "./DiscordGatewayEvent"; +import {DiscordMessageUpdateDTO, isDiscordMessageUpdateDTO} from "./DiscordMessageUpdateDTO"; + +export interface DiscordGatewayDispatchMessageUpdateDTO extends DiscordGatewayOpDTO { + + readonly t : DiscordGatewayEvent.MESSAGE_UPDATE; + readonly op : DiscordGatewayOp.DISPATCH; + +} + +export function isDiscordGatewayDispatchMessageUpdateDTO (value : any) : value is DiscordGatewayDispatchMessageUpdateDTO { + + return ( + !!value + && value?.t === DiscordGatewayEvent.MESSAGE_UPDATE + && value?.op === DiscordGatewayOp.DISPATCH + && isDiscordMessageUpdateDTO(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayDispatchReadyDTO.ts b/discord/discord/src/types/DiscordGatewayDispatchReadyDTO.ts new file mode 100644 index 0000000..2cb4e70 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayDispatchReadyDTO.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isArray } from "../../../../types/Array"; +import { isNumber } from "../../../../types/Number"; +import { every } from "../../../../functions/every"; +import { isString } from "../../../../types/String"; + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import {DiscordGatewayEvent} from "./DiscordGatewayEvent"; +import {DiscordUserDTO, isDiscordUserDTO} from "./DiscordUserDTO"; +import {DiscordUnavailableGuildDTO, isDiscordUnavailableGuildDTO} from "./DiscordUnavailableGuildDTO"; +import {DiscordApplicationDTO, isDiscordApplicationDTO} from "./DiscordApplicationDTO"; + +export interface DiscordGatewayDispatchReadyPayload { + readonly v : number; + readonly user : DiscordUserDTO; + readonly guilds : DiscordUnavailableGuildDTO[]; + readonly session_id : string; + readonly application : DiscordApplicationDTO; +} + +export function isDiscordGatewayDispatchReadyPayload (value : any) : value is DiscordGatewayDispatchReadyPayload { + return ( + !!value + && isNumber(value?.v) + && isDiscordUserDTO(value?.user) + && ( isArray(value?.guilds) && every(value?.guilds, isDiscordUnavailableGuildDTO) ) + && isString(value?.session_id) + && isDiscordApplicationDTO(value?.application) + ); +} + +export interface DiscordGatewayDispatchReadyDTO extends DiscordGatewayOpDTO { + + readonly t : DiscordGatewayEvent.READY; + readonly op : DiscordGatewayOp.DISPATCH; + +} + +export function isDiscordGatewayDispatchReadyDTO (value : any) : value is DiscordGatewayDispatchReadyDTO { + + return ( + !!value + && value?.t === DiscordGatewayEvent.READY + && value?.op === DiscordGatewayOp.DISPATCH + && isDiscordGatewayDispatchReadyPayload(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayEvent.ts b/discord/discord/src/types/DiscordGatewayEvent.ts new file mode 100644 index 0000000..154401e --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayEvent.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +export enum DiscordGatewayEvent { + + READY = "READY", + GUILD_MEMBER_UPDATE = "GUILD_MEMBER_UPDATE", + MESSAGE_CREATE = "MESSAGE_CREATE", + MESSAGE_UPDATE = "MESSAGE_UPDATE", + MESSAGE_DELETE = "MESSAGE_DELETE", + MESSAGE_DELETE_BULK = "MESSAGE_DELETE_BULK" + +} diff --git a/discord/discord/src/types/DiscordGatewayHeartbeatAckDTO.ts b/discord/discord/src/types/DiscordGatewayHeartbeatAckDTO.ts new file mode 100644 index 0000000..0cfa490 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayHeartbeatAckDTO.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import {DiscordGatewayOp} from "./DiscordGatewayOp"; + +export interface DiscordGatewayHeartbeatAckDTO extends DiscordGatewayOpDTO { + + readonly op : DiscordGatewayOp.HEARTBEAT_ACK; + +} + +export function isDiscordGatewayHeartbeatAckDTO (value: any) : value is DiscordGatewayHeartbeatAckDTO { + return ( + !!value + && value?.op === DiscordGatewayOp.HEARTBEAT_ACK + && ( value?.d === null || value?.d === undefined ) + ); +} diff --git a/discord/discord/src/types/DiscordGatewayHeartbeatDTO.ts b/discord/discord/src/types/DiscordGatewayHeartbeatDTO.ts new file mode 100644 index 0000000..104e29b --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayHeartbeatDTO.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import { isNumber } from "../../../../types/Number"; + +export interface DiscordGatewayHeartbeatDTO extends DiscordGatewayOpDTO { + + readonly op : DiscordGatewayOp.HEARTBEAT; + +} + +export function isDiscordGatewayHeartbeatDTO (value: any) : value is DiscordGatewayHeartbeatDTO { + return ( + !!value + && value?.op === DiscordGatewayOp.HEARTBEAT + && ( value?.d === null || isNumber(value?.d) ) + ); +} diff --git a/discord/discord/src/types/DiscordGatewayOp.ts b/discord/discord/src/types/DiscordGatewayOp.ts new file mode 100644 index 0000000..dfdab26 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayOp.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +export enum DiscordGatewayOp { + + DISPATCH = 0, + HEARTBEAT = 1, + IDENTIFY = 2, + PRESENCE_UPDATE = 3, + VOICE_STATE_UPDATE = 4, + RESUME = 6, + RECONNECT = 7, + REQUEST_GUILD_MEMBERS = 8, + INVALID_SESSION = 9, + HELLO = 10, + HEARTBEAT_ACK = 11 + +} + diff --git a/discord/discord/src/types/DiscordGatewayOpDTO.ts b/discord/discord/src/types/DiscordGatewayOpDTO.ts new file mode 100644 index 0000000..3e03e45 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayOpDTO.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayEvent} from "./DiscordGatewayEvent"; + +export interface DiscordGatewayOpDTO { + + readonly t : DiscordGatewayEvent; + readonly op : DiscordGatewayOp; + readonly d : T; + readonly s : number | null; + +} diff --git a/discord/discord/src/types/DiscordGatewayOpHelloDTO.ts b/discord/discord/src/types/DiscordGatewayOpHelloDTO.ts new file mode 100644 index 0000000..ac4b751 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayOpHelloDTO.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import { isNumber } from "../../../../types/Number"; + +export interface DiscordGatewayOpHelloPayload { + readonly heartbeat_interval: number; +} + +export function isDiscordGatewayOpHelloPayload (value : any) : value is DiscordGatewayOpHelloPayload { + return ( + !!value + && isNumber(value?.heartbeat_interval) + ); +} + +export interface DiscordGatewayOpHelloDTO extends DiscordGatewayOpDTO { + + readonly op : DiscordGatewayOp.HELLO; + +} + +export function isDiscordGatewayOpHelloDTO (value : any) : value is DiscordGatewayOpHelloDTO { + + return ( + !!value + && value?.op === DiscordGatewayOp.HELLO + && isDiscordGatewayOpHelloPayload(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayOpIdentifyDTO.ts b/discord/discord/src/types/DiscordGatewayOpIdentifyDTO.ts new file mode 100644 index 0000000..204322f --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayOpIdentifyDTO.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isNumber } from "../../../../types/Number"; +import { isString } from "../../../../types/String"; + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; + +export interface DiscordGatewayOpIdentifyPayloadProperties { + readonly $os : string; + readonly $browser : string; + readonly $device : string; +} + +export function isDiscordGatewayOpIdentifyPayloadProperties (value : any) : value is DiscordGatewayOpIdentifyPayloadProperties { + return ( + !!value + && isString(value?.$os) + && isString(value?.$browser) + && isString(value?.$device) + ); +} + +export interface DiscordGatewayOpIdentifyPayload { + readonly token : string; + readonly intents : number; + readonly properties : DiscordGatewayOpIdentifyPayloadProperties; +} + +export function isDiscordGatewayOpIdentifyPayload (value : any) : value is DiscordGatewayOpIdentifyPayload { + return ( + !!value + && isString(value?.token) + && isNumber(value?.intents) + && isDiscordGatewayOpIdentifyPayloadProperties(value?.properties) + ); +} + +export interface DiscordGatewayOpIdentifyDTO extends DiscordGatewayOpDTO { + + readonly op : DiscordGatewayOp.IDENTIFY; + +} + +export function isDiscordGatewayOpIdentifyDTO (value : any) : value is DiscordGatewayOpIdentifyDTO { + + return ( + !!value + && value?.op === DiscordGatewayOp.IDENTIFY + && isDiscordGatewayOpIdentifyPayload(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayOpInvalidSessionDTO.ts b/discord/discord/src/types/DiscordGatewayOpInvalidSessionDTO.ts new file mode 100644 index 0000000..94ab733 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayOpInvalidSessionDTO.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import { isBoolean } from "../../../../types/Boolean"; + +export interface DiscordGatewayOpInvalidSessionDTO extends DiscordGatewayOpDTO { + + readonly op : DiscordGatewayOp.INVALID_SESSION; + +} + +export function isDiscordGatewayOpInvalidSessionDTO (value : any) : value is DiscordGatewayOpInvalidSessionDTO { + + return ( + !!value + && value?.op === DiscordGatewayOp.INVALID_SESSION + && isBoolean(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayOpReconnectDTO.ts b/discord/discord/src/types/DiscordGatewayOpReconnectDTO.ts new file mode 100644 index 0000000..931132f --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayOpReconnectDTO.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; + +export interface DiscordGatewayOpReconnectDTO extends DiscordGatewayOpDTO { + + readonly op : DiscordGatewayOp.RECONNECT; + +} + +export function isDiscordGatewayOpReconnectDTO (value : any) : value is DiscordGatewayOpReconnectDTO { + + return ( + !!value + && value?.op === DiscordGatewayOp.RECONNECT + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayOpResumeDTO.ts b/discord/discord/src/types/DiscordGatewayOpResumeDTO.ts new file mode 100644 index 0000000..f90b973 --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayOpResumeDTO.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {DiscordGatewayOp} from "./DiscordGatewayOp"; +import {DiscordGatewayOpDTO} from "./DiscordGatewayOpDTO"; +import { isNumber } from "../../../../types/Number"; + +export interface DiscordGatewayOpResumePayload { + readonly token : string; + readonly session_id : string; + readonly seq : number; +} + +export function isDiscordGatewayOpResumePayload (value : any) : value is DiscordGatewayOpResumePayload { + return ( + !!value + && isNumber(value?.heartbeat_interval) + ); +} + +export interface DiscordGatewayOpResumeDTO extends DiscordGatewayOpDTO { + + readonly op : DiscordGatewayOp.RESUME; + +} + +export function isDiscordGatewayOpResumeDTO (value : any) : value is DiscordGatewayOpResumeDTO { + + return ( + !!value + && value?.op === DiscordGatewayOp.RESUME + && isDiscordGatewayOpResumePayload(value?.d) + ); + +} diff --git a/discord/discord/src/types/DiscordGatewayState.ts b/discord/discord/src/types/DiscordGatewayState.ts new file mode 100644 index 0000000..c5c86ea --- /dev/null +++ b/discord/discord/src/types/DiscordGatewayState.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +export enum DiscordGatewayState { + + /** + * WebSocket is not initialized yet + */ + UNINITIALIZED = "UNINITIALIZED", + + /** + * Connect method has been called for the WebSocket and listeners has been initialized, but "open" event has not + * been triggered. + * + * This state will be also after the "close" event has been received from the WebSocket. + */ + INITIALIZED = "INITIALIZED", + + /** + * The WS "open" event has been received + */ + OPEN = "OPEN", + + /** + * We have received the "hello" event from the Discord gateway and we're sending our first heartbeat OP. + */ + HELLO = "HELLO", + + /** + * The bot is in the normal heartbeat loop + */ + HEARTBEATING = "HEARTBEATING", + + /** + * We have received the ready event + */ + CONNECTED = "CONNECTED", + + /** + * The bot was destroyed + */ + DESTROYED = "DESTROYED", + +} diff --git a/discord/discord/src/types/DiscordMessageDTO.ts b/discord/discord/src/types/DiscordMessageDTO.ts new file mode 100644 index 0000000..30f5cde --- /dev/null +++ b/discord/discord/src/types/DiscordMessageDTO.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import {every} from "../../../../functions/every"; +import { isArray} from "../../../../types/Array"; +import { isBoolean} from "../../../../types/Boolean"; +import {isString} from "../../../../types/String"; +import {DiscordUserDTO, isDiscordUserDTO} from "./DiscordUserDTO"; +import {DiscordEmbedDTO, isDiscordEmbedDTO} from "./DiscordEmbedDTO"; + +export interface DiscordMessageDTO { + + id : string; + channel_id : string; + author : DiscordUserDTO; + content : string; + timestamp : string; + tts : boolean; + embeds ?: DiscordEmbedDTO[]; + +} + +export function isDiscordMessageDTO (value: any) : value is DiscordMessageDTO { + + return ( + !!value + && isString(value?.id) + && isString(value?.channel_id) + && isString(value?.timestamp) + && isDiscordUserDTO(value?.author) + && isString(value?.content) + && isBoolean(value?.tts) + && ( value?.embeds === undefined || ( isArray(value?.embeds) && every(value?.embeds, isDiscordEmbedDTO) )) + ); + +} diff --git a/discord/discord/src/types/DiscordMessageDeleteBulkDTO.ts b/discord/discord/src/types/DiscordMessageDeleteBulkDTO.ts new file mode 100644 index 0000000..b36b54f --- /dev/null +++ b/discord/discord/src/types/DiscordMessageDeleteBulkDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { every } from "../../../../functions/every"; +import { isArray } from "../../../../types/Array"; +import { isString, isStringOrUndefined } from "../../../../types/String"; + +export interface DiscordMessageDeleteBulkDTO { + + ids : string[]; + channel_id : string; + guild_id ?: string; + +} + +export function isDiscordMessageDeleteBulkDTO (value: any) : value is DiscordMessageDeleteBulkDTO { + + return ( + !!value + && isArray(value?.ids) && every(value?.ids, isString) + && isString(value?.channel_id) + && isStringOrUndefined(value?.guild_id) + ); + +} diff --git a/discord/discord/src/types/DiscordMessageDeleteDTO.ts b/discord/discord/src/types/DiscordMessageDeleteDTO.ts new file mode 100644 index 0000000..0eee253 --- /dev/null +++ b/discord/discord/src/types/DiscordMessageDeleteDTO.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isString, isStringOrUndefined } from "../../../../types/String"; + +export interface DiscordMessageDeleteDTO { + + id : string; + channel_id : string; + guild_id ?: string; + +} + +export function isDiscordMessageDeleteDTO (value: any) : value is DiscordMessageDeleteDTO { + + return ( + !!value + && isString(value?.id) + && isString(value?.channel_id) + && isStringOrUndefined(value?.guild_id) + ); + +} diff --git a/discord/discord/src/types/DiscordMessageUpdateDTO.ts b/discord/discord/src/types/DiscordMessageUpdateDTO.ts new file mode 100644 index 0000000..ed46602 --- /dev/null +++ b/discord/discord/src/types/DiscordMessageUpdateDTO.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { every } from "../../../../functions/every"; +import { isArray } from "../../../../types/Array"; +import { isBooleanOrUndefined } from "../../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../../types/String"; + +import {DiscordUserDTO, isDiscordUserDTO} from "./DiscordUserDTO"; +import {DiscordEmbedDTO, isDiscordEmbedDTO} from "./DiscordEmbedDTO"; + +export interface DiscordMessageUpdateDTO { + + id : string; + channel_id : string; + author ?: DiscordUserDTO; + content ?: string; + timestamp ?: string; + tts ?: boolean; + embeds ?: DiscordEmbedDTO[]; + +} + +export function isDiscordMessageUpdateDTO (value: any) : value is DiscordMessageUpdateDTO { + + return ( + !!value + && isString(value?.id) + && isString(value?.channel_id) + && isStringOrUndefined(value?.timestamp) + && ( value?.author === undefined || isDiscordUserDTO(value?.author) ) + && isStringOrUndefined(value?.content) + && isBooleanOrUndefined(value?.tts) + && ( value?.embeds === undefined || ( isArray(value?.embeds) && every(value?.embeds, isDiscordEmbedDTO) )) + ); + +} diff --git a/discord/discord/src/types/DiscordSessionStartLimitDTO.ts b/discord/discord/src/types/DiscordSessionStartLimitDTO.ts new file mode 100644 index 0000000..de230ab --- /dev/null +++ b/discord/discord/src/types/DiscordSessionStartLimitDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isNumber } from "../../../../types/Number"; + +export interface DiscordSessionStartLimitDTO { + + readonly total : number; + readonly remaining : number; + readonly reset_after : number; + readonly max_concurrency : number; + +} + +export function isDiscordSessionStartLimitDTO (value : any) : value is DiscordSessionStartLimitDTO { + + return ( + !!value + && isNumber(value?.total) + && isNumber(value?.remaining) + && isNumber(value?.reset_after) + && isNumber(value?.max_concurrency) + ); + +} diff --git a/discord/discord/src/types/DiscordUnavailableGuildDTO.ts b/discord/discord/src/types/DiscordUnavailableGuildDTO.ts new file mode 100644 index 0000000..dd949f9 --- /dev/null +++ b/discord/discord/src/types/DiscordUnavailableGuildDTO.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isBoolean } from "../../../../types/Boolean"; +import { isNumberOrUndefined } from "../../../../types/Number"; +import { isString, isStringOrUndefined } from "../../../../types/String"; + +export interface DiscordUnavailableGuildDTO { + + id : string; + unavailable : boolean; + approximate_member_count ?: number; + approximate_presence_count ?: number; + description ?: string; + +} + +export function isDiscordUnavailableGuildDTO (value: any) : value is DiscordUnavailableGuildDTO { + + return ( + !!value + && isString(value?.id) + && isBoolean(value?.unavailable) + && isNumberOrUndefined(value?.approximate_member_count) + && isNumberOrUndefined(value?.approximate_presence_count) + && isStringOrUndefined(value?.description) + ); + +} diff --git a/discord/discord/src/types/DiscordUserDTO.ts b/discord/discord/src/types/DiscordUserDTO.ts new file mode 100644 index 0000000..fe486c8 --- /dev/null +++ b/discord/discord/src/types/DiscordUserDTO.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2021 Sendanor. All rights reserved. + +import { isBooleanOrUndefined } from "../../../../types/Boolean"; +import { isStringOrUndefined, isString } from "../../../../types/String"; + +export interface DiscordUserDTO { + + id : string; + username : string; + discriminator : string; + avatar ?: string; + bot ?: boolean; + system ?: boolean; + locale ?: string; + +} + +export function isDiscordUserDTO (value: any) : value is DiscordUserDTO { + + return ( + !!value + && isString(value?.id) + && isString(value?.username) + && isString(value?.discriminator) + && isStringOrUndefined(value?.avatar) + && isBooleanOrUndefined(value?.bot) + && isBooleanOrUndefined(value?.system) + && isBooleanOrUndefined(value?.locale) + ); + +} diff --git a/discord/discord/tsconfig.json b/discord/discord/tsconfig.json new file mode 100644 index 0000000..88444f4 --- /dev/null +++ b/discord/discord/tsconfig.json @@ -0,0 +1,80 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + "typeRoots": [ + "./node_modules/@types/" + ], + + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./build", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + + /* Advanced Options */ + "skipLibCheck": true, /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + }, + "include": [ + "src" + ], + "exclude": [ + "src/hg/core/Repository.ts", + "src/hg/core/repository" + ] +} diff --git a/email/EmailService.ts b/email/EmailService.ts new file mode 100644 index 0000000..25f651f --- /dev/null +++ b/email/EmailService.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { EmailMessage } from "./types/EmailMessage"; + +export interface EmailService { + + /** + * Set default email from address + * @param from + */ + setDefaultFrom (from: string): void; + + /** + * Initialize the service + * + * @param config + */ + initialize ( config ?: string ) : void; + + /** + * Send email message + * + * @param message + */ + sendEmailMessage (message: EmailMessage): Promise; + +} diff --git a/email/types/EmailMessage.ts b/email/types/EmailMessage.ts new file mode 100644 index 0000000..9a4d28e --- /dev/null +++ b/email/types/EmailMessage.ts @@ -0,0 +1,12 @@ + +/** + * Email message DTO + */ +export interface EmailMessage { + readonly from?: string; + readonly to: string | string[]; + readonly cc?: string | string[]; + readonly subject: string; + readonly content?: string; + readonly htmlContent?: string; +} diff --git a/entities/action/Action.ts b/entities/action/Action.ts new file mode 100644 index 0000000..2426182 --- /dev/null +++ b/entities/action/Action.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { Entity } from "../types/Entity"; +import { ActionDTO } from "./ActionDTO"; +import { ActionEntity } from "./ActionEntity"; + +/** + * Presents an interface for SeoEntity. + */ +export interface Action extends Entity { + + /** + * @inheritDoc + */ + valueOf () : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + getDTO () : ActionDTO; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + + /** + * Get a label. + */ + getLabel () : string; + + /** + * Set a label. + * + * @param label + */ + setLabel (label : string) : this; + + /** + * Set a label. + * + * An alias for `.setLabel(label)`. + * + * @param label + */ + label (label : string) : this; + + + /** + * Get a target. + */ + getTarget () : string; + + /** + * Set a target. + * + * @param target + */ + setTarget (target : string) : this; + + /** + * Set a target. + * + * An alias for `.setTarget(target)`. + * + * @param target + */ + target (target : string) : this; + + + /** + * Get a method. + */ + getMethod () : string; + + /** + * Set a method. + * + * @param method + */ + setMethod (method : string) : this; + + /** + * Set a method. + * + * An alias for `.setMethod(method)`. + * + * @param method + */ + method (method : string) : this; + + + /** + * Get a body. + */ + getBody () : ReadonlyJsonObject; + + /** + * Set a body. + * + * @param body + */ + setBody (body : ReadonlyJsonObject) : this; + + /** + * Set a body. + * + * An alias for `.setBody(body)`. + * + * @param body + */ + body (body : ReadonlyJsonObject) : this; + + + /** + * Get a success redirect. + */ + getSuccessRedirect () : string | ActionEntity | undefined; + + /** + * Set a successRedirect. + * + * @param successRedirect + */ + setSuccessRedirect (successRedirect : string | Action | ActionEntity | ActionDTO | undefined) : this; + + /** + * Set a success redirect. + * + * An alias for `.setSuccessRedirect(successRedirect)`. + * + * @param successRedirect + */ + successRedirect (successRedirect : string | Action | ActionEntity | ActionDTO | undefined) : this; + + + /** + * Get a error redirect. + */ + getErrorRedirect () : string | ActionEntity | undefined; + + /** + * Set a error redirect. + * + * @param errorRedirect + */ + setErrorRedirect (errorRedirect : string | Action | ActionEntity | ActionDTO | undefined) : this; + + /** + * Set a error redirect. + * + * An alias for `.setErrorRedirect(errorRedirect)`. + * + * @param errorRedirect + */ + errorRedirect (errorRedirect : string | Action | ActionEntity | ActionDTO | undefined) : this; + + +} diff --git a/entities/action/ActionDTO.ts b/entities/action/ActionDTO.ts new file mode 100644 index 0000000..60a0146 --- /dev/null +++ b/entities/action/ActionDTO.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { DTO } from "../types/DTO"; + +export interface ActionDTO extends DTO { + + /** + * Label of the action + */ + readonly label: string; + + /** + * The target for the action, e.g. URL. + */ + readonly target: string; + + /** + * The HTTP method to use, e.g. "POST" + */ + readonly method: string; + + /** + * The HTTP request body to use for the request. + * + * If this value is not provided, and this action was a chained action from + * a success or an error handler, the response body from the previous action + * will be used instead. + */ + readonly body ?: ReadonlyJsonObject | undefined; + + /** + * The action when the response was successful. + * + * Defaults to redirection to another view or resource if a string is provided. + * + * If `HyperAction` is provided, will perform another action. See the body + * property. The response from the first action will be provided as the body + * for this action if the success action does not define one. + */ + readonly successRedirect ?: string | ActionDTO | undefined; + + /** + * The action when the response was unsuccessful. + * + * Defaults to redirection to another view or resource if a string is provided. + * + * If `HyperAction` is provided, will perform another action. See the body + * property. The response from the first action will be provided as the body + * for this action if the success action does not define one. + */ + readonly errorRedirect ?: string | ActionDTO | undefined; + +} + diff --git a/entities/action/ActionEntity.ts b/entities/action/ActionEntity.ts new file mode 100644 index 0000000..fa8ed6b --- /dev/null +++ b/entities/action/ActionEntity.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { reduce } from "../../functions/reduce"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { VariableType } from "../types/VariableType"; +import { Action } from "./Action"; +import { ActionDTO } from "./ActionDTO"; + +export const ActionEntityFactory = ( + EntityFactoryImpl.create("Action") + .add( EntityFactoryImpl.createProperty("label").setTypes(VariableType.STRING) ) + .add( EntityFactoryImpl.createProperty("target").setTypes(VariableType.STRING) ) + .add( EntityFactoryImpl.createProperty("method").setTypes(VariableType.STRING) ) + .add( EntityFactoryImpl.createProperty("body").setTypes(VariableType.JSON, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("successRedirect").setTypes(VariableType.STRING, "Action", VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("errorRedirect").setTypes(VariableType.STRING, "Action", VariableType.UNDEFINED) ) +); + +export const BaseActionEntity = ActionEntityFactory.createEntityType(); + +export const isActionDTO = ActionEntityFactory.createTestFunctionOfDTO(); + +export const isAction = ActionEntityFactory.createTestFunctionOfInterface(); + +export const explainActionDTO = ActionEntityFactory.createExplainFunctionOfDTO(); + +export const isActionDTOOrUndefined = ActionEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainActionDTOOrUndefined = ActionEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const isActionDTOOrStringOrUndefined = ActionEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.STRING, VariableType.UNDEFINED); + +export const explainActionDTOOrStringOrUndefined = ActionEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.STRING, VariableType.UNDEFINED); + +/** + * Action entity. + */ +export class ActionEntity + extends BaseActionEntity + implements Action +{ + + /** + * Creates a Action entity. + * + * @param value The optional DTO of Action + */ + public static create ( + value ?: ActionDTO, + ) : ActionEntity { + return new ActionEntity(value); + } + + /** + * Creates a Action entity from DTO. + * + * @param dto The optional DTO of Action + */ + public static createFromDTO ( + dto : ActionDTO, + ) : ActionEntity { + return new ActionEntity(dto); + } + + /** + * Merges multiple values as one entity. + */ + public static merge ( + ...values: readonly (ActionDTO | Action | ActionEntity)[] + ) : ActionEntity { + return ActionEntity.createFromDTO( + reduce( + values, + ( + prev: ActionDTO, + item: ActionDTO | Action | ActionEntity, + ) : ActionDTO => { + const dto : ActionDTO = this.toDTO(item); + return { + ...prev, + ...dto, + }; + }, + ActionEntityFactory.createDefaultDTO(), + ) + ); + } + + /** + * Normalizes the value as a DTO. + */ + public static toDTO ( + value: ActionDTO | Action | ActionEntity, + ) : ActionDTO { + if (isActionEntity(value)) { + return value.getDTO(); + } else if (isAction(value)) { + return value.getDTO(); + } else { + return value; + } + } + + /** + * Construct an entity of ActionEntity. + */ + public constructor ( + dto ?: ActionDTO | undefined, + ) { + super(dto); + } + +} + +export function isActionEntity (value: unknown): value is ActionEntity { + return value instanceof ActionEntity; +} diff --git a/entities/app/App.ts b/entities/app/App.ts new file mode 100644 index 0000000..e2e316b --- /dev/null +++ b/entities/app/App.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { Component } from "../component/Component"; +import { ComponentDTO } from "../component/ComponentDTO"; +import { Route } from "../route/Route"; +import { View } from "../view/View"; +import { AppDTO } from "./AppDTO"; +import { RouteDTO } from "../route/RouteDTO"; +import { ViewDTO } from "../view/ViewDTO"; +import { ComponentEntity } from "../component/ComponentEntity"; +import { ExtendableEntity } from "../types/ExtendableEntity"; +import { RouteEntity } from "../route/RouteEntity"; +import { ViewEntity } from "../view/ViewEntity"; + +/** + * Interface for application definitions. + */ +export interface App + extends ExtendableEntity { + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// standard methods ////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get DTO presentation. + */ + getDTO () : AppDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// name methods ////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + getName () : string; + + /** + * @inheritDoc + */ + setName (name : string) : this; + + /** + * @inheritDoc + */ + name (name : string) : this; + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////// component methods /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get components. + */ + getComponents () : RouteEntity[]; + + /** + * Get components. + */ + getComponentsDTO () : RouteDTO[]; + + /** + * Set components. + * + * @param components + */ + setComponents ( + components : readonly (ComponentDTO | ComponentEntity | Component)[] + ) : this; + + /** + * Set components. + * + * @param components + */ + components ( + components : readonly (ComponentDTO | ComponentEntity | Component)[] + ) : this; + + /** + * Add a component. + * + * @param component + */ + addComponent (component : ComponentDTO | ComponentEntity | readonly (ComponentDTO | ComponentEntity)[] ) : this; + + /** + * Add a component. + * + * @param component + */ + addComponents (component : ComponentDTO | ComponentEntity | readonly (ComponentDTO | ComponentEntity)[] ) : this; + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// view methods ////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get views. + */ + getViews () : RouteEntity[]; + + /** + * Get views. + */ + getViewsDTO () : RouteDTO[]; + + /** + * Set views. + * + * @param views + */ + setViews ( + views : readonly (ViewDTO | ViewEntity | View)[] + ) : this; + + /** + * Set views. Alias for .setViews(). + * + * @param views + */ + views ( + views : readonly (ViewDTO | ViewEntity | View)[] + ) : this; + + /** + * Add a view. + * + * @param view + */ + addView (view : ViewDTO | ViewEntity | readonly (ViewDTO | ViewEntity)[]) : this; + + /** + * Add a view. + * + * @param view + */ + addViews (view : ViewDTO | ViewEntity | readonly (ViewDTO | ViewEntity)[]) : this; + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// route methods ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get routes. + * + * @param routes + */ + getRoutes () : RouteEntity[]; + + /** + * Get routes. + * + * @param routes + */ + getRoutesDTO () : RouteDTO[]; + + /** + * Set routes. + * + * @param routes + */ + setRoutes ( + routes : readonly (RouteDTO | RouteEntity | Route)[] + ) : this; + + /** + * Set routes. + * + * @param routes + */ + routes ( + routes : readonly (RouteDTO | RouteEntity | Route)[] + ) : this; + + /** + * Add a route. + * + * @param route + */ + addRoute ( + route : RouteDTO | RouteEntity | readonly (RouteDTO | RouteEntity)[] + ) : this; + + /** + * Add a route. + * + * @param route + */ + addRoutes ( + route : RouteDTO | RouteEntity | readonly (RouteDTO | RouteEntity)[] + ) : this; + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// extend methods //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + getExtend () : string | undefined; + + /** + * @inheritDoc + */ + setExtend (name : string | undefined) : this; + + /** + * @inheritDoc + */ + extend (name : string | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////// publicUrl methods /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get the public URL. + */ + getPublicUrl () : string | undefined; + + /** + * Set the public URL. + * + * @param value + */ + setPublicUrl (value : string | undefined) : this; + + /** + * Set the public URL. + * + * @param value + */ + publicUrl (value : string | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////////// language methods /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get the language. + */ + getLanguage () : string | undefined; + + /** + * Set the language. + * + * @param value + */ + setLanguage (value : string | undefined) : this; + + /** + * Set the language. + * + * @param value + */ + language (value : string | undefined) : this; + + +} diff --git a/entities/app/AppDTO.ts b/entities/app/AppDTO.ts new file mode 100644 index 0000000..5b4c291 --- /dev/null +++ b/entities/app/AppDTO.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ExtendableDTO } from "../types/ExtendableDTO"; +import { ComponentDTO } from "../component/ComponentDTO"; +import { RouteDTO } from "../route/RouteDTO"; +import { ViewDTO } from "../view/ViewDTO"; +import { DTOWithName } from "../types/DTOWithName"; +import { DTOWithOptionalExtend } from "../types/DTOWithOptionalExtend"; +import { DTOWithOptionalLanguage } from "../types/DTOWithOptionalLanguage"; +import { DTOWithOptionalPublicUrl } from "../types/DTOWithOptionalPublicUrl"; + +export interface AppDTO + extends + DTOWithName, + DTOWithOptionalExtend, + DTOWithOptionalLanguage, + DTOWithOptionalPublicUrl, + ExtendableDTO +{ + readonly name : string; + readonly components : readonly ComponentDTO[]; + readonly views : readonly ViewDTO[]; + readonly routes : readonly RouteDTO[]; + readonly extend ?: string | undefined; + readonly publicUrl ?: string | undefined; + readonly language ?: string | undefined; +} diff --git a/entities/app/AppEntity.test.ts b/entities/app/AppEntity.test.ts new file mode 100644 index 0000000..f747bad --- /dev/null +++ b/entities/app/AppEntity.test.ts @@ -0,0 +1,863 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ComponentDTO } from "../component/ComponentDTO"; +import { + ComponentEntity, +} from "../component/ComponentEntity"; +import { RouteDTO } from "../route/RouteDTO"; +import { RouteEntity } from "../route/RouteEntity"; +import { ViewDTO } from "../view/ViewDTO"; +import { ViewEntity } from "../view/ViewEntity"; +import { App } from "./App"; +import { AppEntity } from "./AppEntity"; + +describe('AppEntity', () => { + + describe('static methods', () => { + + describe('#create', () => { + + it('can create entity', () => { + const entity = AppEntity.create(); + expect(entity).toBeDefined(); + }); + + it('can create entity with name', () => { + const entity = AppEntity.create('Foo'); + expect(entity).toBeDefined(); + }); + + }); + + }); + + describe('standard instance methods', () => { + + describe('.getDTO', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + it('can get the DTO with name property', () => { + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + name: "Foo" + }) + ) + }); + + it('can get the DTO with components property', () => { + const component = ComponentEntity.create('foo').getDTO(); + entity.setComponents([ + component + ]); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + components: expect.arrayContaining( + [ + component + ] + ) + }) + ); + }); + + it('can get the DTO with views property', () => { + const view = ViewEntity.create('foo').getDTO(); + entity.setViews([ + view + ]); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + views: expect.arrayContaining( + [ + view + ] + ) + }) + ); + }); + + it('can get the DTO with routes property', () => { + const route = RouteEntity.create('foo').getDTO(); + entity.setRoutes([ + route + ]); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + routes: expect.arrayContaining( + [ + route + ] + ) + }) + ); + }); + + it('can get the DTO with extend property', () => { + entity.extend('Something'); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + extend: "Something", + }) + ) + }); + + it('can get the DTO with publicUrl property', () => { + entity.publicUrl('http://my.host'); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + publicUrl: "http://my.host", + }) + ) + }); + + it('can get the DTO with language property', () => { + entity.language('es'); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + language: "es", + }) + ) + }); + + }); + + describe('.valueOf', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + it('can get the DTO with name property', () => { + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + name: "Foo" + }) + ) + }); + + it('can get the DTO with components property', () => { + const component = ComponentEntity.create('foo').getDTO(); + entity.setComponents([ + component + ]); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + components: expect.arrayContaining( + [ + component + ] + ) + }) + ); + }); + + it('can get the DTO with views property', () => { + const view = ViewEntity.create('foo').getDTO(); + entity.setViews([ + view + ]); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + views: expect.arrayContaining( + [ + view + ] + ) + }) + ); + }); + + it('can get the DTO with routes property', () => { + const route = RouteEntity.create('foo').getDTO(); + entity.setRoutes([ + route + ]); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + routes: expect.arrayContaining( + [ + route + ] + ) + }) + ); + }); + + it('can get the DTO with extend property', () => { + entity.extend('Something'); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + extend: "Something", + }) + ) + }); + + it('can get the DTO with publicUrl property', () => { + entity.publicUrl('http://my.host'); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + publicUrl: "http://my.host", + }) + ) + }); + + it('can get the DTO with language property', () => { + entity.language('es'); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + language: "es", + }) + ) + }); + + }); + + describe('.toJSON', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + it('can get the DTO with name property', () => { + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + name: "Foo" + }) + ) + }); + + it('can get the DTO with components property', () => { + const component = ComponentEntity.create('foo').getDTO(); + entity.setComponents([ + component + ]); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + components: expect.arrayContaining( + [ + component + ] + ) + }) + ); + }); + + it('can get the DTO with views property', () => { + const view = ViewEntity.create('foo').getDTO(); + entity.setViews([ + view + ]); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + views: expect.arrayContaining( + [ + view + ] + ) + }) + ); + }); + + it('can get the DTO with routes property', () => { + const route = RouteEntity.create('foo').getDTO(); + entity.setRoutes([ + route + ]); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + routes: expect.arrayContaining( + [ + route + ] + ) + }) + ); + }); + + it('can get the DTO with extend property', () => { + entity.extend('Something'); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + extend: "Something", + }) + ) + }); + + it('can get the DTO with publicUrl property', () => { + entity.publicUrl('http://my.host'); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + publicUrl: "http://my.host", + }) + ) + }); + + it('can get the DTO with language property', () => { + entity.language('es'); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + language: "es", + }) + ) + }); + + }); + + }); + + describe('name methods', () => { + + describe('.getName', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + it('can get the name', () => { + expect( entity.getName() ).toEqual('Foo') + }); + + }); + + describe('.setName', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + it('can set the name', () => { + entity.setName('Bar'); + expect( entity.getName() ).toEqual('Bar'); + }); + + }); + + describe('.name', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + it('can set the name', () => { + entity.name('Bar'); + expect( entity.getName() ).toEqual('Bar'); + }); + + }); + + }); + + describe('component methods', () => { + + let component : ComponentDTO; + let component2 : ComponentDTO; + let entity : App; + + beforeEach(() => { + component = ComponentEntity.create('Test').getDTO(); + component2 = ComponentEntity.create('SecondTest').getDTO(); + entity = AppEntity.create('Foo'); + + expect(component).toBeDefined(); + expect(component2).toBeDefined(); + expect(entity).toBeDefined(); + }); + + describe('.getComponents', () => { + + beforeEach(() => { + expect(component).toBeDefined(); + entity = entity.setComponents([component]); + }); + + it('can get the component entity', () => { + const result = entity.getComponents(); + expect( result ).toHaveLength(1); + expect( result[0].getDTO() ).toEqual( + expect.objectContaining( component ) + ); + }); + + }); + + describe('.getComponentsDTO', () => { + + beforeEach(() => { + entity = entity.setComponents([component]); + }); + + it('can get the content with string', () => { + expect( entity.getComponentsDTO() ).toEqual( + expect.arrayContaining([ + component + ]) + ); + }); + + }); + + describe('.setComponents', () => { + + it('can set the content using a string', () => { + + entity.setComponents([component]); + + expect( entity.getComponentsDTO() ).toEqual( + expect.arrayContaining([ + component + ]) + ); + }); + + }); + + describe('.components', () => { + + it('can set the content using a string', () => { + + entity.components([component]); + + expect( entity.getComponentsDTO() ).toEqual( + expect.arrayContaining([ + component + ]) + ); + }); + + }); + + describe('.addComponents', () => { + + beforeEach(() => { + entity.setComponents([component]); + }); + + it('can add more content using a string', () => { + entity.addComponents(component2); + expect( entity.getComponentsDTO() ).toEqual( + expect.arrayContaining([ + component, + component2, + ]) + ); + }); + + }); + + describe('.addComponent', () => { + + beforeEach(() => { + entity.setComponents([component]); + }); + + it('can add more content using a string', () => { + entity.addComponent(component2); + expect( entity.getComponentsDTO() ).toEqual( + expect.arrayContaining([ + component, + component2, + ]) + ); + }); + + }); + + }); + + describe('views methods', () => { + + let view : ViewDTO; + let view2 : ViewDTO; + let entity : App; + + beforeEach(() => { + view = ViewEntity.create('Test').getDTO(); + view2 = ViewEntity.create('SecondTest').getDTO(); + entity = AppEntity.create('Foo'); + + expect(view).toBeDefined(); + expect(view2).toBeDefined(); + expect(entity).toBeDefined(); + }); + + describe('.getViews', () => { + + beforeEach(() => { + expect(view).toBeDefined(); + entity = entity.setViews([view]); + }); + + it('can get the view entity', () => { + const result = entity.getViews(); + expect( result ).toHaveLength(1); + expect( result[0].getDTO() ).toEqual( + expect.objectContaining( view ) + ); + }); + + }); + + describe('.getViewsDTO', () => { + + beforeEach(() => { + entity = entity.setViews([view]); + }); + + it('can get the content with string', () => { + expect( entity.getViewsDTO() ).toEqual( + expect.arrayContaining([ + view + ]) + ); + }); + + }); + + describe('.setViews', () => { + + it('can set the content using a string', () => { + + entity.setViews([view]); + + expect( entity.getViewsDTO() ).toEqual( + expect.arrayContaining([ + view + ]) + ); + }); + + }); + + describe('.views', () => { + + it('can set the content using a string', () => { + + entity.views([view]); + + expect( entity.getViewsDTO() ).toEqual( + expect.arrayContaining([ + view + ]) + ); + }); + + }); + + describe('.addViews', () => { + + beforeEach(() => { + entity.setViews([view]); + }); + + it('can add more content using a string', () => { + entity.addViews(view2); + expect( entity.getViewsDTO() ).toEqual( + expect.arrayContaining([ + view, + view2, + ]) + ); + }); + + }); + + describe('.addView', () => { + + beforeEach(() => { + entity.setViews([view]); + }); + + it('can add more content using a string', () => { + entity.addView(view2); + expect( entity.getViewsDTO() ).toEqual( + expect.arrayContaining([ + view, + view2, + ]) + ); + }); + + }); + + + }); + + describe('routes methods', () => { + + let route : RouteDTO; + let route2 : RouteDTO; + let entity : App; + + beforeEach(() => { + route = RouteEntity.create('Test').getDTO(); + route2 = RouteEntity.create('SecondTest').getDTO(); + entity = AppEntity.create('Foo'); + + expect(route).toBeDefined(); + expect(route2).toBeDefined(); + expect(entity).toBeDefined(); + }); + + describe('.getRoutes', () => { + + beforeEach(() => { + expect(route).toBeDefined(); + entity = entity.setRoutes([route]); + }); + + it('can get the route entity', () => { + const result = entity.getRoutes(); + expect( result ).toHaveLength(1); + expect( result[0].getDTO() ).toEqual( + expect.objectContaining( route ) + ); + }); + + }); + + describe('.getRoutesDTO', () => { + + beforeEach(() => { + entity = entity.setRoutes([route]); + }); + + it('can get the content with string', () => { + expect( entity.getRoutesDTO() ).toEqual( + expect.arrayContaining([ + route + ]) + ); + }); + + }); + + describe('.setRoutes', () => { + + it('can set the content using a string', () => { + + entity.setRoutes([route]); + + expect( entity.getRoutesDTO() ).toEqual( + expect.arrayContaining([ + route + ]) + ); + }); + + }); + + describe('.routes', () => { + + it('can set the content using a string', () => { + + entity.routes([route]); + + expect( entity.getRoutesDTO() ).toEqual( + expect.arrayContaining([ + route + ]) + ); + }); + + }); + + describe('.addRoutes', () => { + + beforeEach(() => { + entity.setRoutes([route]); + }); + + it('can add more content using a string', () => { + entity.addRoutes(route2); + expect( entity.getRoutesDTO() ).toEqual( + expect.arrayContaining([ + route, + route2, + ]) + ); + }); + + }); + + describe('.addRoute', () => { + + beforeEach(() => { + entity.setRoutes([route]); + }); + + it('can add more content using a string', () => { + entity.addRoute(route2); + expect( entity.getRoutesDTO() ).toEqual( + expect.arrayContaining([ + route, + route2, + ]) + ); + }); + + }); + + + }); + + describe('extend methods', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + describe('.getExtend', () => { + + it('can get the extend when it is empty', () => { + expect( entity.getExtend() ).toEqual(undefined); + }); + + it('can get the extend when it is defined', () => { + entity.setExtend('Bar'); + expect( entity.getExtend() ).toEqual('Bar'); + }); + + }); + + describe('.setExtend', () => { + + it('can set the extend', () => { + entity.setExtend('Bar'); + expect( entity.getExtend() ).toEqual('Bar'); + }); + + it('can unset the extend', () => { + entity.setExtend('Bar'); + expect( entity.getExtend() ).toEqual('Bar'); + entity.setExtend(undefined); + expect( entity.getExtend() ).toEqual(undefined); + }); + + }); + + describe('.extend', () => { + + it('can set the extend', () => { + entity.extend('Bar'); + expect( entity.getExtend() ).toEqual('Bar'); + }); + + }); + + }); + + describe('publicUrl methods', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + describe('.getPublicUrl', () => { + + it('can get the publicUrl when it is empty', () => { + expect( entity.getPublicUrl() ).toEqual(undefined); + }); + + it('can get the publicUrl when it is defined', () => { + entity.setPublicUrl('Bar'); + expect( entity.getPublicUrl() ).toEqual('Bar'); + }); + + }); + + describe('.setPublicUrl', () => { + + it('can set the publicUrl', () => { + entity.setPublicUrl('Bar'); + expect( entity.getPublicUrl() ).toEqual('Bar'); + }); + + it('can unset the publicUrl', () => { + entity.setPublicUrl('Bar'); + expect( entity.getPublicUrl() ).toEqual('Bar'); + entity.setPublicUrl(undefined); + expect( entity.getPublicUrl() ).toEqual(undefined); + }); + + }); + + describe('.publicUrl', () => { + + it('can set the publicUrl', () => { + entity.publicUrl('Bar'); + expect( entity.getPublicUrl() ).toEqual('Bar'); + }); + + }); + + }); + + describe('language methods', () => { + + let entity : App; + + beforeEach(() => { + entity = AppEntity.create('Foo'); + }); + + describe('.getLanguage', () => { + + it('can get the language when it is empty', () => { + expect( entity.getLanguage() ).toEqual(undefined); + }); + + it('can get the language when it is defined', () => { + entity.setLanguage('Bar'); + expect( entity.getLanguage() ).toEqual('Bar'); + }); + + }); + + describe('.setLanguage', () => { + + it('can set the language', () => { + entity.setLanguage('Bar'); + expect( entity.getLanguage() ).toEqual('Bar'); + }); + + it('can unset the language', () => { + entity.setLanguage('Bar'); + expect( entity.getLanguage() ).toEqual('Bar'); + entity.setLanguage(undefined); + expect( entity.getLanguage() ).toEqual(undefined); + }); + + }); + + describe('.language', () => { + + it('can set the language', () => { + entity.language('Bar'); + expect( entity.getLanguage() ).toEqual('Bar'); + }); + + }); + + + }); + +}); diff --git a/entities/app/AppEntity.ts b/entities/app/AppEntity.ts new file mode 100644 index 0000000..8f5363d --- /dev/null +++ b/entities/app/AppEntity.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ComponentDTO } from "../component/ComponentDTO"; +import { ComponentEntity } from "../component/ComponentEntity"; +import { RouteDTO } from "../route/RouteDTO"; +import { RouteEntity } from "../route/RouteEntity"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { VariableType } from "../types/VariableType"; +import { ViewDTO } from "../view/ViewDTO"; +import { ViewEntity } from "../view/ViewEntity"; +import { App } from "./App"; +import { AppDTO } from "./AppDTO"; + +export const AppEntityFactory = ( + EntityFactoryImpl.create('App') + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING) ) + .add( EntityFactoryImpl.createArrayProperty("components").setTypes(ComponentEntity) ) + .add( EntityFactoryImpl.createArrayProperty("views").setTypes(ViewEntity) ) + .add( EntityFactoryImpl.createArrayProperty("routes").setTypes(RouteEntity) ) + .add( EntityFactoryImpl.createProperty("extend").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("publicUrl").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("language").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) +); + +export const isAppDTO = AppEntityFactory.createTestFunctionOfDTO(); + +export const explainAppDTO = AppEntityFactory.createExplainFunctionOfDTO(); + +export const isAppDTOOrUndefined = AppEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainAppDTOOrUndefined = AppEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseAppEntity = AppEntityFactory.createEntityType(); + + +export class AppEntity + extends BaseAppEntity + implements App +{ + + public static create (name ?: string) : AppEntity { + return name ? (new this( )).setName(name) : (new this()); + } + + public constructor ( + dto ?: AppDTO, + ) { + super(dto); + } + + public addView (view : ViewDTO | ViewEntity | readonly (ViewDTO | ViewEntity)[]) : this { + return this.addViews(view); + } + + public addComponent (component : ComponentDTO | ComponentEntity | readonly (ComponentDTO | ComponentEntity)[]) : this { + return this.addComponents(component); + } + + public addRoute (route : RouteDTO | RouteEntity | readonly (RouteDTO | RouteEntity)[]) : this { + return this.addRoutes(route); + } + +} + +export function isHyperEntity (value: unknown): value is AppEntity { + return value instanceof AppEntity; +} + diff --git a/entities/background/Background.ts b/entities/background/Background.ts new file mode 100644 index 0000000..0c80396 --- /dev/null +++ b/entities/background/Background.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { BackgroundPositionDTO } from "../backgroundPosition/BackgroundPositionDTO"; +import { BackgroundPositionEntity } from "../backgroundPosition/BackgroundPositionEntity"; +import { ColorEntity } from "../color/ColorEntity"; +import { BackgroundRepeatType } from "../types/BackgroundRepeatType"; +import { BackgroundDTO } from "./BackgroundDTO"; +import { BackgroundImageDTO } from "../backgroundImage/BackgroundImageDTO"; +import { BackgroundRepeatDTO } from "../backgroundRepeat/BackgroundRepeatDTO"; +import { ColorDTO } from "../color/ColorDTO"; +import { BackgroundAttachment } from "../types/BackgroundAttachment"; +import { BackgroundBlendMode } from "../types/BackgroundBlendMode"; +import { BackgroundClip } from "../types/BackgroundClip"; +import { BackgroundOrigin } from "../types/BackgroundOrigin"; +import { BackgroundPositionValue } from "../types/BackgroundPositionValue"; +import { BackgroundSizeOptions } from "../types/BackgroundSizeOptions"; +import { BackgroundImage } from "../backgroundImage/BackgroundImage"; +import { BackgroundRepeat } from "../backgroundRepeat/BackgroundRepeat"; +import { Color } from "../color/Color"; +import { Entity } from "../types/Entity"; + +/** + * Presents a background image value + */ +export interface Background extends Entity { + + /** + * Returns the DTO object. + */ + getDTO () : BackgroundDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + getAttachment () : BackgroundAttachment | undefined; + getBlendMode () : BackgroundBlendMode | undefined; + getClip () : BackgroundClip | undefined; + getColor () : Color | undefined; + getColorDTO () : ColorDTO | undefined; + getImage () : BackgroundImage | undefined; + getImageDTO () : BackgroundImageDTO | undefined; + getOrigin () : BackgroundOrigin | undefined; + getRepeat () : BackgroundRepeat | undefined; + getRepeatDTO () : BackgroundRepeatDTO | undefined; + getSize () : BackgroundSizeOptions | undefined; + + setAttachment (value : BackgroundAttachment | undefined) : this; + setBlendMode (value : BackgroundBlendMode | undefined) : this; + setClip (value : BackgroundClip | undefined) : this; + setColor (value : ColorEntity | Color | ColorDTO | string | undefined) : this; + setTransparentColor () : this; + setSetTransparentColor () : this; + setImage (value : BackgroundImage | BackgroundImageDTO | undefined) : this; + setOrigin (value : BackgroundOrigin | undefined) : this; + setRepeat (value : BackgroundRepeat | BackgroundRepeatDTO | undefined) : this; + setSize (value : BackgroundSizeOptions | undefined) : this; + + attachment (value : BackgroundAttachment | undefined) : this; + blendMode (value : BackgroundBlendMode | undefined) : this; + clip (value : BackgroundClip | undefined) : this; + color (value : ColorEntity | Color | ColorDTO | string | undefined) : this; + transparentColor () : this; + setTransparentColor () : this; + image (value : BackgroundImage | BackgroundImageDTO | undefined) : this; + origin (value : BackgroundOrigin | undefined) : this; + + repeat (value : BackgroundRepeat | BackgroundRepeatDTO | BackgroundRepeatType | undefined) : this; + size (value : BackgroundSizeOptions | undefined) : this; + + + getPosition () : BackgroundPositionEntity | undefined; + getPositionDTO () : BackgroundPositionDTO | undefined; + setPosition (value : BackgroundPositionValue | BackgroundPositionEntity | BackgroundPositionDTO | undefined) : this; + position (value : BackgroundPositionValue | BackgroundPositionEntity | BackgroundPositionDTO | undefined) : this; + +} + +export function isBackground (value : unknown) : value is Background { + return ( + isObject(value) + && isFunction(value?.getDTO) + && isFunction(value?.valueOf) + && isFunction(value?.toJSON) + && isFunction(value?.getCssStyles) + && isFunction(value?.getUrl) + && isFunction(value?.url) + ); +} + diff --git a/entities/background/BackgroundDTO.ts b/entities/background/BackgroundDTO.ts new file mode 100644 index 0000000..4765aa5 --- /dev/null +++ b/entities/background/BackgroundDTO.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { BackgroundImageDTO} from "../backgroundImage/BackgroundImageDTO"; +import { BackgroundPositionDTO } from "../backgroundPosition/BackgroundPositionDTO"; +import { ColorDTO } from "../color/ColorDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { SizeDimensionsDTO } from "../sizeDimensions/SizeDimensionsDTO"; +import { BackgroundAttachment } from "../types/BackgroundAttachment"; +import { BackgroundBlendMode } from "../types/BackgroundBlendMode"; +import { BackgroundClip } from "../types/BackgroundClip"; +import { BackgroundOrigin } from "../types/BackgroundOrigin"; +import { BackgroundRepeatDTO } from "../backgroundRepeat/BackgroundRepeatDTO"; +import { BackgroundSize } from "../types/BackgroundSize"; +import { DTO } from "../types/DTO"; + +export interface BackgroundDTO extends DTO { + readonly attachment ?: BackgroundAttachment; + readonly blendMode ?: BackgroundBlendMode; + readonly clip ?: BackgroundClip; + readonly color ?: ColorDTO; + readonly image ?: BackgroundImageDTO; + readonly origin ?: BackgroundOrigin; + readonly position ?: BackgroundPositionDTO; + readonly repeat ?: BackgroundRepeatDTO; + readonly size ?: BackgroundSize | SizeDTO | SizeDimensionsDTO; +} diff --git a/entities/background/BackgroundEntity.test.ts b/entities/background/BackgroundEntity.test.ts new file mode 100644 index 0000000..47a6315 --- /dev/null +++ b/entities/background/BackgroundEntity.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { describe, it, expect } from '@jest/globals'; +import { ColorDTO } from "../color/ColorDTO"; +import { BackgroundDTO } from "./BackgroundDTO"; +import { BackgroundEntity } from "./BackgroundEntity"; + +describe('BackgroundEntity', () => { + + describe('#create', () => { + it('can create an entity', () => { + let obj = BackgroundEntity.create(); + expect(obj.getDTO()).toStrictEqual({}); + }); + }); + + describe('#isDTO', () => { + + it('can test a DTO with color', () => { + const color : ColorDTO = { + value: "#222222" + }; + const dto : BackgroundDTO = { + color + }; + expect( BackgroundEntity.isDTO(dto) ).toStrictEqual(true); + }); + + }); + + describe('.getDTO', () => { + it('can get DTO', () => { + let obj = BackgroundEntity.create().setColor({value: '#333'}); + expect(obj.getDTO()).toStrictEqual({ + color: { + value: '#333', + } + }); + }); + }); + + describe('.setColor', () => { + it('can set color', () => { + let entity = BackgroundEntity.create().setColor({value: '#444'}); + expect(entity.getDTO()).toStrictEqual({ + color: { + value: '#444', + } + }); + }); + }); + + describe('.setTransparentColor', () => { + it('can set transparent color', () => { + let entity = BackgroundEntity.create().setTransparentColor(); + expect(entity.getDTO()).toStrictEqual({ + color: { + value: 'transparent', + } + }); + }); + }); + + describe('.transparentColor', () => { + it('can set transparent color', () => { + let entity = BackgroundEntity.create().transparentColor(); + expect(entity.getDTO()).toStrictEqual({ + color: { + value: 'transparent', + } + }); + }); + }); + +}); diff --git a/entities/background/BackgroundEntity.ts b/entities/background/BackgroundEntity.ts new file mode 100644 index 0000000..7177d1a --- /dev/null +++ b/entities/background/BackgroundEntity.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { BackgroundPositionDTO } from "../backgroundPosition/BackgroundPositionDTO"; +import { BackgroundPositionEntity } from "../backgroundPosition/BackgroundPositionEntity"; +import { BackgroundPositionValue } from "../types/BackgroundPositionValue"; +import { BackgroundSize } from "../types/BackgroundSize"; +import { ReadonlyJsonObject } from "../../Json"; +import { VariableType } from "../types/VariableType"; +import { BackgroundDTO } from "./BackgroundDTO"; +import { BackgroundImageDTO } from "../backgroundImage/BackgroundImageDTO"; +import { BackgroundRepeatDTO } from "../backgroundRepeat/BackgroundRepeatDTO"; +import { ColorDTO } from "../color/ColorDTO"; +import { BackgroundAttachment } from "../types/BackgroundAttachment"; +import { BackgroundBlendMode } from "../types/BackgroundBlendMode"; +import { BackgroundClip } from "../types/BackgroundClip"; +import { BackgroundOrigin } from "../types/BackgroundOrigin"; +import { BackgroundSizeOptions, getCssStylesForBackgroundSizeOptions } from "../types/BackgroundSizeOptions"; +import { BackgroundImageEntity } from "../backgroundImage/BackgroundImageEntity"; +import { BackgroundRepeatEntity } from "../backgroundRepeat/BackgroundRepeatEntity"; +import { ColorEntity } from "../color/ColorEntity"; +import { SizeDimensionsEntity } from "../sizeDimensions/SizeDimensionsEntity"; +import { SizeEntity } from "../size/SizeEntity"; +import { Background } from "./Background"; +import { BackgroundImage } from "../backgroundImage/BackgroundImage"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; + +export const BackgroundEntityFactory = ( + EntityFactoryImpl.create('Background') + .add( EntityFactoryImpl.createProperty("attachment").setTypes(BackgroundAttachment, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("blendMode").setTypes(BackgroundBlendMode, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("clip").setTypes(BackgroundClip, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("color").setTypes(ColorEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("image").setTypes(BackgroundImageEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("origin").setTypes(BackgroundOrigin, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("position").setTypes(BackgroundPositionEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("repeat").setTypes(BackgroundRepeatEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("size").setTypes(BackgroundSize, SizeEntity, SizeDimensionsEntity, VariableType.UNDEFINED) ) +); + +export const BaseBackgroundEntity = BackgroundEntityFactory.createEntityType(); + +/** + * Background entity. + */ +export class BackgroundEntity + extends BaseBackgroundEntity + implements Background +{ + + /** + * Creates a background entity. + * + */ + public static create () : BackgroundEntity { + return new BackgroundEntity(); + } + + /** + * Creates a background entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : BackgroundDTO, + ) : BackgroundEntity { + return new BackgroundEntity(value); + } + + /** + */ + public static attachment (value : BackgroundAttachment | undefined) : BackgroundEntity { + return this.create().attachment(value); + } + + /** + */ + public static blendMode (value : BackgroundBlendMode | undefined) : BackgroundEntity { + return this.create().blendMode(value); + } + + /** + */ + public static clip (value : BackgroundClip | undefined) : BackgroundEntity { + return this.create().clip(value); + } + + /** + */ + public static color (value : ColorEntity | ColorDTO | undefined) : BackgroundEntity { + return this.create().setColor(value); + } + + /** + */ + public static image (value : BackgroundImageEntity | BackgroundImage | BackgroundImageDTO | undefined) : BackgroundEntity { + return this.create().image(value); + } + + /** + */ + public static imageUrl (value : string) : BackgroundEntity { + return this.create().imageUrl(value); + } + + /** + */ + public static origin (value : BackgroundOrigin | undefined) : BackgroundEntity { + return this.create().origin(value); + } + + /** + */ + public static position (value : BackgroundPositionValue | BackgroundPositionDTO | BackgroundPositionEntity | undefined) : BackgroundEntity { + return this.create().setPosition(value); + } + + /** + */ + public static repeat (value : BackgroundRepeatDTO | undefined) : BackgroundEntity { + return this.create().repeat(value); + } + + /** + */ + public static size (value : BackgroundSizeOptions | undefined) : BackgroundEntity { + return this.create().size(value); + } + + + public constructor ( + dto ?: BackgroundDTO + ) { + super(dto); + } + + /** + * @inheritDoc + */ + public getCssStyles (): ReadonlyJsonObject { + const attachment = this.getAttachment(); + const blendMode = this.getBlendMode(); + const clip = this.getClip(); + const color = this.getColor(); + const image = this.getImage(); + const origin = this.getOrigin(); + const position = this.getPosition(); + const repeat = this.getRepeat(); + const size = this.getSize(); + return { + ...(attachment ? { backgroundAttachment: attachment } : {}), + ...(blendMode ? { backgroundBlendMode: blendMode } : {}), + ...(clip ? { backgroundClip: clip } : {}), + ...(color ? { backgroundColor: color.getCssStyles() } : {}), + ...(image ? { backgroundImage: image.getCssStyles() } : {}), + ...(origin ? { backgroundOrigin: origin } : {}), + ...(position ? position.getCssStyles() : {}), + ...(repeat ? { backgroundRepeat: repeat.getCssStyles() } : {}), + ...(size ? { backgroundSize: getCssStylesForBackgroundSizeOptions(size) } : {}), + }; + } + + /** + * @inheritDoc + */ + public imageUrl ( + value : string, + ) : this { + return this.setImage( BackgroundImageEntity.url(value) ); + } + + /** + * @inheritDoc + */ + public setTransparentColor () : this { + return this.setColor('transparent'); + } + + /** + * @inheritDoc + */ + public transparentColor () : this { + return this.setTransparentColor(); + } + +} + +export function isBackgroundEntity (value: unknown): value is BackgroundEntity { + return value instanceof BackgroundEntity; +} diff --git a/entities/backgroundImage/BackgroundImage.ts b/entities/backgroundImage/BackgroundImage.ts new file mode 100644 index 0000000..ce96bed --- /dev/null +++ b/entities/backgroundImage/BackgroundImage.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { BackgroundImageDTO } from "./BackgroundImageDTO"; +import { Entity } from "../types/Entity"; + +/** + * Presents a background image value + */ +export interface BackgroundImage extends Entity { + + /** + * Returns the DTO object. + */ + getDTO () : BackgroundImageDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : string; + + /** + * Get url. + */ + getUrl () : string; + + /** + * Set image by URL. + * + * @param value + * @param unit + */ + url (value : string) : this; + +} diff --git a/entities/backgroundImage/BackgroundImageDTO.ts b/entities/backgroundImage/BackgroundImageDTO.ts new file mode 100644 index 0000000..9fe6111 --- /dev/null +++ b/entities/backgroundImage/BackgroundImageDTO.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DTO } from "../types/DTO"; + +export interface BackgroundImageDTO extends DTO { + readonly url: string; +} diff --git a/entities/backgroundImage/BackgroundImageEntity.ts b/entities/backgroundImage/BackgroundImageEntity.ts new file mode 100644 index 0000000..a13b35c --- /dev/null +++ b/entities/backgroundImage/BackgroundImageEntity.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { VariableType } from "../types/VariableType"; +import { + BackgroundImageDTO, +} from "./BackgroundImageDTO"; +import { isString } from "../../types/String"; +import { + BackgroundImage, +} from "./BackgroundImage"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; + +export const BackgroundImageEntityFactory = ( + EntityFactoryImpl.create('BackgroundImage') + .add( EntityFactoryImpl.createProperty("url").setTypes(VariableType.STRING) ) +); + +export const BaseBackgroundImageEntity = BackgroundImageEntityFactory.createEntityType(); + +export const isBackgroundImageDTO = BackgroundImageEntityFactory.createTestFunctionOfDTO(); + +export const isBackgroundImage = BackgroundImageEntityFactory.createTestFunctionOfInterface(); + +export const explainBackgroundImageDTO = BackgroundImageEntityFactory.createExplainFunctionOfDTO(); + +export const isBackgroundImageDTOOrUndefined = BackgroundImageEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainBackgroundImageDTOOrUndefined = BackgroundImageEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + + +/** + * Background image entity. + */ +export class BackgroundImageEntity + extends BaseBackgroundImageEntity + implements BackgroundImage +{ + + /** + * Creates a background image entity. + * + * @param url + */ + public static create ( + url ?: string | undefined, + ) : BackgroundImageEntity { + return new BackgroundImageEntity( url ); + } + + /** + * Creates a background image entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : BackgroundImageDTO, + ) : BackgroundImageEntity { + return new BackgroundImageEntity( value ); + } + + public static url ( + url : string, + ) : BackgroundImageEntity { + return new BackgroundImageEntity( url ); + } + + public constructor ( + arg ?: string | BackgroundImage | BackgroundImageEntity | BackgroundImageDTO | undefined, + ) { + if (arg === undefined) { + super(); + } else if (isString(arg)) { + super( { url: arg }); + } else if (isBackgroundImageDTO(arg)) { + super(arg); + } else if (isBackgroundImageEntity(arg) || isBackgroundImage(arg)) { + super(arg.getDTO()); + } else { + throw new TypeError(`new BackgroundImageEntity(): Unsupported runtime type: ${arg}`); + } + } + + /** + * @inheritDoc + */ + public getCssStyles (): string { + return `url(${this.getUrl()})`; + } + +} + +export function isBackgroundImageEntity (value: unknown): value is BackgroundImageEntity { + return value instanceof BackgroundImageEntity; +} diff --git a/entities/backgroundPosition/.DS_Store b/entities/backgroundPosition/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/entities/backgroundPosition/.DS_Store differ diff --git a/entities/backgroundPosition/BackgroundPosition.ts b/entities/backgroundPosition/BackgroundPosition.ts new file mode 100644 index 0000000..eb0683a --- /dev/null +++ b/entities/backgroundPosition/BackgroundPosition.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { SizeDTO } from "../size/SizeDTO"; +import { SizeEntity } from "../size/SizeEntity"; +import { BackgroundPositionValue } from "../types/BackgroundPositionValue"; +import { Entity } from "../types/Entity"; +import { BackgroundPositionDTO } from "./BackgroundPositionDTO"; + +/** + * Presents an interface for SeoEntity. + */ +export interface BackgroundPosition extends Entity { + + /** + * @inheritDoc + */ + valueOf () : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + getDTO () : BackgroundPositionDTO; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + + /** + */ + getPosition () : BackgroundPositionValue | undefined; + + /** + * @param value + */ + setPosition (value : BackgroundPositionValue | undefined) : this; + + /** + * An alias for `.setPosition(value)`. + * + * @param value + */ + position (value : BackgroundPositionValue | undefined) : this; + + + /** + */ + getSize () : SizeEntity | undefined; + + /** + */ + getSizeDTO () : SizeDTO | undefined; + + /** + * @param value + */ + setSize (value : SizeEntity | undefined) : this; + + /** + * An alias for `.setSize(value)`. + * + * @param value + */ + size (value : SizeEntity | SizeDTO | undefined) : this; + + + /** + */ + getSecondPosition () : BackgroundPositionValue | undefined; + + /** + * @param value + */ + setSecondPosition (value : BackgroundPositionValue | undefined) : this; + + /** + * An alias for `.setPosition(value)`. + * + * @param value + */ + secondPosition (value : BackgroundPositionValue | undefined) : this; + + + /** + */ + getSecondSize () : SizeEntity | undefined; + + /** + */ + getSecondSizeDTO () : SizeDTO | undefined; + + /** + * @param value + */ + setSecondSize (value : SizeEntity | SizeDTO | undefined) : this; + + /** + * An alias for `.setSize(value)`. + * + * @param value + */ + secondSize (value : SizeEntity | SizeDTO | undefined) : this; + + +} diff --git a/entities/backgroundPosition/BackgroundPositionDTO.ts b/entities/backgroundPosition/BackgroundPositionDTO.ts new file mode 100644 index 0000000..9bb16c4 --- /dev/null +++ b/entities/backgroundPosition/BackgroundPositionDTO.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + SizeDTO, +} from "../size/SizeDTO"; +import { BackgroundPositionValue } from "../types/BackgroundPositionValue"; +import { DTO } from "../types/DTO"; + +export interface BackgroundPositionDTO extends DTO { + readonly position ?: BackgroundPositionValue; + readonly size ?: SizeDTO; + readonly secondPosition ?: BackgroundPositionValue; + readonly secondSize ?: SizeDTO; +} diff --git a/entities/backgroundPosition/BackgroundPositionEntity.test.ts b/entities/backgroundPosition/BackgroundPositionEntity.test.ts new file mode 100644 index 0000000..1fab50e --- /dev/null +++ b/entities/backgroundPosition/BackgroundPositionEntity.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SizeEntity } from "../size/SizeEntity"; +import { BackgroundPositionValue } from "../types/BackgroundPositionValue"; +import { BackgroundPositionEntity } from "./BackgroundPositionEntity"; + +describe('BackgroundPositionEntity', () => { + + describe('.getCssStyles', () => { + + let entity : BackgroundPositionEntity; + + beforeEach(() => { + entity = BackgroundPositionEntity.create(); + }); + + it('can get styles for default value', () => { + expect( entity.getCssStyles() ).toStrictEqual({ + }); + }); + + it('can get styles for center position', () => { + entity.setPosition(BackgroundPositionValue.CENTER); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'center' + }); + }); + + it('can get styles for left position', () => { + entity.setPosition(BackgroundPositionValue.LEFT); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'left' + }); + }); + + it('can get styles for right position', () => { + entity.setPosition(BackgroundPositionValue.RIGHT); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'right' + }); + }); + + it('can get styles for top position', () => { + entity.setPosition(BackgroundPositionValue.TOP); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'top' + }); + }); + + it('can get styles for bottom position', () => { + entity.setPosition(BackgroundPositionValue.BOTTOM); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'bottom' + }); + }); + + it('can get styles for top position with 10 px', () => { + entity.setPosition(BackgroundPositionValue.BOTTOM); + entity.setSize(SizeEntity.createPx(10)); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'bottom 10px' + }); + }); + + it('can get styles for 10 px', () => { + entity.setSize(SizeEntity.createPx(10)); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: '10px' + }); + }); + + it('can get styles for 10 px, 20 px', () => { + entity.setSize(SizeEntity.createPx(10)); + entity.setSecondSize(SizeEntity.createPx(20)); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: '10px 20px' + }); + }); + + it('can get styles for 10 px, 20 px', () => { + entity.setSize(SizeEntity.createPercent(10)); + entity.setSecondSize(SizeEntity.createPercent(20)); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: '10% 20%' + }); + }); + + it('can get styles for top 10 px, left', () => { + entity.setPosition(BackgroundPositionValue.TOP); + entity.setSize(SizeEntity.createPx(10)); + entity.setSecondPosition(BackgroundPositionValue.LEFT); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'top 10px left' + }); + }); + + it('can get styles for top 10 px, left 20px', () => { + entity.setPosition(BackgroundPositionValue.TOP); + entity.setSize(SizeEntity.createPx(10)); + entity.setSecondPosition(BackgroundPositionValue.LEFT); + entity.setSecondSize(SizeEntity.createPx(20)); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'top 10px left 20px' + }); + }); + + it('can get styles for top left 20px', () => { + entity.setPosition(BackgroundPositionValue.TOP); + entity.setSecondPosition(BackgroundPositionValue.LEFT); + entity.setSecondSize(SizeEntity.createPx(20)); + expect( entity.getCssStyles() ).toStrictEqual({ + backgroundPosition: 'top left 20px' + }); + }); + + }); + +}); diff --git a/entities/backgroundPosition/BackgroundPositionEntity.ts b/entities/backgroundPosition/BackgroundPositionEntity.ts new file mode 100644 index 0000000..d445131 --- /dev/null +++ b/entities/backgroundPosition/BackgroundPositionEntity.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { reduce } from "../../functions/reduce"; +import { ReadonlyJsonObject } from "../../Json"; +import { SizeEntity } from "../size/SizeEntity"; +import { BackgroundPositionValue } from "../types/BackgroundPositionValue"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { VariableType } from "../types/VariableType"; +import { BackgroundPosition } from "./BackgroundPosition"; +import { BackgroundPositionDTO } from "./BackgroundPositionDTO"; + +export const BackgroundPositionEntityFactory = ( + EntityFactoryImpl.create('BackgroundPosition') + .add( EntityFactoryImpl.createProperty("position").setTypes(BackgroundPositionValue, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("size").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("secondPosition").setTypes(BackgroundPositionValue, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("secondSize").setTypes(SizeEntity, VariableType.UNDEFINED) ) +); + +export const isBackgroundPositionDTO = BackgroundPositionEntityFactory.createTestFunctionOfDTO(); + +export const isBackgroundPosition = BackgroundPositionEntityFactory.createTestFunctionOfInterface(); + +export const explainBackgroundPositionDTO = BackgroundPositionEntityFactory.createExplainFunctionOfDTO(); + +export const isBackgroundPositionDTOOrUndefined = BackgroundPositionEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainBackgroundPositionDTOOrUndefined = BackgroundPositionEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseBackgroundPositionEntity = BackgroundPositionEntityFactory.createEntityType(); + +/** + * BackgroundPosition entity. + */ +export class BackgroundPositionEntity + extends BaseBackgroundPositionEntity + implements BackgroundPosition +{ + + /** + * Creates a BackgroundPosition entity. + * + * @param value The optional DTO of BackgroundPosition + */ + public static create ( + value ?: BackgroundPositionDTO, + ) : BackgroundPositionEntity { + return new BackgroundPositionEntity(value); + } + + /** + * Creates a BackgroundPosition entity from DTO. + * + * @param dto The optional DTO of BackgroundPosition + */ + public static createFromDTO ( + dto : BackgroundPositionDTO, + ) : BackgroundPositionEntity { + return new BackgroundPositionEntity(dto); + } + + /** + * Merges multiple values as one entity. + */ + public static merge ( + ...values: readonly (BackgroundPositionDTO | BackgroundPosition | BackgroundPositionEntity)[] + ) : BackgroundPositionEntity { + return BackgroundPositionEntity.createFromDTO( + reduce( + values, + ( + prev: BackgroundPositionDTO, + item: BackgroundPositionDTO | BackgroundPosition | BackgroundPositionEntity, + ) : BackgroundPositionDTO => { + const dto : BackgroundPositionDTO = this.toDTO(item); + return { + ...prev, + ...dto, + }; + }, + {}, + ) + ); + } + + /** + * Normalizes the value as a DTO. + */ + public static toDTO ( + value: BackgroundPositionDTO | BackgroundPosition | BackgroundPositionEntity, + ) : BackgroundPositionDTO { + if (isBackgroundPositionEntity(value)) { + return value.getDTO(); + } else if (isBackgroundPosition(value)) { + return value.getDTO(); + } else { + return value; + } + } + + /** + * Construct an entity of BackgroundPositionEntity. + */ + public constructor ( + dto ?: BackgroundPositionDTO | undefined, + ) { + super(dto); + } + + /** + * @inheritDoc + */ + public getCssStyles (): ReadonlyJsonObject { + + const position = this.getPosition(); + const size = this.getSize()?.getCssStyles(); + const secondPosition = this.getSecondPosition(); + const secondSize = this.getSecondSize()?.getCssStyles(); + + if ( position && size && secondPosition && secondSize ) { + return { + backgroundPosition: `${position} ${size} ${secondPosition} ${secondSize}` + }; + } + + if ( position && secondPosition && secondSize ) { + return { + backgroundPosition: `${position} ${secondPosition} ${secondSize}` + }; + } + + if ( position && size && secondPosition ) { + return { + backgroundPosition: `${position} ${size} ${secondPosition}` + }; + } + + if ( position && size ) { + return { + backgroundPosition : `${ position } ${ size }` + }; + } + + if ( size && secondSize ) { + return { + backgroundPosition : `${ size } ${ secondSize }` + }; + } + + if ( size ) { + return { + backgroundPosition : `${ size }` + }; + } + + if ( position ) { + return { + backgroundPosition : `${ position }` + }; + } + + return {}; + } + +} + +export function isBackgroundPositionEntity (value: unknown): value is BackgroundPositionEntity { + return value instanceof BackgroundPositionEntity; +} diff --git a/entities/backgroundRepeat/BackgroundRepeat.ts b/entities/backgroundRepeat/BackgroundRepeat.ts new file mode 100644 index 0000000..68cd029 --- /dev/null +++ b/entities/backgroundRepeat/BackgroundRepeat.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { BackgroundRepeatDTO } from "./BackgroundRepeatDTO"; +import { BackgroundRepeatType } from "../types/BackgroundRepeatType"; +import { Entity } from "../types/Entity"; + +/** + * Presents a color value + */ +export interface BackgroundRepeat extends Entity { + + /** + * Returns the DTO object. + */ + getDTO () : BackgroundRepeatDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : string; + + /** + * Get x. + */ + getX () : BackgroundRepeatType; + + /** + * Get y. + */ + getY () : BackgroundRepeatType; + + /** + * Set x. + * + * @param value + * @param unit + */ + x ( + value : BackgroundRepeatType, + ) : this; + + /** + * Set y. + * + * @param value + * @param unit + */ + y ( + value : BackgroundRepeatType, + ) : this; + + repeatX() : this; + repeatY() : this; + repeat() : this; + space() : this; + round() : this; + noRepeat() : this; + +} + +export function isBackgroundRepeat (value : unknown) : value is BackgroundRepeat { + return ( + isObject(value) + && isFunction(value?.getDTO) + && isFunction(value?.valueOf) + && isFunction(value?.toJSON) + && isFunction(value?.getCssStyles) + && isFunction(value?.getX) + && isFunction(value?.getY) + && isFunction(value?.x) + && isFunction(value?.y) + ); +} + diff --git a/entities/backgroundRepeat/BackgroundRepeatDTO.ts b/entities/backgroundRepeat/BackgroundRepeatDTO.ts new file mode 100644 index 0000000..a6a76b2 --- /dev/null +++ b/entities/backgroundRepeat/BackgroundRepeatDTO.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { BackgroundRepeatType } from "../types/BackgroundRepeatType"; +import { DTO } from "../types/DTO"; + +export interface BackgroundRepeatDTO extends DTO { + readonly x: BackgroundRepeatType; + readonly y: BackgroundRepeatType; +} diff --git a/entities/backgroundRepeat/BackgroundRepeatEntity.ts b/entities/backgroundRepeat/BackgroundRepeatEntity.ts new file mode 100644 index 0000000..2e695f4 --- /dev/null +++ b/entities/backgroundRepeat/BackgroundRepeatEntity.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { EntityMethodImpl } from "../types/EntityMethodImpl"; +import { VariableType } from "../types/VariableType"; +import { + BackgroundRepeatDTO, +} from "./BackgroundRepeatDTO"; +import { + BackgroundRepeatType, + isBackgroundRepeatType, +} from "../types/BackgroundRepeatType"; +import { BackgroundRepeat } from "./BackgroundRepeat"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; + +export const BackgroundRepeatEntityFactory = ( + EntityFactoryImpl.create("BackgroundRepeat") + .addStaticMethod( + EntityMethodImpl.create('create') + .addArgument(BackgroundRepeatType) + .returnType('BackgroundRepeat') + ) + .addStaticMethod( + EntityMethodImpl.create('create') + .addArgument(BackgroundRepeatType) + .addArgument(BackgroundRepeatType) + .returnType('BackgroundRepeat') + ) + .add( EntityFactoryImpl.createProperty("x").setTypes(BackgroundRepeatType) ) + .add( EntityFactoryImpl.createProperty("y").setTypes(BackgroundRepeatType) ) +); + +export const isBackgroundRepeatDTO = BackgroundRepeatEntityFactory.createTestFunctionOfDTO(); + +export const isBackgroundRepeat = BackgroundRepeatEntityFactory.createTestFunctionOfInterface(); + +export const explainBackgroundRepeatDTO = BackgroundRepeatEntityFactory.createExplainFunctionOfDTO(); + +export const isBackgroundRepeatDTOOrUndefined = BackgroundRepeatEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainBackgroundRepeatDTOOrUndefined = BackgroundRepeatEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseBackgroundRepeatEntity = BackgroundRepeatEntityFactory.createEntityType(); + +/** + * Background repeat entity. + */ +export class BackgroundRepeatEntity + extends BaseBackgroundRepeatEntity + implements BackgroundRepeat +{ + + /** + * Creates a background repeat entity. + * + * @param x + * @param y + */ + public static create ( + x : BackgroundRepeatType | undefined = undefined, + y : BackgroundRepeatType | undefined = undefined, + ) : BackgroundRepeatEntity { + return new BackgroundRepeatEntity( + x ?? BackgroundRepeatType.NO_REPEAT, + y ?? BackgroundRepeatType.NO_REPEAT, + ); + } + + /** + * Creates a background repeat entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : BackgroundRepeatDTO, + ) : BackgroundRepeatEntity { + return BackgroundRepeatEntity.create( + value?.x, + value?.y, + ); + } + + public static x (x : BackgroundRepeatType | undefined) : BackgroundRepeatEntity { + return BackgroundRepeatEntity.create(x, BackgroundRepeatType.NO_REPEAT); + } + + public static y (y : BackgroundRepeatType | undefined) : BackgroundRepeatEntity { + return BackgroundRepeatEntity.create(BackgroundRepeatType.NO_REPEAT, y); + } + + public static repeatX() : BackgroundRepeatEntity { + return this.create(BackgroundRepeatType.REPEAT, BackgroundRepeatType.NO_REPEAT); + } + + public static repeatY() : BackgroundRepeatEntity { + return this.create(BackgroundRepeatType.NO_REPEAT, BackgroundRepeatType.REPEAT); + } + + public static repeat() : BackgroundRepeatEntity { + return this.create(BackgroundRepeatType.REPEAT, BackgroundRepeatType.REPEAT); + } + + public static space() : BackgroundRepeatEntity { + return this.create(BackgroundRepeatType.SPACE, BackgroundRepeatType.SPACE); + } + + public static round() : BackgroundRepeatEntity { + return this.create(BackgroundRepeatType.ROUND, BackgroundRepeatType.ROUND); + } + + public static noRepeat() : BackgroundRepeatEntity { + return this.create(BackgroundRepeatType.NO_REPEAT, BackgroundRepeatType.NO_REPEAT); + } + + public constructor ( + x ?: BackgroundRepeatType | BackgroundRepeatDTO, + y ?: BackgroundRepeatType, + ) { + if (x === undefined) { + super(); + } else if ( isBackgroundRepeatType(x) && isBackgroundRepeatType(y) ) { + super( { x, y } ); + } else if (isBackgroundRepeatDTO(x) ) { + super( x ); + } else { + throw new TypeError(`new BackgroundRepeatEntity(): Unsupported arguments: ${x}, ${y}`); + } + } + + /** + * @inheritDoc + */ + public getCssStyles (): string { + + const x = this.getX(); + const y = this.getY(); + + if (x === BackgroundRepeatType.REPEAT && y === BackgroundRepeatType.NO_REPEAT) { + return "repeat-x"; + } + if (x === BackgroundRepeatType.REPEAT && y === BackgroundRepeatType.REPEAT) { + return "repeat"; + } + + if (x === BackgroundRepeatType.NO_REPEAT && y === BackgroundRepeatType.REPEAT) { + return "repeat-y"; + } + if (x === BackgroundRepeatType.NO_REPEAT && y === BackgroundRepeatType.NO_REPEAT) { + return "no-repeat"; + } + + if (x === BackgroundRepeatType.SPACE && y === BackgroundRepeatType.SPACE) { + return "space"; + } + + if (x === BackgroundRepeatType.ROUND && y === BackgroundRepeatType.ROUND) { + return "round"; + } + + return `${x} ${y}`; + } + + /** + * @inheritDoc + */ + public repeatX() : this { + return this.x(BackgroundRepeatType.REPEAT).y(BackgroundRepeatType.NO_REPEAT); + } + + /** + * @inheritDoc + */ + public repeatY() : this { + return this.x(BackgroundRepeatType.NO_REPEAT).y(BackgroundRepeatType.REPEAT); + } + + /** + * @inheritDoc + */ + public repeat() : this { + return this.x(BackgroundRepeatType.REPEAT).y(BackgroundRepeatType.REPEAT); + } + + /** + * @inheritDoc + */ + public space() : this { + return this.x(BackgroundRepeatType.SPACE).y(BackgroundRepeatType.SPACE); + } + + /** + * @inheritDoc + */ + public round() : this { + return this.x(BackgroundRepeatType.ROUND).y(BackgroundRepeatType.ROUND); + } + + /** + * @inheritDoc + */ + public noRepeat() : this { + return this.x(BackgroundRepeatType.NO_REPEAT).y(BackgroundRepeatType.NO_REPEAT); + } + +} + +export function isBackgroundRepeatEntity (value: unknown): value is BackgroundRepeatEntity { + return value instanceof BackgroundRepeatEntity; +} diff --git a/entities/border/Border.ts b/entities/border/Border.ts new file mode 100644 index 0000000..d504cb0 --- /dev/null +++ b/entities/border/Border.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { BorderDTO } from "./BorderDTO"; +import { ColorDTO } from "../color/ColorDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { BorderStyle } from "../types/BorderStyle"; +import { ColorEntity } from "../color/ColorEntity"; +import { Color } from "../color/Color"; +import { Entity } from "../types/Entity"; +import { Size } from "../size/Size"; + +/** + * Presents a border value + */ +export interface Border extends Entity { + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + getDTO () : BorderDTO; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + setStyle (value : BorderStyle) : this; + getStyle () : BorderStyle | undefined; + + setWidth (value : Size | SizeDTO | number | undefined) : this; + getWidth () : Size | undefined; + getWidthDTO () : SizeDTO | undefined; + + setRadius (value : Size | SizeDTO | number | undefined) : this; + getRadius () : Size | undefined; + getRadiusDTO () : SizeDTO | undefined; + + setColor (value : Color | ColorDTO | ColorEntity | string) : this; + getColor () : Color | undefined; + getColorDTO () : ColorDTO | undefined; + +} + +export function isBorder ( + value: unknown +): value is Border { + return ( + isObject(value) + && isFunction(value?.valueOf) + && isFunction(value?.getDTO) + && isFunction(value?.toJSON) + && isFunction(value?.getCssStyles) + && isFunction(value?.setStyle) + && isFunction(value?.getStyle) + && isFunction(value?.setWidth) + && isFunction(value?.getWidth) + && isFunction(value?.setColor) + && isFunction(value?.getColor) + ); +} diff --git a/entities/border/BorderDTO.ts b/entities/border/BorderDTO.ts new file mode 100644 index 0000000..84a16fe --- /dev/null +++ b/entities/border/BorderDTO.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ColorDTO } from "../color/ColorDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { BorderStyle } from "../types/BorderStyle"; +import { DTO } from "../types/DTO"; + +export interface BorderDTO extends DTO { + + /** + * Defaults to NONE + */ + readonly style ?: BorderStyle | undefined; + + readonly width ?: SizeDTO | undefined; + + readonly radius ?: SizeDTO | undefined; + + readonly color ?: ColorDTO | undefined; + +} diff --git a/entities/border/BorderEntity.ts b/entities/border/BorderEntity.ts new file mode 100644 index 0000000..1fe7332 --- /dev/null +++ b/entities/border/BorderEntity.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { LogUtils } from "../../LogUtils"; +import { isNumber } from "../../types/Number"; +import { EntityMethodImpl } from "../types/EntityMethodImpl"; +import { UnitType } from "../types/UnitType"; +import { VariableType } from "../types/VariableType"; +import { + BorderDTO, +} from "./BorderDTO"; +import { + ColorDTO, +} from "../color/ColorDTO"; +import { + SizeDTO, +} from "../size/SizeDTO"; +import { + BorderStyle, + isBorderStyle, +} from "../types/BorderStyle"; +import { ReadonlyJsonObject } from "../../Json"; +import { ColorEntity } from "../color/ColorEntity"; +import { + isSizeDTO, + SizeEntity, +} from "../size/SizeEntity"; +import { + Border, + isBorder, +} from "./Border"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; + +export const BorderEntityFactory = ( + EntityFactoryImpl.create('Border') + .addStaticMethod( + EntityMethodImpl.create('create') + .addArgument(VariableType.NUMBER) + .returnType('Border') + ) + .add( EntityFactoryImpl.createProperty("style").setTypes(BorderStyle, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("width").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("radius").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("color").setTypes(ColorEntity, VariableType.UNDEFINED) ) +); + +export const isBorderDTO = BorderEntityFactory.createTestFunctionOfDTO(); + +export const explainBorderDTO = BorderEntityFactory.createExplainFunctionOfDTO(); + +export const isBorderDTOOrUndefined = BorderEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainBorderDTOOrUndefined = BorderEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseBorderEntity = BorderEntityFactory.createEntityType(); + +/** + * Border entity. + */ +export class BorderEntity + extends BaseBorderEntity + implements Border +{ + + public static create () : BorderEntity; + public static create ( width : number ) : BorderEntity; + public static create ( style : BorderDTO ) : BorderEntity; + + public static create ( + style ?: BorderStyle | undefined, + width ?: SizeDTO | undefined, + color ?: ColorDTO | undefined, + radius ?: SizeDTO | undefined, + ) : BorderEntity; + + /** + * Creates a border entity. + * + * @param style + * @param width + * @param color + * @param radius + */ + public static create ( + style ?: BorderDTO | BorderStyle | number | undefined, + width ?: SizeDTO | undefined, + color ?: ColorDTO | undefined, + radius ?: SizeDTO | undefined, + ) : BorderEntity { + return new BorderEntity( + style, + width, + color, + radius, + ); + } + + public static createEmptyBorder () : BorderEntity { + return this.create( + undefined, + undefined, + undefined, + undefined, + ); + } + + /** + * Creates a border entity from a DTO. + * + * @param dto + */ + public static createFromDTO ( + dto : BorderDTO, + ) : BorderEntity { + return BorderEntity.create( + dto?.style, + dto?.width, + dto?.color, + dto?.radius, + ); + } + + public static toDTO ( + value: BorderEntity | Border | number | undefined, + ) : BorderDTO | undefined { + if (value === undefined) { + return undefined; + } + if (isBorderEntity(value)) { + return value.getDTO(); + } + if (isBorder(value)) { + return value.getDTO(); + } + if (isNumber(value)) { + return { + width: { + value: value, + unit: UnitType.PX, + }, + }; + } + throw new TypeError(`BorderEntity.toDTO(): Could not turn into DTO: ${LogUtils.stringifyValue(value)}`); + } + + + public constructor ( + style ?: BorderStyle | BorderDTO | number | undefined, + width ?: SizeDTO | undefined, + color ?: ColorDTO | undefined, + radius ?: SizeDTO | undefined, + ) { + + if ( isNumber(style) ) { + width = SizeEntity.create(style).getDTO(); + style = undefined; + } + + if ( style === undefined && width === undefined && color === undefined && radius === undefined ) { + super(); + } else if ( isBorderStyle(style) ) { + super( + { + width, + style, + color, + radius, + } + ); + } else if ( style === undefined && isSizeDTO(width) ) { + super( + { + width, + style, + color, + radius, + } + ); + } else if ( style === undefined && isSizeDTO(radius) ) { + super( + { + width, + style, + color, + radius, + } + ); + } else if ( isBorderDTO(style) ) { + super(style); + } else { + throw new TypeError(`new BorderEntity(): Incorrect arguments: ${LogUtils.stringifyValue(style)}, ${LogUtils.stringifyValue(width)}, ${LogUtils.stringifyValue(color)}, ${LogUtils.stringifyValue(radius)}`); + } + } + + public getCssStyles (): ReadonlyJsonObject { + const width = this.getWidth(); + const color = this.getColor(); + const style = this.getStyle(); + return { + border: `${ width ? width.getCssStyles() : '0' } ${ + style + }${ color ? ' ' + color.getCssStyles() : '' }` + }; + } + +} + +export function isBorderEntity (value: unknown): value is BorderEntity { + return value instanceof BorderEntity; +} diff --git a/entities/borderBox/BorderBox.ts b/entities/borderBox/BorderBox.ts new file mode 100644 index 0000000..21c78d5 --- /dev/null +++ b/entities/borderBox/BorderBox.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { + BorderDTO, +} from "../border/BorderDTO"; +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { + BorderBoxDTO, +} from "./BorderBoxDTO"; +import { BorderEntity } from "../border/BorderEntity"; +import { Border } from "../border/Border"; +import { Entity } from "../types/Entity"; + +/** + * Presents a box of borders (e.g. top, right, bottom, left) + */ +export interface BorderBox + extends Entity { + + /** + * Returns the DTO object. + */ + getDTO () : BorderBoxDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + + /** + * Get a top border. + */ + getTop () : BorderEntity | undefined; + + /** + * Get top border as a DTO. + */ + getTopDTO () : BorderDTO | undefined; + + /** + * Set a top border. + * + * @param value + */ + setTop ( + value ?: BorderEntity | Border | BorderDTO | undefined, + ) : this; + + + /** + * Get a right border. + */ + getRight () : BorderEntity | undefined; + + /** + * Get right border as a DTO. + */ + getRightDTO () : BorderDTO | undefined; + + /** + * Set a right border. + * + * @param value + */ + setRight ( + value ?: BorderEntity | Border | BorderDTO | undefined, + ) : this; + + + /** + * Get a bottom border. + */ + getBottom () : BorderEntity | undefined; + + /** + * Get bottom border as a DTO. + */ + getBottomDTO () : BorderDTO | undefined; + + /** + * Set a bottom border. + * + * @param value + */ + setBottom ( + value ?: BorderEntity | Border | BorderDTO | undefined, + ) : this; + + + /** + * Get a left border. + */ + getLeft () : BorderEntity | undefined; + + /** + * Get left border as a DTO. + */ + getLeftDTO () : BorderDTO | undefined; + + /** + * Set a left border. + * + * @param value + */ + setLeft ( + value ?: BorderEntity | Border | BorderDTO | undefined, + ) : this; + +} diff --git a/entities/borderBox/BorderBoxDTO.ts b/entities/borderBox/BorderBoxDTO.ts new file mode 100644 index 0000000..23a3fa7 --- /dev/null +++ b/entities/borderBox/BorderBoxDTO.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + BorderDTO, +} from "../border/BorderDTO"; +import { DTO } from "../types/DTO"; + +export interface BorderBoxDTO extends DTO { + readonly top ?: BorderDTO; + readonly right ?: BorderDTO; + readonly bottom ?: BorderDTO; + readonly left ?: BorderDTO; +} diff --git a/entities/borderBox/BorderBoxEntity.ts b/entities/borderBox/BorderBoxEntity.ts new file mode 100644 index 0000000..bdbc61d --- /dev/null +++ b/entities/borderBox/BorderBoxEntity.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { VariableType } from "../types/VariableType"; +import { + BorderBoxDTO, +} from "./BorderBoxDTO"; +import { + BorderDTO, +} from "../border/BorderDTO"; +import { reduce } from "../../functions/reduce"; +import { ReadonlyJsonObject } from "../../Json"; +import { + BorderEntity, + isBorderDTO, +} from "../border/BorderEntity"; +import { + BorderBox, +} from "./BorderBox"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; + +export const BorderBoxEntityFactory = ( + EntityFactoryImpl.create('BorderBox') + .add( EntityFactoryImpl.createProperty("top").setTypes(BorderEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("right").setTypes(BorderEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("bottom").setTypes(BorderEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("left").setTypes(BorderEntity, VariableType.UNDEFINED) ) +); + +export const isBorderBoxDTO = BorderBoxEntityFactory.createTestFunctionOfDTO(); + +export const isBorderBox = BorderBoxEntityFactory.createTestFunctionOfInterface(); + +export const explainBorderBoxDTO = BorderBoxEntityFactory.createExplainFunctionOfDTO(); + +export const isBorderBoxDTOOrUndefined = BorderBoxEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainBorderBoxDTOOrUndefined = BorderBoxEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseBorderBoxEntity = BorderBoxEntityFactory.createEntityType(); + + +/** + * BorderBox entity. + */ +export class BorderBoxEntity + extends BaseBorderBoxEntity + implements BorderBox +{ + + public static create () : BorderBoxEntity; + + /** + * Creates a size box entity. + * + * @param topAndBottom + * @param rightAndLeft + */ + public static create ( + topAndBottom : BorderDTO, + rightAndLeft : BorderDTO, + ) : BorderBoxEntity; + + /** + * Creates a size box entity. + * + * @param top + * @param right + * @param bottom + * @param left + */ + public static create ( + top : BorderDTO, + right : BorderDTO, + bottom : BorderDTO, + left : BorderDTO, + ) : BorderBoxEntity; + + /** + * Creates a size box entity. + * + * @param top + * @param right + * @param bottom + * @param left + */ + public static create ( + top ?: BorderDTO | BorderBoxDTO | BorderBoxEntity, + right ?: BorderDTO, + bottom ?: BorderDTO, + left ?: BorderDTO, + ) : BorderBoxEntity { + if ( top === undefined && right === undefined && bottom === undefined && left === undefined ) { + return new BorderBoxEntity(); + } else if ( isBorderBoxDTO(top) ) { + return new BorderBoxEntity(top); + } else if ( isBorderBoxEntity(top) ) { + return new BorderBoxEntity(top.getDTO()); + } else if ( isBorderBox(top) ) { + return new BorderBoxEntity(top.getDTO()); + } else if ( isBorderDTO(top) && isBorderDTO(right) && bottom === undefined && left === undefined ) { + return new BorderBoxEntity( { top, right, bottom: top, left: right } ); + } else if ( isBorderDTO(top) && isBorderDTO(right) && isBorderDTO(bottom) && isBorderDTO(left) ) { + return new BorderBoxEntity( { top, right, bottom, left } ); + } else { + throw new TypeError(`Invalid arguments for create: ${top}, ${right}, ${bottom}, ${left}`); + } + } + + /** + * Creates a size box entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : BorderBoxDTO, + ) : BorderBoxEntity { + return new BorderBoxEntity(value); + } + + public static merge ( + ...values: readonly (BorderBoxDTO | BorderBox | BorderBoxEntity)[] + ) : BorderBoxEntity { + return BorderBoxEntity.createFromDTO( + reduce( + values, + ( + prev: BorderBoxDTO, + item: BorderBoxDTO | BorderBox | BorderBoxEntity, + ) : BorderBoxDTO => { + const dto : BorderBoxDTO = this.toDTO(item); + return { + ...prev, + ...dto, + }; + }, + {}, + ) + ); + } + + public static toDTO ( + value: BorderBoxDTO | BorderBox | BorderBoxEntity, + ) : BorderBoxDTO { + if (isBorderBoxEntity(value)) { + return value.getDTO(); + } else if (isBorderBox(value)) { + return value.getDTO(); + } else { + return value; + } + } + + protected constructor ( + value ?: BorderBoxDTO | BorderBox, + ) { + super( + isBorderBoxDTO(value) + ? value + : ( + isBorderBox(value) + ? value.getDTO() + : undefined + ) + ); + } + + /** + * @inheritDoc + */ + public getCssStyles (): ReadonlyJsonObject { + const top = this.getTop(); + const right = this.getRight(); + const bottom = this.getBottom(); + const left = this.getLeft(); + return { + ...( top !== undefined ? { 'border-top': top.getCssStyles() }: {}), + ...( right !== undefined ? { 'border-right': right.getCssStyles() }: {}), + ...( bottom !== undefined ? { 'border-bottom': bottom.getCssStyles() }: {}), + ...( left !== undefined ? { 'border-left': left.getCssStyles() }: {}), + }; + } + +} + +export function isBorderBoxEntity (value: unknown): value is BorderBoxEntity { + return value instanceof BorderBoxEntity; +} diff --git a/entities/color/Color.ts b/entities/color/Color.ts new file mode 100644 index 0000000..f38ab6e --- /dev/null +++ b/entities/color/Color.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { ColorDTO } from "./ColorDTO"; +import { Entity } from "../types/Entity"; + +/** + * Presents an interface for color value + */ +export interface Color extends Entity { + + /** + * @inheritDoc + */ + valueOf () : ReadonlyJsonObject; + + /** + * + */ + getDTO () : ColorDTO; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Get a value. + */ + getValue () : string; + + /** + * Set a value. + * + * @param value + */ + setValue (value : string) : this; + + /** + * Set a value. + * + * An alias for `.setValue(value)`. + * + * @param value + */ + value (value : string) : this; + + /** + * Returns CSS styles. + */ + getCssStyles () : string; + +} diff --git a/entities/color/ColorDTO.ts b/entities/color/ColorDTO.ts new file mode 100644 index 0000000..fead14f --- /dev/null +++ b/entities/color/ColorDTO.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DTO } from "../types/DTO"; + +export interface ColorDTO extends DTO { + readonly value: string; +} diff --git a/entities/color/ColorEntity.test.ts b/entities/color/ColorEntity.test.ts new file mode 100644 index 0000000..26e4974 --- /dev/null +++ b/entities/color/ColorEntity.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { describe, it, expect } from '@jest/globals'; +import { ColorDTO } from "./ColorDTO"; +import { ColorEntity } from "./ColorEntity"; + +describe('ColorEntity', () => { + + describe('#create', () => { + it('can create color entities', () => { + let obj = ColorEntity.create(); + expect(obj.getDTO()).toStrictEqual({ + value: '' + }); + }); + }); + + describe('#isDTO', () => { + it('can test a DTO', () => { + const dto: ColorDTO = { + value: "#ffffff" + }; + expect( ColorEntity.isDTO(dto) ).toStrictEqual(true); + }); + }); + + describe('.getDTO', () => { + it('can get DTO', () => { + let obj = ColorEntity.create().setValue('#333'); + expect(obj.getDTO()).toEqual({ + value: '#333' + }); + }); + }); + + describe('.setValue', () => { + it('can set value by string', () => { + let obj = ColorEntity.create().setValue('#333'); + expect(obj.getValue()).toEqual('#333'); + }); + }); + +}); diff --git a/entities/color/ColorEntity.ts b/entities/color/ColorEntity.ts new file mode 100644 index 0000000..b4320e7 --- /dev/null +++ b/entities/color/ColorEntity.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { EntityMethodImpl } from "../types/EntityMethodImpl"; +import { VariableType } from "../types/VariableType"; +import { ColorDTO } from "./ColorDTO"; +import { reduce } from "../../functions/reduce"; +import { isString } from "../../types/String"; +import { Color } from "./Color"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; + +export const ColorEntityFactory = ( + EntityFactoryImpl.create('Color') + .addStaticMethod( + EntityMethodImpl.create('create') + .addArgument(VariableType.STRING) + .returnType('Color') + ) + .add( EntityFactoryImpl.createProperty("value").setTypes(VariableType.STRING) ) +); + +export const isColorDTO = ColorEntityFactory.createTestFunctionOfDTO(); + +export const isColor = ColorEntityFactory.createTestFunctionOfInterface(); + +export const explainColorDTO = ColorEntityFactory.createExplainFunctionOfDTO(); + +export const isColorDTOOrUndefined = ColorEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainColorDTOOrUndefined = ColorEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseColorEntity = ColorEntityFactory.createEntityType(); + +/** + * Color entity. + */ +export class ColorEntity + extends BaseColorEntity + implements Color +{ + + /** + * Creates a color entity. + * + * @param value + */ + public static create ( + value ?: string, + ) : ColorEntity { + return new ColorEntity(value); + } + + /** + * Creates a transparent color entity. + */ + public static createTransparent ( + ) : ColorEntity { + return new ColorEntity("transparent"); + } + + /** + * Creates a color entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : ColorDTO, + ) : ColorEntity { + return new ColorEntity(value.value); + } + + public static merge ( + ...values: readonly (ColorDTO | Color | ColorEntity | string)[] + ) : ColorEntity { + return ColorEntity.createFromDTO( + reduce( + values, + ( + prev: ColorDTO, + item: ColorDTO | Color | ColorEntity | string, + ) : ColorDTO => { + const dto : ColorDTO = this.toDTO(item); + return { + ...prev, + ...dto, + }; + }, + {value: ''}, + ) + ); + } + + public static toDTO ( + value: ColorDTO | Color | ColorEntity | string, + ) : ColorDTO { + if (isString(value)) { + return ColorEntity.create(value).getDTO(); + } else if (isColorEntity(value)) { + return value.getDTO(); + } else if (isColor(value)) { + return value.getDTO(); + } else { + return value; + } + } + + + protected constructor ( + value ?: string | ColorDTO | Color, + ) { + super( + isColorDTO(value) + ? value + : ( + isColor(value) + ? value.getDTO() + : ( + value + ? { value } + : undefined + ) + ) + ); + } + + /** + * @inheritDoc + */ + public getCssStyles (): string { + return `${this.getValue()}`; + } + +} + +export function isColorEntity (value: unknown): value is ColorEntity { + return value instanceof ColorEntity; +} diff --git a/entities/component/Component.ts b/entities/component/Component.ts new file mode 100644 index 0000000..badc03c --- /dev/null +++ b/entities/component/Component.ts @@ -0,0 +1,292 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ReadonlyJsonAny, ReadonlyJsonArray, ReadonlyJsonArrayOf, ReadonlyJsonObject } from "../../Json"; +import { TestCallbackNonStandard } from "../../types/TestCallback"; +import { + ComponentContent, + ComponentDTOContent, + UnreparedComponentContent, +} from "./ComponentContent"; +import { ComponentDTO } from "./ComponentDTO"; +import { StyleDTO } from "../style/StyleDTO"; +import { StyleEntity } from "../style/StyleEntity"; +import { ExtendableEntity } from "../types/ExtendableEntity"; +import { Style } from "../style/Style"; + +/** + * Interface for Component entities. + * + * @see {@link ComponentEntity} for the implementation. + * @see {@link ComponentType} for the static API. + */ +export interface Component + extends ExtendableEntity +{ + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// standard methods ////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get component DTO + */ + getDTO () : ComponentDTO; + + /** + * Get value of component, e.g. a JSON object. + */ + valueOf() : ReadonlyJsonObject; + + /** + * Get JSON presentation of the component. + */ + toJSON () : ReadonlyJsonObject; + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////////// name methods //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + getName () : string; + + setName (name : string) : this; + + name (name : string) : this; + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// content methods /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get inner content. + */ + getContent () : ComponentContent | undefined; + + /** + * Get inner content. + */ + getContentDTO () : ComponentDTOContent | undefined; + + /** + * Get inner content. + */ + setContent (value : UnreparedComponentContent | undefined) : this; + + /** + * Get inner content. + */ + content (value : UnreparedComponentContent | undefined) : this; + + /** + * Add inner content. + * + * @param value + */ + add (value : UnreparedComponentContent) : this; + + /** + * Add inner content. + * + * @param value + */ + addContent (value : UnreparedComponentContent) : this; + + /** + * Add inner content as a string. + * + * @param value + */ + addText (value : string) : this; + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////////// extend methods /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + getExtend () : string | undefined; + + /** + * @inheritDoc + */ + setExtend (name : string | undefined) : this; + + /** + * @inheritDoc + */ + extend (name : string | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////////// meta methods ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get internal meta object. + */ + getMeta () : ReadonlyJsonObject | undefined; + + /** + * Set internal meta object. + * + * @param value + */ + setMeta ( value: ReadonlyJsonObject | undefined ) : this; + + /** + * Set internal meta object. + * + * @param value + */ + meta ( value: ReadonlyJsonObject | undefined ) : this; + + + /** + * Returns true if the meta property exists. + * + * @param name + */ + hasMetaProperty (name : string) : boolean; + + /** + * Returns the value of a meta property. + * @param name + */ + getMetaProperty (name : string) : any | undefined; + + /** + * Get a value of internal string meta property. + * + * @param name + */ + getMetaString (name : string) : string | undefined; + + /** + * Set a value of internal string meta property. + * + * @param name + * @param value + */ + setMetaString (name : string, value: string | undefined) : this; + + /** + * Get a value of internal number meta property. + * + * @param name + */ + getMetaNumber (name : string) : number | null | undefined; + + /** + * Set a value of internal number meta property. + * + * @param name + * @param value + */ + setMetaNumber (name : string, value: number | undefined) : this; + + /** + * Get a value of internal boolean meta property. + * + * @param name + */ + getMetaBoolean (name : string) : boolean | null | undefined; + + /** + * Set a value of internal boolean meta property. + * + * @param name + * @param value + */ + setMetaBoolean (name : string, value: boolean | undefined) : this; + + /** + * Get a value of internal object meta property. + * + * @param name + */ + getMetaObject (name : string) : ReadonlyJsonObject | null | undefined; + + /** + * Set a value of internal object meta property. + * + * @param name + * @param value + */ + setMetaObject (name : string, value: ReadonlyJsonObject | null | undefined) : this; + + /** + * Get a value of internal array meta property. + * + * @param name + */ + getMetaArray (name : string) : ReadonlyJsonArray | undefined; + + /** + * Get a value of internal array meta property. + * + * @param name + */ + getMetaArrayOf ( + name : string, + isItemOf : TestCallbackNonStandard, + ) : ReadonlyJsonArrayOf | undefined; + + /** + * Set a value of internal array meta property. + * + * @param name + * @param value + */ + setMetaArray (name : string, value: ReadonlyJsonArray | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////////// style methods //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Get internal style entity. + */ + getStyle () : Style; + + /** + * Get internal style DTO. + */ + getStyleDTO () : StyleDTO; + + /** + * Set internal style entity. + * + * @param style + */ + setStyle (style : Style | StyleEntity | StyleDTO | undefined) : this; + + /** + * Alias for setStyle(). + * + * @param style + */ + style (style : Style | StyleEntity | StyleDTO | undefined) : this; + + /** + * Merges more styles. + * + * @param style + */ + addStyle (style : Style | StyleEntity | StyleDTO | undefined) : this; + addStyles (style : Style | StyleEntity | StyleDTO | undefined) : this; + + +} diff --git a/entities/component/ComponentContent.ts b/entities/component/ComponentContent.ts new file mode 100644 index 0000000..27e3c3e --- /dev/null +++ b/entities/component/ComponentContent.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { + explainArrayOf, + isArrayOf, +} from "../../types/Array"; +import { + explainNot, + explainOk, + explainOr, +} from "../../types/explain"; +import { prefixLines } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; +import { Component } from "./Component"; +import { + ComponentDTO, +} from "./ComponentDTO"; +import { + ComponentEntity, + explainComponentDTOOrString, + isComponentDTOOrString, + isComponentOrString, +} from "./ComponentEntity"; + + +export type UnreparedComponentContentItem = string | ComponentDTO | ComponentEntity | Component; +export type UnreparedComponentContentList = readonly UnreparedComponentContentItem[]; +export type UnreparedComponentContent = UnreparedComponentContentItem | UnreparedComponentContentList; + + +export type ComponentContentItem = string | ComponentEntity | Component; +export type ComponentContent = readonly ComponentContentItem[]; + +export function isComponentContent ( value: unknown) : value is ComponentContent { + return isArrayOf(value, isComponentOrString); +} + +export function explainComponentContent (value: any) : string { + return isComponentContent(value) ? explainOk() : explainNot( + `Array(\n${prefixLines(explainArrayOf( + "string | ComponentDTO", + explainComponentDTOOrString, + value, + isComponentDTOOrString + ), ' ')}\n)` + ); +} + +export function isComponentContentOrUndefined ( value: unknown) : value is ComponentContent | undefined { + return isUndefined(value) || isComponentContent(value); +} + +export function explainComponentContentOrUndefined ( value: unknown): string { + return isComponentContentOrUndefined(value) ? explainOk() : explainNot(explainOr([ + `ComponentContent (\n${prefixLines(explainComponentContent(value), ' ')}\n)`, + 'undefined' + ])); +} + + + +export type ComponentDTOContentItem = string | ComponentDTO; +export type ComponentDTOContent = readonly ComponentDTOContentItem[]; + +export function isComponentDTOContent ( value: unknown) : value is ComponentDTOContent { + return isArrayOf(value, isComponentDTOOrString); +} + diff --git a/entities/component/ComponentDTO.ts b/entities/component/ComponentDTO.ts new file mode 100644 index 0000000..ad04570 --- /dev/null +++ b/entities/component/ComponentDTO.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { StyleDTO } from "../style/StyleDTO"; +import { DTOWithContent } from "../types/DTOWithContent"; +import { DTOWithName } from "../types/DTOWithName"; +import { DTOWithOptionalExtend } from "../types/DTOWithOptionalExtend"; +import { ExtendableDTO } from "../types/ExtendableDTO"; +import { + ComponentDTOContent, +} from "./ComponentContent"; + +export interface ComponentDTO + extends + DTOWithName, + DTOWithOptionalExtend, + DTOWithContent, + ExtendableDTO +{ + readonly name : string; + readonly content ?: ComponentDTOContent; + readonly extend ?: string; + readonly meta ?: ReadonlyJsonObject; + readonly style ?: StyleDTO; +} diff --git a/entities/component/ComponentEntity.test.ts b/entities/component/ComponentEntity.test.ts new file mode 100644 index 0000000..9c595bb --- /dev/null +++ b/entities/component/ComponentEntity.test.ts @@ -0,0 +1,1577 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { HeadingEntity } from "../../components/heading/HeadingEntity"; +import { isString } from "../../types/String"; +import { ColorEntity } from "../color/ColorEntity"; +import { StyleEntity } from "../style/StyleEntity"; +import { BoxSizing } from "../types/BoxSizing"; +import { TextAlign } from "../types/TextAlign"; +import { UnitType } from "../types/UnitType"; +import { Component } from "./Component"; +import { ComponentDTO } from "./ComponentDTO"; +import { + ComponentEntity, + isComponentDTO, +} from "./ComponentEntity"; + +describe('ComponentEntity', () => { + + describe('isComponentDTO', () => { + + it('can test a complex DTO', () => { + + const dto : ComponentDTO = { + "name" : "DraftSection", + "extend" : "ArticleComponent", + "style" : { + "width" : { "value" : 100, "unit" : UnitType.PERCENT }, + "margin" : { "value" : 10, "unit" : UnitType.PX }, + "padding" : { "value" : 10, "unit" : UnitType.PX }, + "textColor" : { "value" : "#ffffff" }, + "background" : { "color" : { "value" : "#333333" } }, + "border" : { + "radius" : { + "value" : 4, + "unit" : UnitType.PX, + }, + }, + }, + "content" : [ + { + "name" : "DraftHeading", + "extend" : "HeadingComponent", + "content" : [ + "Keskeneräiset tilaukset", + ], + }, + { + "name" : "DraftTable", + "extend" : "TableComponent", + "style" : { + "width" : { + "value" : 100, + "unit" : UnitType.PERCENT, + }, + "margin" : { + "value" : 10, + "unit" : UnitType.PX, + }, + "padding" : { + "value" : 10, + "unit" : UnitType.PX, + }, + "textColor" : { "value" : "#ffffff" }, + "background" : { "color" : { "value" : "#333333" } }, + "border" : { + "radius" : { + "value" : 4, + "unit" : UnitType.PX, + }, + }, + }, + }, + ], + }; + + expect( isComponentDTO(dto) ).toBe(true); + }); + + }); + + describe('static methods', () => { + + describe('#create', () => { + + it('can create entity', () => { + const entity = ComponentEntity.create(); + expect(entity).toBeDefined(); + }); + + it('can create entity with name', () => { + const entity = ComponentEntity.create('Foo'); + expect(entity).toBeDefined(); + }); + + }); + + describe('#isDTO', () => { + + it('can test a DTO with name', () => { + expect( ComponentEntity.isDTO({name : 'Test'}) ).toBe(true); + }); + + it('can test a complex DTO', () => { + + const dto : ComponentDTO = { + "name" : "DraftSection", + "extend" : "ArticleComponent", + "style" : { + "width" : { "value" : 100, "unit" : UnitType.PERCENT }, + "margin" : { "value" : 10, "unit" : UnitType.PX }, + "padding" : { "value" : 10, "unit" : UnitType.PX }, + "textColor" : { "value" : "#ffffff" }, + "background" : { "color" : { "value" : "#333333" } }, + "border" : { + "radius" : { + "value" : 4, + "unit" : UnitType.PX, + }, + }, + }, + "content" : [ + { + "name" : "DraftHeading", + "extend" : "HeadingComponent", + "content" : [ "Keskeneräiset tilaukset", + ], + }, + { + "name" : "DraftTable", + "extend" : "TableComponent", + "style" : { + "width" : { + "value" : 100, + "unit" : UnitType.PERCENT, + }, + "margin" : { + "value" : 10, + "unit" : UnitType.PX, + }, + "padding" : { + "value" : 10, + "unit" : UnitType.PX, + }, + "textColor" : { "value" : "#ffffff" }, + "background" : { "color" : { "value" : "#333333" } }, + "border" : { + "radius" : { + "value" : 4, + "unit" : UnitType.PX, + }, + }, + }, + }, + ], + }; + + expect( ComponentEntity.isDTO(dto) ).toBe(true); + }); + + }); + + }); + + describe('basic methods', () => { + + describe('.getDTO', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can get the DTO with name property', () => { + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + name: "Foo" + }) + ) + }); + + it('can get the DTO with content property', () => { + entity.addContent('hello'); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + content: expect.arrayContaining( + [ + 'hello' + ] + ) + }) + ); + }); + + it('can get the DTO with extend property', () => { + entity.extend('Something'); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + extend: "Something", + }) + ) + }); + + it('can get the DTO with meta property', () => { + entity.setMeta({ + foo: 'Bar' + }); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + meta: expect.objectContaining({ + foo: 'Bar' + }), + }) + ) + }); + + it('can get the DTO with style property', () => { + entity.setStyle({ + textAlign: TextAlign.CENTER + }); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + style: expect.objectContaining({ + textAlign: 'center' + }), + }) + ) + }); + + }); + + describe('.valueOf', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can get the DTO with name property', () => { + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + name: "Foo" + }) + ) + }); + + it('can get the DTO with content property', () => { + entity.addContent('hello'); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + content: expect.arrayContaining( + [ + 'hello' + ] + ) + }) + ) + }); + + it('can get the DTO with extend property', () => { + entity.extend('Something'); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + extend: "Something", + }) + ) + }); + + it('can get the DTO with meta property', () => { + entity.setMeta({ + foo: 'Bar' + }); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + meta: expect.objectContaining({ + foo: 'Bar' + }), + }) + ) + }); + + it('can get the DTO with style property', () => { + entity.setStyle({ + textAlign: TextAlign.CENTER + }); + expect( entity.valueOf() ).toEqual( + expect.objectContaining({ + style: expect.objectContaining({ + textAlign: 'center' + }), + }) + ) + }); + + }); + + describe('.toJSON', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can get the DTO with name property', () => { + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + name: "Foo" + }) + ) + }); + + it('can get the DTO with content property', () => { + entity.addContent('hello'); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + content: expect.arrayContaining( + [ + 'hello' + ] + ) + }) + ) + }); + + it('can get the DTO with extend property', () => { + entity.extend('Something'); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + extend: "Something", + }) + ) + }); + + it('can get the DTO with meta property', () => { + entity.setMeta({ + foo: 'Bar' + }); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + meta: expect.objectContaining({ + foo: 'Bar' + }), + }) + ) + }); + + it('can get the DTO with style property', () => { + entity.setStyle({ + textAlign: TextAlign.CENTER + }); + expect( entity.toJSON() ).toEqual( + expect.objectContaining({ + style: expect.objectContaining({ + textAlign: 'center' + }), + }) + ) + }); + + }); + + }); + + describe('name property methods', () => { + + describe('.getName', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can get the name', () => { + expect( entity.getName() ).toEqual('Foo') + }); + + }); + + describe('.setName', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set the name', () => { + entity.setName('Bar'); + expect( entity.getName() ).toEqual('Bar'); + }); + + }); + + describe('.name', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set the name', () => { + entity.name('Bar'); + expect( entity.getName() ).toEqual('Bar'); + }); + + }); + + }); + + describe('content property methods', () => { + + describe('.getContent', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo').addContent('hello'); + }); + + it('can get the content with string', () => { + expect( entity.getContent() ).toEqual( + expect.arrayContaining([ + 'hello' + ]) + ); + }); + + }); + + describe('.getContentDTO', () => { + + let dto : ComponentDTO; + let entity : Component; + + beforeEach(() => { + + dto = HeadingEntity.createText('DraftHeading', 'Keskeneräiset tilaukset').getDTO(); + + entity = ( + ComponentEntity.create('Foo') + .addContent('hello') + .addContent(dto) + ); + }); + + it('can get the content with string', () => { + expect( entity.getContentDTO() ).toEqual( + expect.arrayContaining([ + 'hello' + ]) + ); + }); + + it('can get the content with DTO', () => { + expect( entity.getContentDTO() ).toEqual( + expect.arrayContaining([ + dto + ]) + ); + }); + + }); + + describe('.setContent', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set the content using a string', () => { + + entity.setContent(['hello']); + + expect( entity.getContent() ).toEqual( + expect.arrayContaining([ + 'hello' + ]) + ); + }); + + }); + + describe('.content', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set the content using a string', () => { + + entity.content(['hello']); + + expect( entity.getContent() ).toEqual( + expect.arrayContaining([ + 'hello' + ]) + ); + }); + + }); + + describe('.add', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo').setContent(['hello']); + }); + + it('can add more content using a string', () => { + entity.addContent('world'); + expect( entity.getContent() ).toEqual( + expect.arrayContaining([ + 'hello', + 'world', + ]) + ); + }); + + it('can add more content using a DTO', () => { + const dto = HeadingEntity.createText('DraftHeading', 'Keskeneräiset tilaukset').getDTO(); + entity.addContent( dto ); + const result = entity.getContentDTO(); + expect( result ).toEqual( + expect.arrayContaining([ + dto + ]) + ); + }); + + }); + + describe('.addContent', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo').setContent(['hello']); + }); + + it('can add more content using a string', () => { + entity.addContent('world'); + expect( entity.getContent() ).toEqual( + expect.arrayContaining([ + 'hello', + 'world', + ]) + ); + }); + + }); + + describe('.addText', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo').setContent(['hello']); + }); + + it('can add more content using a string', () => { + entity.addText('world'); + expect( entity.getContent() ).toEqual( + expect.arrayContaining([ + 'hello', + 'world', + ]) + ); + }); + + }); + + }); + + describe('extend property methods', () => { + + describe('.getExtend', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can get the extend when it is empty', () => { + expect( entity.getExtend() ).toEqual(undefined); + }); + + it('can get the extend when it is defined', () => { + entity.setExtend('Bar'); + expect( entity.getExtend() ).toEqual('Bar'); + }); + + }); + + describe('.setExtend', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set the extend', () => { + entity.setExtend('Bar'); + expect( entity.getExtend() ).toEqual('Bar'); + }); + + it('can unset the extend', () => { + entity.setExtend('Bar'); + expect( entity.getExtend() ).toEqual('Bar'); + entity.setExtend(undefined); + expect( entity.getExtend() ).toEqual(undefined); + }); + + }); + + describe('.extend', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set the extend', () => { + entity.extend('Bar'); + expect( entity.getExtend() ).toEqual('Bar'); + }); + + }); + + }); + + + describe('meta property methods', () => { + + describe('.getMeta', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can get the meta when it is empty', () => { + expect( entity.getMeta() ).toEqual(undefined); + }); + + it('can get the meta when it is defined', () => { + entity.setMeta({ + hello : 'World' + }); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World' + }), + ); + }); + + }); + + describe('.setMeta', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set the meta', () => { + entity.setMeta({ + hello : 'World' + }); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World' + }), + ); + }); + + it('can unset the meta', () => { + + entity.setMeta({ + hello : 'World' + }); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World' + }), + ); + + entity.setMeta(undefined); + expect( entity.getMeta() ).toEqual(undefined); + }); + + }); + + describe('.meta', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set the meta', () => { + entity.meta({ + hello : 'World' + }); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World' + }), + ); + }); + + }); + + describe('.hasMetaProperty', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World' + }); + }); + + it('can check if meta property does not exist', () => { + expect( entity.hasMetaProperty('foo') ).toEqual(false); + }); + + it('can check if meta property is defined', () => { + expect( entity.hasMetaProperty('hello') ).toEqual(true); + }); + + }); + + describe('.getMetaProperty', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World' + }); + }); + + it('can get if meta property does not exist', () => { + expect( entity.getMetaProperty('foo') ).toStrictEqual(undefined); + }); + + it('can get if meta property is defined', () => { + expect( entity.getMetaProperty('hello') ).toStrictEqual('World'); + }); + + }); + + describe('string methods', () => { + + describe('.getMetaString', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + }); + }); + + it('can get undefined if meta property does not exist', () => { + expect( entity.getMetaString('foo') ).toStrictEqual(undefined); + }); + + it('can get a string if meta property is defined', () => { + expect( entity.getMetaString('hello') ).toStrictEqual('World'); + }); + + it('can get a string if meta property is a number', () => { + expect( entity.getMetaString('value') ).toStrictEqual('100'); + }); + + }); + + describe('.setMetaString', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + }); + }); + + it('can set a string if meta property is a string', () => { + entity.setMetaString('hello', 'foobar'); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'foobar', + value: 100, + }) + ); + }); + + it('can set a string if meta property is not defined', () => { + entity.setMetaString('bar', 'foo'); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + bar: 'foo', + }) + ); + }); + + it('can set a string if meta property is a number', () => { + entity.setMetaString('value', '1000'); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: '1000', + }) + ); + }); + + }); + + }); + + describe('number methods', () => { + + describe('.getMetaNumber', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + }); + }); + + it('can get undefined if meta property does not exist', () => { + expect( entity.getMetaNumber('foo') ).toStrictEqual(undefined); + }); + + it('can get a null if meta property is a string', () => { + expect( entity.getMetaNumber('hello') ).toStrictEqual(null); + }); + + it('can get a number if meta property is a number', () => { + expect( entity.getMetaNumber('value') ).toStrictEqual(100); + }); + + }); + + describe('.setMetaNumber', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + }); + }); + + it('cannot set a string value', () => { + expect( () => entity.setMetaNumber('hello', 'foobar' as unknown as number) ).toThrowError( + TypeError, + expect.stringContaining('Component.setMetaNumber(): The new property value was not a number: foobar') + ); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + }) + ); + }); + + it('cannot set an undefined value', () => { + expect( () => entity.setMetaNumber('hello', undefined as unknown as number) ).toThrowError( + TypeError, + expect.stringContaining('Component.setMetaNumber(): The new property value was not a number: undefined') + ); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + }) + ); + }); + + it('can set a number if meta property is a string', () => { + entity.setMetaNumber('hello', 123); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 123, + value: 100, + }) + ); + }); + + it('can set a number if meta property is not defined', () => { + entity.setMetaNumber('bar', 123); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + bar: 123 + }) + ); + }); + + it('can set a number if meta property is a number', () => { + entity.setMetaNumber('value', 1000); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 1000, + }) + ); + }); + + }); + + }); + + describe('boolean methods', () => { + + describe('.getMetaBoolean', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + enabled : true, + hidden : false, + }); + }); + + it('can get undefined if meta property does not exist', () => { + expect( entity.getMetaBoolean('foo') ).toStrictEqual(undefined); + }); + + it('can get a null if meta property is a string', () => { + expect( entity.getMetaBoolean('hello') ).toStrictEqual(null); + }); + + it('can get a boolean if meta property is true', () => { + expect( entity.getMetaBoolean('enabled') ).toStrictEqual(true); + }); + + it('can get a boolean if meta property is false', () => { + expect( entity.getMetaBoolean('hidden') ).toStrictEqual(false); + }); + + }); + + describe('.setMetaBoolean', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + enabled : true, + }); + }); + + it('cannot set a string value', () => { + expect( () => entity.setMetaBoolean('hello', 'foobar' as unknown as boolean) ).toThrowError( + TypeError, + expect.stringContaining('Component.setMetaBoolean(): The new property value was not a boolean: foobar') + ); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + }) + ); + }); + + it('cannot set an undefined value', () => { + expect( () => entity.setMetaBoolean('hello', undefined as unknown as boolean) ).toThrowError( + TypeError, + expect.stringContaining('Component.setMetaBoolean(): The new property value was not a boolean: undefined') + ); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + }) + ); + }); + + it('can set a boolean if meta property is a string', () => { + entity.setMetaBoolean('hello', true); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: true, + value: 100, + }) + ); + }); + + it('can set a boolean if meta property is not defined', () => { + entity.setMetaBoolean('bar', true); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + bar: true + }) + ); + }); + + it('can set a boolean if meta property is a boolean', () => { + entity.setMetaBoolean('enabled', false); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled: false, + }) + ); + }); + + }); + + }); + + describe('array methods', () => { + + describe('.getMetaArray', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + enabled : true, + hidden : false, + payload : ['foo', 'bar'] + }); + }); + + it('can get undefined if meta property does not exist', () => { + expect( entity.getMetaArray('foo') ).toStrictEqual(undefined); + }); + + it('can get a null if meta property is a string', () => { + expect( entity.getMetaArray('hello') ).toStrictEqual(null); + }); + + it('can get an array if meta property is array', () => { + expect( entity.getMetaArray('payload') ).toStrictEqual(['foo', 'bar']); + }); + + }); + + describe('.getMetaArrayOf', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + enabled : true, + hidden : false, + payload : ['foo', 'bar'], + ids : [123, 456], + }); + }); + + it('cannot get an array if value is number array', () => { + expect( entity.getMetaArrayOf('ids', isString) ).toStrictEqual(null); + }); + + it('can get undefined if meta property does not exist', () => { + expect( entity.getMetaArrayOf('foo', isString) ).toStrictEqual(undefined); + }); + + it('can get a null if meta property is a string', () => { + expect( entity.getMetaArrayOf('hello', isString) ).toStrictEqual(null); + }); + + it('can get an array if meta property is array', () => { + expect( entity.getMetaArrayOf('payload', isString) ).toStrictEqual(['foo', 'bar']); + }); + + }); + + describe('.setMetaArray', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + enabled : true, + payload : [], + }); + }); + + it('cannot set a string value', () => { + expect( () => entity.setMetaArray('payload', 'foobar' as unknown as string[]) ).toThrowError( + TypeError, + expect.stringContaining('Component.setMetaArray(): The new property value was not an array: foobar') + ); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled : true, + payload : [], + }) + ); + }); + + it('cannot set an undefined value', () => { + expect( () => entity.setMetaArray('hello', undefined as unknown as string[]) ).toThrowError( + TypeError, + expect.stringContaining('Component.setMetaArray(): The new property value was not an array: undefined') + ); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled : true, + payload : [], + }) + ); + }); + + it('can set an array if meta property is a string', () => { + entity.setMetaArray('hello', ['hello', 'world']); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: ['hello', 'world'], + value: 100, + enabled : true, + payload : [], + }) + ); + }); + + it('can set an array if meta property is an array', () => { + entity.setMetaArray('payload', ['hello', 'world']); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled : true, + payload : ['hello', 'world'], + }) + ); + }); + + it('can set an array if meta property is not defined', () => { + entity.setMetaArray('bar', ['hello', 'world']); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + bar: ['hello', 'world'], + enabled : true, + payload : [], + }) + ); + }); + + it('can set an array if meta property is a boolean', () => { + entity.setMetaArray('enabled', ['hello', 'world']); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled: ['hello', 'world'], + payload : [], + }) + ); + }); + + }); + + }); + + describe('object methods', () => { + + describe('.getMetaObject', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + enabled : true, + hidden : false, + payload : ['foo', 'bar'] + }); + }); + + it('can get undefined if meta property does not exist', () => { + expect( entity.getMetaObject('foo') ).toStrictEqual(undefined); + }); + + it('can get a null if meta property is a string', () => { + expect( entity.getMetaObject('hello') ).toStrictEqual(null); + }); + + it('can get an array if meta property is array', () => { + expect( entity.getMetaObject('payload') ).toStrictEqual(['foo', 'bar']); + }); + + }); + + describe('.setMetaObject', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setMeta({ + hello : 'World', + value : 100, + enabled : true, + payload : [], + }); + }); + + it('cannot set a string value', () => { + expect( () => entity.setMetaObject('payload', 'foobar' as unknown as {}) ).toThrowError( + TypeError, + expect.stringContaining('Component.setMetaObject(): The new property value was not an array: foobar') + ); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled : true, + payload : [], + }) + ); + }); + + it('cannot set an undefined value', () => { + expect( () => entity.setMetaObject('hello', undefined as unknown as {}) ).toThrowError( + TypeError, + expect.stringContaining('Component.setMetaObject(): The new property value was not an array: undefined') + ); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled : true, + payload : [], + }) + ); + }); + + it('can set an array if meta property is a string', () => { + entity.setMetaObject('hello', {hello : 'world'}); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: {hello : 'world'}, + value: 100, + enabled : true, + payload : [], + }) + ); + }); + + it('can set an object if meta property is an object', () => { + entity.setMetaObject('payload', {hello : 'world'}); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled : true, + payload : {hello : 'world'}, + }) + ); + }); + + it('can set an object if meta property is not defined', () => { + entity.setMetaObject('bar', {hello : 'world'}); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + bar: {hello : 'world'}, + enabled : true, + payload : [], + }) + ); + }); + + it('can set an object if meta property is a boolean', () => { + entity.setMetaObject('enabled', {hello : 'world'}); + expect( entity.getMeta() ).toEqual( + expect.objectContaining({ + hello: 'World', + value: 100, + enabled: {hello : 'world'}, + payload : [], + }) + ); + }); + + }); + + }); + + }); + + describe('style methods', () => { + + describe('.getStyle', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('cannot get a style when it is empty', () => { + expect( entity.getStyle() ).toEqual(undefined); + }); + + it('can get a style when text align is defined', () => { + entity.setStyle({ + textAlign : TextAlign.CENTER + }); + expect( entity.getStyle().getDTO() ).toEqual( + expect.objectContaining({ + textAlign: TextAlign.CENTER + }), + ); + }); + + it('can get a style when box sizing is defined', () => { + entity.setStyle({ + boxSizing : BoxSizing.BORDER_BOX + }); + expect( entity.getStyle().getDTO() ).toEqual( + expect.objectContaining({ + boxSizing: BoxSizing.BORDER_BOX + }), + ); + }); + + }); + + describe('.getStyleDTO', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('cannot get a style when it is empty', () => { + expect( entity.getStyleDTO() ).toEqual(undefined); + }); + + it('can get a style when it is defined', () => { + entity.setStyle({ + textAlign : TextAlign.CENTER + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign: TextAlign.CENTER + }), + ); + }); + + }); + + describe('.setStyle', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set a text align style using DTO', () => { + entity.setStyle({ + textAlign : TextAlign.CENTER + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign : TextAlign.CENTER + }), + ); + }); + + it('can set a box sizing style using DTO', () => { + entity.setStyle({ + boxSizing : BoxSizing.BORDER_BOX + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + boxSizing : BoxSizing.BORDER_BOX + }), + ); + }); + + it('can set a box sizing style using entity', () => { + entity.setStyle( + StyleEntity.create().setBoxSizing(BoxSizing.BORDER_BOX) + ); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + boxSizing : BoxSizing.BORDER_BOX + }), + ); + }); + + it('can unset a style', () => { + entity.setStyle({ + textAlign : TextAlign.CENTER + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign : TextAlign.CENTER + }), + ); + + entity.setStyle(undefined); + expect( entity.getStyleDTO() ).toEqual(undefined); + }); + + }); + + describe('.style', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + }); + + it('can set a style', () => { + entity.style({ + textAlign : TextAlign.CENTER + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign : TextAlign.CENTER + }), + ); + }); + + }); + + describe('.addStyle', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setStyle({ + textAlign : TextAlign.CENTER + }); + }); + + it('can override a style', () => { + entity.addStyle({ + textAlign : TextAlign.LEFT + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign : TextAlign.LEFT + }), + ); + }); + + it('can add a style', () => { + const color = ColorEntity.create('#fff').getDTO(); + entity.addStyle({ + textColor : color + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign : TextAlign.CENTER, + textColor: color, + }), + ); + }); + + }); + + describe('.addStyles', () => { + + let entity : Component; + + beforeEach(() => { + entity = ComponentEntity.create('Foo'); + entity.setStyle({ + textAlign : TextAlign.CENTER + }); + }); + + it('can override a style from DTO', () => { + entity.addStyles({ + textAlign : TextAlign.LEFT + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign : TextAlign.LEFT + }), + ); + }); + + it('can append a text color style from DTO', () => { + const color = ColorEntity.create('#fff').getDTO(); + entity.addStyles({ + textColor : color + }); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign : TextAlign.CENTER, + textColor: color, + }), + ); + }); + + it('can append a style from entity', () => { + const color = ColorEntity.create('#fff').getDTO(); + entity.addStyles( + StyleEntity.create().setTextColor(color) + ); + expect( entity.getStyleDTO() ).toEqual( + expect.objectContaining({ + textAlign : TextAlign.CENTER, + textColor: color, + }), + ); + }); + + }); + + }); + +}); diff --git a/entities/component/ComponentEntity.ts b/entities/component/ComponentEntity.ts new file mode 100644 index 0000000..e25bf60 --- /dev/null +++ b/entities/component/ComponentEntity.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { map } from "../../functions/map"; +import { LogUtils } from "../../LogUtils"; +import { isArray } from "../../types/Array"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { isString } from "../../types/String"; +import { Style } from "../style/Style"; +import { StyleDTO } from "../style/StyleDTO"; +import { StyleEntity } from "../style/StyleEntity"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { VariableType } from "../types/VariableType"; +import { Component } from "./Component"; +import { + ComponentDTOContent, + ComponentDTOContentItem, + UnreparedComponentContent, + UnreparedComponentContentItem, +} from "./ComponentContent"; +import { ComponentDTO } from "./ComponentDTO"; +import { ComponentType } from "./ComponentType"; + +export const ComponentEntityFactory = ( + EntityFactoryImpl.create('Component') + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING) ) + .add( EntityFactoryImpl.createOptionalArrayProperty("content").setTypes('Component', VariableType.STRING) ) + .add( EntityFactoryImpl.createProperty("extend").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("meta").setTypes(VariableType.JSON, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("style").setTypes(StyleEntity, VariableType.UNDEFINED) ) +); + +export const BaseComponentEntity = ComponentEntityFactory.createEntityType(); + +export const isComponentDTO = ComponentEntityFactory.createTestFunctionOfDTO(); + +export const isComponent = ComponentEntityFactory.createTestFunctionOfInterface(); + +export const isComponentOrString = ComponentEntityFactory.createTestFunctionOfInterfaceOrOneOf( VariableType.STRING ); + +export const explainComponentDTO = ComponentEntityFactory.createExplainFunctionOfDTO(); + +export const isComponentDTOOrUndefined = ComponentEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); +export const explainComponentDTOOrUndefined = ComponentEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +/** + * Tries to detect if this value is an interface for static ComponentEntity. + * + * This function cannot really detect if the value has the correct interface. + * It can only detect that the object has a create function. + * + * @param value + * @todo Create support for this in ComponentEntityFactory + */ +export function isComponentType (value: unknown): value is ComponentType { + return isObject(value) && isFunction(value?.create); +} + +/** + * Entity for components. + * + * @see {@link ComponentType} for the interface of static methods. + */ +export class ComponentEntity + extends BaseComponentEntity + implements Component +{ + + /** + * Create a component entity. + * + * @param name + */ + public static create ( + name ?: string + ) : ComponentEntity { + return new this(name); + } + + + + /** + * Construct the component entity. + * + * @param name + * @protected + */ + protected constructor ( + name ?: string | ComponentDTO | undefined, + ) { + if (name === undefined) { + super(); + } else if (isString(name)) { + super( { name } ); + } else if (isComponentDTO(name)) { + super( name ); + } else { + throw new TypeError(`ComponentEntity: Incorrect arguments`); + } + } + + /** + * @inheritDoc + */ + public addContent ( value : UnreparedComponentContent ) : this { + + if ( isArray(value) ) { + const prevContent : ComponentDTOContent | undefined = this.getContentDTO(); + return this.setContent( [ + ...(prevContent ? prevContent : []), + ...map( + value, + (item: UnreparedComponentContentItem) : ComponentDTOContentItem => { + if (isComponentEntity(item)) { + return item.getDTO(); + } + if (isComponent(item)) { + return item.getDTO(); + } + return item; + } + ), + ]); + } + + if ( isString(value) || isComponentDTO(value) ) { + const prevContent = this.getContentDTO(); + return this.setContent( [ + ...(prevContent ? prevContent : []), + value, + ]); + } + + if ( isComponentEntity(value) || isComponent(value) ) { + const prevContent = this.getContentDTO(); + return this.setContent( [ + ...(prevContent ? prevContent : []), + value.getDTO(), + ]); + } + + console.log(`WOOT: value = `, value); + + throw new TypeError(`${this.getEntityType().getEntityName()}.addContent: Invalid argument: ${LogUtils.stringifyValue(value)}`); + + } + + /** + * @inheritDoc + */ + public add ( value : UnreparedComponentContent ) : this { + return this.addContent(value); + } + + /** + * @inheritDoc + */ + public addText ( value : string ) : this { + return this.addContent(value); + } + + // /** + // * @inheritDoc + // */ + // public getContentDTO () : ComponentDTOContent | undefined { + // return this.getContentDTO(); + // } + + /** + * @inheritDoc + */ + public addStyles (style : Style | StyleEntity | StyleDTO | undefined) : this { + return this.addStyle(style); + } + +} + +/** + * Returns true if the value is instance of ComponentEntity. + * + * @param value + */ +export function isComponentEntity (value: unknown): value is ComponentEntity { + return value instanceof ComponentEntity; +} + +export const isComponentDTOOrString = ComponentEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.STRING); +export const explainComponentDTOOrString = ComponentEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.STRING); diff --git a/entities/component/ComponentType.ts b/entities/component/ComponentType.ts new file mode 100644 index 0000000..7821c6b --- /dev/null +++ b/entities/component/ComponentType.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentEntity } from "./ComponentEntity"; + +/** + * Public interface of static `ComponentEntity`. + */ +export interface ComponentType { + create (name ?: string) : ComponentEntity; +} diff --git a/entities/font/Font.ts b/entities/font/Font.ts new file mode 100644 index 0000000..313662a --- /dev/null +++ b/entities/font/Font.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { SizeEntity } from "../size/SizeEntity"; +import { FontDTO } from "./FontDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { Entity } from "../types/Entity"; +import { FontStyle } from "./types/FontStyle"; +import { FontVariant } from "./types/FontVariant"; +import { FontWeight } from "./types/FontWeight"; +import { Size } from "../size/Size"; + +/** + * Presents a font value. + */ +export interface Font + extends Entity { + + /** + * @inheritDoc + */ + valueOf () : ReadonlyJsonObject; + + /** + * + */ + getDTO () : FontDTO; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + + /** + * Get a font style. + */ + getFontStyle () : FontStyle | undefined; + + /** + * Get a font style. + */ + getStyle () : FontStyle | undefined; + + /** + * Set a font style. + * + * @param value + */ + setFontStyle (value : FontStyle | undefined) : this; + + /** + * Set a font style. + * + * @param value + */ + setStyle (value : FontStyle | undefined) : this; + + + /** + * Get a font variant. + */ + getFontVariant () : FontVariant | undefined; + + /** + * Get a font variant. + */ + getVariant () : FontVariant | undefined; + + /** + * Set a font variant. + * + * @param value + */ + setFontVariant (value : FontVariant | undefined) : this; + + /** + * Set a font variant. + * + * @param value + */ + setVariant (value : FontVariant | undefined) : this; + + + /** + * Get a font weight. + */ + getFontWeight () : FontWeight | undefined; + + /** + * Get a font weight. + */ + getWeight () : FontWeight | undefined; + + /** + * Set a font weight. + * + * @param value + */ + setFontWeight (value : FontWeight | undefined) : this; + + /** + * Set a font weight. + * + * @param value + */ + setWeight (value : FontWeight | undefined) : this; + + + /** + * Get a font size. + */ + getFontSize () : Size | undefined; + + /** + * Get a font size. + */ + getSize () : Size | undefined; + + /** + * Get a font size. + */ + getFontSizeDTO () : SizeDTO | undefined; + + /** + * Get a font size. + */ + getSizeDTO () : SizeDTO | undefined; + + /** + * Set a font size. + * + * @param value + */ + setSize (value : SizeEntity | SizeDTO | number | undefined) : this; + + /** + * Set a font size. + * + * @param value + */ + setFontSize (value : SizeEntity | SizeDTO | number | undefined) : this; + + + /** + * Get a font line-height. + */ + getLineHeight () : Size | undefined; + + /** + * Get a font line-height. + */ + getLineHeightDTO () : SizeDTO | undefined; + + /** + * Set a font line-height. + * + * @param value + */ + setLineHeight (value : SizeEntity | SizeDTO | undefined) : this; + + + /** + * Get a font family. + */ + getFamily () : string | undefined; + + /** + * Get a font family. + * Alias for `./getFamily()`. + */ + getFontFamily () : string | undefined; + + /** + * Set a font family. + * + * @param value + */ + setFontFamily (value : string | undefined) : this; + + /** + * Set a font family. + * + * Alias for `./setFamily()`. + * @param value + */ + setFamily (value : string | undefined) : this; + + +} + +export function isFont (value : unknown) : value is Font { + return ( + isObject(value) + && isFunction(value?.valueOf) + && isFunction(value?.getDTO) + && isFunction(value?.toJSON) + && isFunction(value?.getCssStyles) + && isFunction(value?.getFontStyle) + && isFunction(value?.getFontVariant) + && isFunction(value?.getFontWeight) + && isFunction(value?.getFontSize) + && isFunction(value?.getLineHeight) + && isFunction(value?.getFontFamily) + && isFunction(value?.setFontStyle) + && isFunction(value?.setFontVariant) + && isFunction(value?.setFontWeight) + && isFunction(value?.setFontSize) + && isFunction(value?.setLineHeight) + && isFunction(value?.setFontFamily) + ); +} diff --git a/entities/font/FontDTO.ts b/entities/font/FontDTO.ts new file mode 100644 index 0000000..f47760e --- /dev/null +++ b/entities/font/FontDTO.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { FontStyle } from "./types/FontStyle"; +import { FontVariant } from "./types/FontVariant"; +import { FontWeight } from "./types/FontWeight"; +import { SizeDTO } from "../size/SizeDTO"; +import { DTO } from "../types/DTO"; + +export interface FontDTO extends DTO { + readonly style ?: FontStyle; + readonly variant ?: FontVariant; + readonly weight ?: FontWeight; + readonly size ?: SizeDTO; + readonly lineHeight ?: SizeDTO; + readonly family ?: string; +} diff --git a/entities/font/FontEntity.test.ts b/entities/font/FontEntity.test.ts new file mode 100644 index 0000000..455c9c3 --- /dev/null +++ b/entities/font/FontEntity.test.ts @@ -0,0 +1,333 @@ +// Copyright (c) 2024. Heusala Group Oy . All rights reserved. + +import { UnitType } from "../types/UnitType"; +import { FontEntity } from "./FontEntity"; +import { FontStyle } from "./types/FontStyle"; +import { FontVariant } from "./types/FontVariant"; +import { FontWeight } from "./types/FontWeight"; + +describe('FontEntity', () => { + + describe('Static methods', () => { + + describe('#create', () => { + + it('can create empty entity', () => { + const entity = FontEntity.create(); + expect(entity).toBeDefined(); + }); + + }); + + }); + + describe('Font style methods', () => { + + let entity : FontEntity; + + beforeEach(() => { + entity = FontEntity.create({ + style: FontStyle.ITALIC + }); + }); + + describe('.getStyle', () => { + it('can get font style', () => { + expect( entity.getStyle() ).toBe(FontStyle.ITALIC); + }); + }); + + describe('.setStyle', () => { + it('can set font style', () => { + entity.setStyle(FontStyle.NORMAL); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + style: FontStyle.NORMAL + }) + ); + }); + }); + + describe('.getFontStyle', () => { + it('can get font style', () => { + expect( entity.getFontStyle() ).toBe(FontStyle.ITALIC); + }); + }); + + describe('.setFontStyle', () => { + it('can set font style', () => { + entity.setFontStyle(FontStyle.NORMAL); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + style: FontStyle.NORMAL + }) + ); + }); + }); + + }); + + describe('Font variant methods', () => { + + let entity : FontEntity; + + beforeEach(() => { + entity = FontEntity.create({ + variant: FontVariant.SMALL_CAPS + }); + }); + + describe('.getVariant', () => { + it('can get font variant', () => { + expect( entity.getVariant() ).toBe(FontVariant.SMALL_CAPS); + }); + }); + + describe('.setVariant', () => { + it('can set font variant', () => { + entity.setVariant(FontVariant.NORMAL); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + variant: FontVariant.NORMAL + }) + ); + }); + }); + + describe('.getFontVariant', () => { + it('can get font variant', () => { + expect( entity.getFontVariant() ).toBe(FontVariant.SMALL_CAPS); + }); + }); + + describe('.setFontVariant', () => { + it('can set font variant', () => { + entity.setFontVariant(FontVariant.NORMAL); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + variant: FontVariant.NORMAL + }) + ); + }); + }); + + }); + + describe('Font weight methods', () => { + + let entity : FontEntity; + + beforeEach(() => { + entity = FontEntity.create({ + weight: FontWeight.NORMAL + }); + }); + + describe('.getWeight', () => { + it('can get font weight', () => { + expect( entity.getWeight() ).toBe(FontWeight.NORMAL); + }); + }); + + describe('.setWeight', () => { + it('can set font weight', () => { + entity.setWeight(FontWeight.BOLD); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + weight: FontWeight.BOLD + }) + ); + }); + }); + + describe('.getFontWeight', () => { + it('can get font weight', () => { + expect( entity.getFontWeight() ).toBe(FontWeight.NORMAL); + }); + }); + + describe('.setFontWeight', () => { + it('can set font weight', () => { + entity.setFontWeight(FontWeight.BOLD); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + weight: FontWeight.BOLD + }) + ); + }); + }); + + }); + + describe('Font size methods', () => { + + let entity : FontEntity; + + beforeEach(() => { + entity = FontEntity.create({ + size: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getSize', () => { + it('can get font size', () => { + expect( entity.getSize()?.getDTO() ).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ); + }); + }); + + describe('.setSize', () => { + it('can set font size', () => { + entity.setSize({ + value: 20, + unit: UnitType.PX, + }); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + size: expect.objectContaining({ + value: 20, + unit: UnitType.PX, + }) + }) + ); + }); + }); + + describe('.getFontSize', () => { + it('can get font size', () => { + expect( entity.getFontSize()?.getDTO() ).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ); + }); + }); + + describe('.setFontSize', () => { + it('can set font size', () => { + entity.setFontSize({ + value: 20, + unit: UnitType.PX, + }); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + size: expect.objectContaining({ + value: 20, + unit: UnitType.PX, + }) + }) + ); + }); + }); + + }); + + describe('Line height methods', () => { + + let entity : FontEntity; + + beforeEach(() => { + entity = FontEntity.create({ + lineHeight: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getLineHeight', () => { + it('can get line height', () => { + expect( entity.getLineHeight()?.getDTO() ).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ); + }); + }); + + describe('.getLineHeightDTO', () => { + it('can get line height', () => { + expect( entity.getLineHeightDTO() ).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ); + }); + }); + + describe('.setLineHeight', () => { + it('can set line height', () => { + entity.setLineHeight({ + value: 20, + unit: UnitType.PX, + }); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + lineHeight: expect.objectContaining({ + value: 20, + unit: UnitType.PX, + }) + }) + ); + }); + }); + + }); + + describe('Font family methods', () => { + + let entity : FontEntity; + + beforeEach(() => { + entity = FontEntity.create({ + family: 'Times' + }); + }); + + describe('.getFamily', () => { + it('can get font family', () => { + expect( entity.getFamily() ).toBe('Times'); + }); + }); + + describe('.setFamily', () => { + it('can set font family', () => { + entity.setFamily('Courier'); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + family: 'Courier' + }) + ); + }); + }); + + describe('.getFontFamily', () => { + it('can get font family', () => { + expect( entity.getFontFamily() ).toBe('Times'); + }); + }); + + describe('.setFontFamily', () => { + it('can set font family', () => { + entity.setFontFamily('Courier'); + expect( entity.getDTO() ).toEqual( + expect.objectContaining({ + family: 'Courier' + }) + ); + }); + }); + + }); + +}); diff --git a/entities/font/FontEntity.ts b/entities/font/FontEntity.ts new file mode 100644 index 0000000..9ef893a --- /dev/null +++ b/entities/font/FontEntity.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { LogUtils } from "../../LogUtils"; +import { isNumber } from "../../types/Number"; +import { isString } from "../../types/String"; +import { Size } from "../size/Size"; +import { SizeDTO } from "../size/SizeDTO"; +import { + isSizeDTO, + isSizeEntity, + SizeEntity, +} from "../size/SizeEntity"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { EntityMethodImpl } from "../types/EntityMethodImpl"; +import { UnitType } from "../types/UnitType"; +import { VariableType } from "../types/VariableType"; +import { Font } from "./Font"; +import { FontDTO } from "./FontDTO"; +import { + FontStyle, + isFontStyle, + isFontStyleOrUndefined, +} from "./types/FontStyle"; +import { FontVariant } from "./types/FontVariant"; +import { FontWeight } from "./types/FontWeight"; + +export const FontEntityFactory = ( + EntityFactoryImpl.create('Font') + .addStaticMethod( + EntityMethodImpl.create('create') + .addArgument(VariableType.NUMBER) + .returnType('Font') + ) + .addStaticMethod( + EntityMethodImpl.create('create') + .addArgument(VariableType.STRING) + .returnType('Font') + ) + .add( EntityFactoryImpl.createProperty("style").setTypes( FontStyle, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("variant").setTypes( FontVariant, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("weight").setTypes( FontWeight, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("size").setTypes( SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("lineHeight").setTypes( SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("family").setTypes( VariableType.STRING, VariableType.UNDEFINED) ) +); + +export const isFontDTO = FontEntityFactory.createTestFunctionOfDTO(); + +export const isFont = FontEntityFactory.createTestFunctionOfInterface(); + +export const explainFontDTO = FontEntityFactory.createExplainFunctionOfDTO(); + +export const isFontDTOOrUndefined = FontEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainFontDTOOrUndefined = FontEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseFontEntity = FontEntityFactory.createEntityType(); + + +/** + * Font entity. + */ +export class FontEntity + extends BaseFontEntity + implements Font +{ + + public static create () : FontEntity; + public static create (value : string) : FontEntity; + public static create (value : number) : FontEntity; + public static create (value : FontDTO) : FontEntity; + + /** + * Creates a font entity. + * + * @param value + */ + public static create ( + value ?: FontDTO | number | string | undefined, + ) : FontEntity { + if (value === undefined) return new FontEntity(); + if (isFontDTO(value)) return new FontEntity(value); + if (isNumber(value)) return new FontEntity(value); + if (isString(value)) return new FontEntity(value); + throw new TypeError( + `FontEntity.create(): Invalid argument: ${LogUtils.stringifyValue(value)}` + ); + } + + + /** + * Creates a font entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : FontDTO, + ) : FontEntity { + return new FontEntity( + value?.style, + value?.variant, + value?.weight, + value?.size, + value?.lineHeight, + value?.family, + ); + } + + public static toDTO ( + value : FontStyle | FontDTO | SizeEntity | SizeDTO | FontEntity | Font | number | string | undefined, + ) : FontDTO { + if ( value === undefined ) return {}; + if ( isFontDTO(value) ) return value; + if ( isFontEntity(value) ) return value.getDTO(); + if ( isFontStyle(value) ) { + return { + style: value, + }; + } + if ( isSizeEntity(value) ) { + return { + size: value.getDTO(), + }; + } + if ( isSizeDTO(value) ) { + return { + size: value, + }; + } + if ( isNumber(value) ) { + return { + size: { + value, + unit: UnitType.PX, + }, + }; + } + throw new TypeError( + `FontEntity.toDTO(): Invalid argument: ${LogUtils.stringifyValue(value)}` + ); + } + + public constructor (); + public constructor ( font: FontDTO ); + public constructor ( font: FontEntity ); + public constructor ( font: Font ); + public constructor ( font: number ); + public constructor ( font: string ); + public constructor ( + style ?: FontStyle | undefined, + variant ?: FontVariant | undefined, + weight ?: FontWeight | undefined, + size ?: SizeDTO | undefined, + lineHeight ?: SizeDTO | undefined, + family ?: string | undefined, + ); + + public constructor ( + style ?: FontStyle | FontDTO | FontEntity | Font | number | string | undefined, + variant ?: FontVariant | undefined, + weight ?: FontWeight | undefined, + size ?: SizeDTO | undefined, + lineHeight ?: SizeDTO | undefined, + family ?: string | undefined, + ) { + if ( isFontStyleOrUndefined(style) ) { + super( { + style, + variant, + weight, + size, + lineHeight, + family, + } ); + } else { + const dto: FontDTO | undefined = FontEntity.toDTO(style); + if (dto) { + super(dto); + } else { + throw new TypeError( + `new FontEntity(): Invalid arguments: ${ + LogUtils.stringifyValue(style) }, ${ + LogUtils.stringifyValue(variant) }, ${ + LogUtils.stringifyValue(weight) }, ${ + LogUtils.stringifyValue(size) }, ${ + LogUtils.stringifyValue(lineHeight) }, ${ + LogUtils.stringifyValue(family) }` + ); + } + } + } + + /** + * @inheritDoc + */ + public getCssStyles (): ReadonlyJsonObject { + const style = this.getFontStyle(); + const variant = this.getFontVariant(); + const weight = this.getFontWeight(); + const size = this.getFontSize(); + const lineHeight = this.getLineHeight(); + const family = this.getFontFamily(); + return { + ...(style ? { fontStyle: style } : {}), + ...(variant ? { fontVariant: variant } : {}), + ...(weight ? { fontWeight: weight } : {}), + ...(size ? { fontSize: size.getCssStyles() } : {}), + ...(lineHeight ? { lineHeight: lineHeight.getCssStyles() } : {}), + ...(family ? { fontFamily: family } : {}), + }; + } + + public getFontWeight () : FontWeight | undefined { + return this.getWeight(); + } + + public setFontWeight (value : FontWeight | undefined) : this { + return this.setWeight(value); + } + + public getFontStyle () : FontStyle | undefined { + return this.getStyle(); + } + + public setFontStyle (value : FontStyle | undefined) : this { + return this.setStyle(value); + } + + public getFontVariant () : FontVariant | undefined { + return this.getVariant(); + } + + public setFontVariant (value : FontVariant | undefined) : this { + return this.setVariant(value); + } + + public getFontSize () : Size | undefined { + return this.getSize(); + } + + public setFontSize (value : Size | SizeDTO | SizeEntity | number | undefined) : this { + return this.setSize(value); + } + + public getFontFamily () : string | undefined { + return this.getFamily(); + } + + public setFontFamily (value : string | undefined) : this { + return this.setFamily(value); + } + +} + +export function isFontEntity (value: unknown): value is FontEntity { + return value instanceof FontEntity; +} diff --git a/entities/font/types/FontStyle.ts b/entities/font/types/FontStyle.ts new file mode 100644 index 0000000..241061a --- /dev/null +++ b/entities/font/types/FontStyle.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; + +export enum FontStyle { + + /** + * The text is shown normally + */ + NORMAL = "normal", + + /** + * The text is shown in italics + */ + ITALIC = "italic", + + /** + * The text is "leaning" (oblique is very similar to italic, but less supported) + */ + OBLIQUE = "oblique", + +} + +export function isFontStyle (value: unknown) : value is FontStyle { + return isEnum(FontStyle, value); +} + +export function explainFontStyle (value : unknown) : string { + return explainEnum("FontStyle", FontStyle, isFontStyle, value); +} + +export function stringifyFontStyle (value : FontStyle) : string { + return stringifyEnum(FontStyle, value); +} + +export function parseFontStyle (value: any) : FontStyle | undefined { + return parseEnum(FontStyle, value) as FontStyle | undefined; +} + +export function isFontStyleOrUndefined (value: unknown): value is FontStyle | undefined { + return isUndefined(value) || isFontStyle(value); +} + +export function explainFontStyleOrUndefined (value: unknown): string { + return isFontStyleOrUndefined(value) ? explainOk() : explainNot(explainOr(['FontStyle', 'undefined'])); +} diff --git a/entities/font/types/FontVariant.ts b/entities/font/types/FontVariant.ts new file mode 100644 index 0000000..a194089 --- /dev/null +++ b/entities/font/types/FontVariant.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; + +/** + * + */ +export enum FontVariant { + NORMAL = "normal", + SMALL_CAPS = "small-caps", +} + +export function isFontVariant (value: unknown) : value is FontVariant { + return isEnum(FontVariant, value); +} + +export function explainFontVariant (value : unknown) : string { + return explainEnum("FontVariant", FontVariant, isFontVariant, value); +} + +export function stringifyFontVariant (value : FontVariant) : string { + return stringifyEnum(FontVariant, value); +} + +export function parseFontVariant (value: any) : FontVariant | undefined { + return parseEnum(FontVariant, value) as FontVariant | undefined; +} + +export function isFontVariantOrUndefined (value: unknown): value is FontVariant | undefined { + return isUndefined(value) || isFontVariant(value); +} + +export function explainFontVariantOrUndefined (value: unknown): string { + return isFontVariantOrUndefined(value) ? explainOk() : explainNot(explainOr(['FontVariant', 'undefined'])); +} diff --git a/entities/font/types/FontWeight.ts b/entities/font/types/FontWeight.ts new file mode 100644 index 0000000..fd9ba66 --- /dev/null +++ b/entities/font/types/FontWeight.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; + +export enum FontWeight { + NORMAL = "normal", + BOLD = "bold", +} + +export function isFontWeight (value: unknown) : value is FontWeight { + return isEnum(FontWeight, value); +} + +export function explainFontWeight (value : unknown) : string { + return explainEnum("FontWeight", FontWeight, isFontWeight, value); +} + +export function stringifyFontWeight (value : FontWeight) : string { + return stringifyEnum(FontWeight, value); +} + +export function parseFontWeight (value: any) : FontWeight | undefined { + return parseEnum(FontWeight, value) as FontWeight | undefined; +} + +export function isFontWeightOrUndefined (value: unknown): value is FontWeight | undefined { + return isUndefined(value) || isFontWeight(value); +} + +export function explainFontWeightOrUndefined (value: unknown): string { + return isFontWeightOrUndefined(value) ? explainOk() : explainNot(explainOr(['FontWeight', 'undefined'])); +} diff --git a/entities/route/Route.ts b/entities/route/Route.ts new file mode 100644 index 0000000..6ec3704 --- /dev/null +++ b/entities/route/Route.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { EntityType } from "../types/EntityType"; +import { RouteDTO } from "./RouteDTO"; +import { Entity } from "../types/Entity"; + +/** + * Presents an interface for route + */ +export interface Route extends Entity { + + /** + * @inheritDoc + */ + valueOf () : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + getDTO () : RouteDTO; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + getEntityType () : EntityType>; + + getName(): string; + setName(value: string): this; + name(value: string): this; + + getPath(): string; + setPath(value: string): this; + path(value: string): this; + + getExtend(): string | undefined; + setExtend(value: string | undefined): this; + extend(value: string | undefined): this; + + getPublicUrl(): string | undefined; + setPublicUrl(value: string | undefined): this; + publicUrl(value: string | undefined): this; + + getLanguage(): string | undefined; + setLanguage(value: string | undefined): this; + language(value: string | undefined): this; + + getView(): string | undefined; + setView(value: string | undefined): this; + view(value: string | undefined): this; + + getRedirect(): string | undefined; + setRedirect(value: string | undefined): this; + redirect(value: string | undefined): this; + +} diff --git a/entities/route/RouteDTO.ts b/entities/route/RouteDTO.ts new file mode 100644 index 0000000..2d9709e --- /dev/null +++ b/entities/route/RouteDTO.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { DTO } from "../types/DTO"; +import { ExtendableDTO } from "../types/ExtendableDTO"; +import { DTOWithOptionalExtend } from "../types/DTOWithOptionalExtend"; +import { DTOWithName } from "../types/DTOWithName"; +import { DTOWithOptionalLanguage } from "../types/DTOWithOptionalLanguage"; +import { DTOWithOptionalPublicUrl } from "../types/DTOWithOptionalPublicUrl"; + +export interface RouteDTO + extends + DTO, + DTOWithName, + DTOWithOptionalExtend, + DTOWithOptionalPublicUrl, + DTOWithOptionalLanguage, + ExtendableDTO +{ + readonly name : string; + readonly path : string; + readonly extend ?: string; + readonly publicUrl ?: string; + readonly language ?: string; + readonly view ?: string; + readonly redirect ?: string; +} diff --git a/entities/route/RouteEntity.test.ts b/entities/route/RouteEntity.test.ts new file mode 100644 index 0000000..9895660 --- /dev/null +++ b/entities/route/RouteEntity.test.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RouteEntity } from "./RouteEntity"; + +describe('RouteEntity', () => { + + describe('#create', () => { + + it('can create an entity', () => { + expect( RouteEntity.create() ).toBeDefined(); + }); + + it('can create an entity with name and path', () => { + const entity = RouteEntity.create( + 'example', + '/example' + ); + expect( entity.getName() ).toBe('example'); + expect( entity.getPath() ).toBe('/example') + }); + + it('can create an entity with allready written name', () => { + const entity = RouteEntity.create().setName('example'); + expect( entity.getName() ).toBe('example'); + }); + + }); + +}); \ No newline at end of file diff --git a/entities/route/RouteEntity.ts b/entities/route/RouteEntity.ts new file mode 100644 index 0000000..afc5633 --- /dev/null +++ b/entities/route/RouteEntity.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RouteDTO } from "./RouteDTO"; +import { Extendable } from "../types/Extendable"; +import { JsonSerializable } from "../types/JsonSerializable"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { Route } from "./Route"; +import { VariableType } from "../types/VariableType"; +import { isString } from "../../types/String"; + + +export const RouteEntityFactory = ( + EntityFactoryImpl.create('Route') + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING) ) + .add( EntityFactoryImpl.createProperty("path").setTypes(VariableType.STRING) ) + .add( EntityFactoryImpl.createProperty("extend").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("publicUrl").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("language").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("view").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("redirect").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) +); + +export const isRoute = RouteEntityFactory.createTestFunctionOfInterface(); + +export const isRouteDTO = RouteEntityFactory.createTestFunctionOfDTO(); + +export const explainRouteDTO = RouteEntityFactory.createExplainFunctionOfDTO(); + +export const isRouteDTOOrUndefined = RouteEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainRouteDTOOrUndefined = RouteEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseRouteEntity = RouteEntityFactory.createEntityType(); + +export class RouteEntity + extends BaseRouteEntity + implements Extendable, JsonSerializable +{ + + + public static create () : RouteEntity; + public static create ( name : string ): RouteEntity; + public static create ( name : string, path : string ): RouteEntity; + public static create ( route : RouteDTO ) : RouteEntity; + + public static create ( + name ?: RouteDTO | string | undefined, + path ?: string | undefined, + ) : RouteEntity { + if (name === undefined && path === undefined) { + return new RouteEntity(); + } else if (isString(name) && path === undefined) { + return new RouteEntity(name, '*'); + } else if (isString(name) && isString(path)) { + return new RouteEntity(name, path); + } else if( path === undefined && isRouteDTO(name) ) { + return new RouteEntity(name); + } else { + throw new TypeError(`RouteEntity.create(name, path): Incorrect arguments: ${name}, ${path}`); + } + } + + public constructor (); + public constructor (route : RouteDTO); + public constructor (name : string); + public constructor (name : string, path: string); + + public constructor ( + name ?: RouteDTO | string | undefined, + path ?: string | undefined, + ) { + if (name === undefined && path === undefined) { + super(); + } else if (isString(name) && isString(path) ) { + super( + { + name, + path, + } + ); + } else if ( isRouteDTO(name) ) { + super(name); + } else { + throw new TypeError(`new RouteEntity(): Incorrect arguments: ${name}, ${path}`); + } + } + +} + +export function isRouteEntity(value: unknown): value is RouteEntity { + return value instanceof RouteEntity; +} diff --git a/entities/seo/Seo.ts b/entities/seo/Seo.ts new file mode 100644 index 0000000..ffd4aa1 --- /dev/null +++ b/entities/seo/Seo.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { Entity } from "../types/Entity"; +import { SeoDTO } from "./SeoDTO"; + +/** + * Presents an interface for SeoEntity. + */ +export interface Seo extends Entity { + + /** + * @inheritDoc + */ + valueOf () : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + getDTO () : SeoDTO; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + + /** + * Get a title. + */ + getTitle () : string | undefined; + + /** + * Set a title. + * + * @param title + */ + setTitle (title : string | undefined) : this; + + /** + * Set a title. + * + * An alias for `.setTitle(title)`. + * + * @param title + */ + title (title : string | undefined) : this; + + + /** + * Get a description. + */ + getDescription () : string | undefined; + + /** + * Set a description. + * + * @param description + */ + setDescription (description : string | undefined) : this; + + /** + * Set a description. + * + * An alias for `.setDescription(description)`. + * + * @param description + */ + description (description : string | undefined) : this; + + + /** + * Get a siteName. + */ + getSiteName () : string | undefined; + + /** + * Set a siteName. + * + * @param siteName + */ + setSiteName (siteName : string | undefined) : this; + + /** + * Set a siteName. + * + * An alias for `.setSiteName(siteName)`. + * + * @param siteName + */ + siteName (siteName : string | undefined) : this; + + +} diff --git a/entities/seo/SeoDTO.ts b/entities/seo/SeoDTO.ts new file mode 100644 index 0000000..d064dab --- /dev/null +++ b/entities/seo/SeoDTO.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { DTO } from "../types/DTO"; + +export interface SeoDTO extends DTO { + readonly title ?: string; + readonly description ?: string; + readonly siteName ?: string; +} diff --git a/entities/seo/SeoEntity.ts b/entities/seo/SeoEntity.ts new file mode 100644 index 0000000..b266e32 --- /dev/null +++ b/entities/seo/SeoEntity.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { reduce } from "../../functions/reduce"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { VariableType } from "../types/VariableType"; +import { Seo } from "./Seo"; +import { SeoDTO } from "./SeoDTO"; + +export const SeoEntityFactory = ( + EntityFactoryImpl.create('Seo') + .add( EntityFactoryImpl.createProperty("title").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("description").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("siteName").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) +); + +export const isSeoDTO = SeoEntityFactory.createTestFunctionOfDTO(); + +export const isSeo = SeoEntityFactory.createTestFunctionOfInterface(); + +export const explainSeoDTO = SeoEntityFactory.createExplainFunctionOfDTO(); + +export const isSeoDTOOrUndefined = SeoEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainSeoDTOOrUndefined = SeoEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseSeoEntity = SeoEntityFactory.createEntityType(); + +/** + * Seo entity. + */ +export class SeoEntity + extends BaseSeoEntity + implements Seo +{ + + /** + * Creates a Seo entity. + * + * @param value The optional DTO of Seo + */ + public static create ( + value ?: SeoDTO, + ) : SeoEntity { + return new SeoEntity(value); + } + + /** + * Creates a Seo entity from DTO. + * + * @param dto The optional DTO of Seo + */ + public static createFromDTO ( + dto : SeoDTO, + ) : SeoEntity { + return new SeoEntity(dto); + } + + /** + * Merges multiple values as one entity. + */ + public static merge ( + ...values: readonly (SeoDTO | Seo | SeoEntity)[] + ) : SeoEntity { + return SeoEntity.createFromDTO( + reduce( + values, + ( + prev: SeoDTO, + item: SeoDTO | Seo | SeoEntity, + ) : SeoDTO => { + const dto : SeoDTO = this.toDTO(item); + return { + ...prev, + ...dto, + }; + }, + {}, + ) + ); + } + + /** + * Normalizes the value as a DTO. + */ + public static toDTO ( + value: SeoDTO | Seo | SeoEntity, + ) : SeoDTO { + if (isSeoEntity(value)) { + return value.getDTO(); + } else if (isSeo(value)) { + return value.getDTO(); + } else { + return value; + } + } + + /** + * Construct an entity of SeoEntity. + */ + public constructor ( + dto ?: SeoDTO | undefined, + ) { + super(dto); + } + +} + +export function isSeoEntity (value: unknown): value is SeoEntity { + return value instanceof SeoEntity; +} diff --git a/entities/size/Size.ts b/entities/size/Size.ts new file mode 100644 index 0000000..178f9d4 --- /dev/null +++ b/entities/size/Size.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { + SizeDTO, + +} from "./SizeDTO"; +import { Entity } from "../types/Entity"; +import { UnitType } from "../types/UnitType"; +import { SpecialSize } from "./SpecialSize"; + +/** + * Presents a color value + */ +export interface Size + extends Entity { + + /** + * Returns the DTO object. + */ + getDTO () : SizeDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Get a value. + */ + getValue () : number | SpecialSize.AUTO; + + /** + * Set a value. + * + * @param value + */ + setValue ( + value : SpecialSize.AUTO, + ) : this; + + /** + * Set a value. + * + * @param value + * @param unit + */ + setValue ( + value : number, + unit ?: UnitType | undefined, + ) : this; + + /** + * Get unit type. + */ + getUnit () : UnitType | undefined; + + /** + * Get unit type. It is an alias. + */ + getUnitType () : UnitType | undefined; + + /** + * Returns CSS styles. + */ + getCssStyles () : string; + + /** + * Set the size to be special auto value. + */ + setAuto () : this; + + /** + * Returns true if the size is special auto value. + */ + isAuto () : boolean; + +} + +export function isSize (value : unknown) : value is Size { + return ( + isObject(value) + && isFunction(value?.getDTO) + && isFunction(value?.valueOf) + && isFunction(value?.toJSON) + && isFunction(value?.getValue) + && isFunction(value?.setValue) + && isFunction(value?.getUnitType) + && isFunction(value?.getCssStyles) + ); +} + diff --git a/entities/size/SizeDTO.ts b/entities/size/SizeDTO.ts new file mode 100644 index 0000000..f90323e --- /dev/null +++ b/entities/size/SizeDTO.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DTO } from "../types/DTO"; +import { UnitType } from "../types/UnitType"; +import { SpecialSize } from "./SpecialSize"; + +export interface SizeDTO extends DTO { + + readonly value: number | SpecialSize; + + /** + * Defaults to pixels. + */ + readonly unit ?: UnitType; + +} + +/** + * + * @deprecated + */ +export function createSizeDTO ( + value : number | SpecialSize, + unit ?: UnitType | undefined, +) : SizeDTO { + return { + value, + ...(unit ? {unit} : {}), + }; +} diff --git a/entities/size/SizeEntity.test.ts b/entities/size/SizeEntity.test.ts new file mode 100644 index 0000000..c1f397d --- /dev/null +++ b/entities/size/SizeEntity.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SizeEntity } from "./SizeEntity"; +import { UnitType } from "../types/UnitType"; + +describe('SizeEntity', () => { + + describe('#create', () => { + + it('can create an entity', () => { + expect( SizeEntity.create() ).toBeDefined(); + }); + + it('can create an entity with auto size by default', () => { + expect( SizeEntity.create().isAuto() ).toBe(true); + }); + + it('can create an entity with 10 px', () => { + const entity = SizeEntity.create(10); + expect( entity.getValue() ).toBe(10); + expect( entity.getUnitType() ).toBe(UnitType.PX); + }); + + it('can create an entity with 10 %', () => { + const entity = SizeEntity.create(10, UnitType.PERCENT); + expect( entity.getValue() ).toBe(10); + expect( entity.getUnitType() ).toBe(UnitType.PERCENT); + }); + + }); + +}); diff --git a/entities/size/SizeEntity.ts b/entities/size/SizeEntity.ts new file mode 100644 index 0000000..237f6d8 --- /dev/null +++ b/entities/size/SizeEntity.ts @@ -0,0 +1,362 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { LogUtils } from "../../LogUtils"; +import { isNumber } from "../../types/Number"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { EntityMethodImpl } from "../types/EntityMethodImpl"; +import { + isUnitTypeOrUndefined, + UnitType, +} from "../types/UnitType"; +import { VariableType } from "../types/VariableType"; +import { Size } from "./Size"; +import { SizeDTO } from "./SizeDTO"; +import { + isAutoSpecialSize, + SpecialSize, +} from "./SpecialSize"; + +export const SizeEntityFactory = ( + EntityFactoryImpl.create('Size') + .addStaticMethod( + EntityMethodImpl.create('create') + .addArgument(VariableType.NUMBER) + .returnType('Size') + ) + .addStaticMethod( + EntityMethodImpl.create('create') + .addArgument(SpecialSize) + .returnType('Size') + ) + .add( EntityFactoryImpl.createProperty("value").setTypes(VariableType.NUMBER, SpecialSize) ) + .add( EntityFactoryImpl.createProperty("unit", "unitType").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) +); + +export const isSizeDTO = SizeEntityFactory.createTestFunctionOfDTO(); + +export const isSize = SizeEntityFactory.createTestFunctionOfInterface(); + +export const explainSizeDTO = SizeEntityFactory.createExplainFunctionOfDTO(); + +export const isSizeDTOOrUndefined = SizeEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainSizeDTOOrUndefined = SizeEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseSizeEntity = SizeEntityFactory.createEntityType(); + +/** + * Size entity. + */ +export class SizeEntity + extends BaseSizeEntity + implements Size +{ + + public static createAuto () : SizeEntity { + return new SizeEntity(SpecialSize.AUTO); + } + + /** + * Creates a size entity with auto size. + */ + public static create () : SizeEntity; + + /** + * Creates a size with pixels. + * + * @param value + */ + public static create ( + value : Size, + ) : SizeEntity; + + /** + * Creates a size with pixels. + * + * @param value + */ + public static create ( + value : number, + ) : SizeEntity; + + /** + * Creates a auto size entity. + * + * @param value + */ + public static create ( + value : SpecialSize.AUTO, + ) : SizeEntity; + + /** + * Creates a size entity with a unit type. + * + * @param value + * @param unit Defaults to pixels. + */ + public static create ( + value : number, + unit : UnitType, + ) : SizeEntity; + + /** + * Creates a size entity. + * + * @param value + * @param unit Defaults to pixels. + */ + public static create ( + value ?: Size | number | SpecialSize.AUTO, + unit ?: UnitType, + ) : SizeEntity { + + if (value === undefined) { + return new SizeEntity(SpecialSize.AUTO) + } + + if (isAutoSpecialSize(value)) { + return new SizeEntity(SpecialSize.AUTO); + } + + if (isSizeEntity(value) || isSize(value)) { + const v = value.getValue(); + if (v === SpecialSize.AUTO) return new SizeEntity(SpecialSize.AUTO); + return new SizeEntity( + v, + unit ?? UnitType.PX, + ); + } + + return new SizeEntity( + value, + unit ?? UnitType.PX, + ); + } + + public static toDTO ( + value: SizeEntity | Size | number | undefined, + ) : SizeDTO | undefined { + if (value === undefined) { + return undefined; + } + if (isSizeEntity(value)) { + return value.getDTO(); + } + if (isSize(value)) { + return value.getDTO(); + } + if (isNumber(value)) { + return { + value: value, + unit: UnitType.PX, + }; + } + throw new TypeError(`SizeEntity.toDTO(): Could not turn into DTO: ${LogUtils.stringifyValue(value)}`); + } + + /** + * Creates a size entity using percents. + * + * @param value Value in percents. + */ + public static createPercent ( + value : number, + ) : SizeEntity { + return this.create( + value, + UnitType.PERCENT, + ); + } + + /** + * Creates a size entity using view height (vh). + * + * @param value Value in percents. + */ + public static createViewHeight ( + value : number, + ) : SizeEntity { + return this.create( + value, + UnitType.VH, + ); + } + + /** + * Creates a size entity using view width (vw). + * + * @param value Value in percents. + */ + public static createViewWidth ( + value : number, + ) : SizeEntity { + return this.create( + value, + UnitType.VW, + ); + } + + /** + * Creates a size entity using pixels. + * + * @param value Value in pixels. + */ + public static createPx ( + value : number, + ) : SizeEntity { + return this.create( + value, + UnitType.PX, + ); + } + + /** + * Creates a size entity using pixels. + * + * @param value Value in pixels. + */ + public static createPixels ( + value : number, + ) : SizeEntity { + return this.create( + value, + UnitType.PX, + ); + } + + /** + * Creates a size entity using points. + * + * @param value Value in points. + */ + public static createPt ( + value : number, + ) : SizeEntity { + return this.create( + value, + UnitType.PT, + ); + } + + /** + * Creates a size entity using em. + * + * @param value Value in em. + */ + public static createEm ( + value : number, + ) : SizeEntity { + return this.create( + value, + UnitType.EM, + ); + } + + public static createZero () : SizeEntity { + return SizeEntity.create(0); + } + + /** + * Creates a size entity from a DTO. + * + * @param dto + */ + public static createFromDTO ( + dto : SizeDTO, + ) : SizeEntity { + + if (isAutoSpecialSize(dto.value)) { + return SizeEntity.createAuto(); + } + + return SizeEntity.create( + dto.value, + dto.unit ?? UnitType.PX, + ); + } + + + /** + * Construct empty entity + * + * @protected + */ + public constructor (); + + /** + * Construct empty from DTO + */ + public constructor ( + dto: SizeDTO + ); + + /** + * Construct the entity with unit type. + * + * @param value + * @param unit + * @public + */ + public constructor ( + value : number, + unit : UnitType, + ); + + /** + * Construct the entity as a special auto size. + * + * @param value + * @protected + */ + public constructor ( + value : SpecialSize.AUTO, + ); + + /** + * Implementations. + * + * @param value + * @param unit + * @protected + */ + public constructor ( + value ?: SizeDTO | number | SpecialSize.AUTO, + unit ?: UnitType, + ) { + if (value === undefined && unit === undefined) { + super(); + } else if ( isNumber(value) && isUnitTypeOrUndefined(unit) ) { + super( { value, unit } ); + } else if ( isAutoSpecialSize(value) ) { + super( { value: SpecialSize.AUTO } ); + } else if ( isSizeDTO(value) ) { + super( value ); + } else if ( isSizeEntity(value) ) { + super( value.getDTO() ); + } else { + throw new TypeError(`new SizeEntity(): Invalid arguments: ${value}, ${unit}`); + } + } + + + public isAuto (): boolean { + return this.getValue() === SpecialSize.AUTO; + } + + public setAuto (): this { + this.setValue(SpecialSize.AUTO); + return this; + } + + public getCssStyles (): string { + const value = this.getValue(); + if (value === SpecialSize.AUTO) return 'auto'; + if (value === 0) return `0`; + return `${value}${this.getUnitType()}`; + } + +} + +export function isSizeEntity (value: unknown): value is SizeEntity { + return value instanceof SizeEntity; +} diff --git a/entities/size/SpecialSize.ts b/entities/size/SpecialSize.ts new file mode 100644 index 0000000..e77a085 --- /dev/null +++ b/entities/size/SpecialSize.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export enum SpecialSize { + AUTO = "auto" +} + +export function isAutoSpecialSize ( value : unknown ) : value is SpecialSize.AUTO { + return value === SpecialSize.AUTO; +} diff --git a/entities/sizeBox/SizeBox.ts b/entities/sizeBox/SizeBox.ts new file mode 100644 index 0000000..baa4b9d --- /dev/null +++ b/entities/sizeBox/SizeBox.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { + SizeDTO, + +} from "../size/SizeDTO"; +import { ReadonlyJsonObject } from "../../Json"; +import { SpecialSize } from "../size/SpecialSize"; +import { + SizeBoxDTO, +} from "./SizeBoxDTO"; +import { SizeEntity } from "../size/SizeEntity"; +import { Entity } from "../types/Entity"; +import { UnitType } from "../types/UnitType"; + +/** + * Presents a box of sizes (e.g. top, bottom, left, right) + */ +export interface SizeBox + extends Entity { + + /** + * Returns the DTO object. + */ + getDTO () : SizeBoxDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : string; + + getTop () : SizeEntity | undefined; + getTopDTO () : SizeDTO | undefined; + setTop ( value : undefined ) : this; + setTop ( value : SizeEntity ) : this; + setTop ( value : SizeDTO ) : this; + setTop ( value : SpecialSize.AUTO ) : this; + setTop ( value : number ) : this; + setTop ( value : number, unit : UnitType ) : this; + + getRight () : SizeEntity | undefined; + getRightDTO () : SizeDTO | undefined; + setRight ( value : undefined ) : this; + setRight ( value : SizeEntity ) : this; + setRight ( value : SizeDTO ) : this; + setRight ( value : SpecialSize.AUTO ) : this; + setRight ( value : number ) : this; + setRight ( value : number, unit : UnitType ) : this; + + getBottom () : SizeEntity | undefined; + getBottomDTO () : SizeDTO | undefined; + setBottom ( value : undefined ) : this; + setBottom ( value : SizeEntity ) : this; + setBottom ( value : SizeDTO ) : this; + setBottom ( value : SpecialSize.AUTO ) : this; + setBottom ( value : number ) : this; + setBottom ( value : number, unit : UnitType ) : this; + + getLeft () : SizeEntity | undefined; + getLeftDTO () : SizeDTO | undefined; + setLeft ( value : undefined ) : this; + setLeft ( value : SizeEntity ) : this; + setLeft ( value : SizeDTO ) : this; + setLeft ( value : SpecialSize.AUTO ) : this; + setLeft ( value : number ) : this; + setLeft ( value : number, unit : UnitType ) : this; + +} diff --git a/entities/sizeBox/SizeBoxDTO.ts b/entities/sizeBox/SizeBoxDTO.ts new file mode 100644 index 0000000..3985ef1 --- /dev/null +++ b/entities/sizeBox/SizeBoxDTO.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SizeDTO } from "../size/SizeDTO"; +import { DTO } from "../types/DTO"; + +export interface SizeBoxDTO extends DTO { + readonly top ?: SizeDTO; + readonly right ?: SizeDTO; + readonly bottom ?: SizeDTO; + readonly left ?: SizeDTO; +} diff --git a/entities/sizeBox/SizeBoxEntity.ts b/entities/sizeBox/SizeBoxEntity.ts new file mode 100644 index 0000000..4fc2d65 --- /dev/null +++ b/entities/sizeBox/SizeBoxEntity.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { VariableType } from "../types/VariableType"; +import { SizeBoxDTO } from "./SizeBoxDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { reduce } from "../../functions/reduce"; +import { + isSizeDTO, + SizeEntity, +} from "../size/SizeEntity"; +import { SizeBox } from "./SizeBox"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; + +export const SizeBoxEntityFactory = ( + EntityFactoryImpl.create('SizeBox') + .add( EntityFactoryImpl.createProperty("top").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("right").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("bottom").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("left").setTypes(SizeEntity, VariableType.UNDEFINED) ) +); + +export const isSizeBoxDTO = SizeBoxEntityFactory.createTestFunctionOfDTO(); + +export const isSizeBox = SizeBoxEntityFactory.createTestFunctionOfInterface(); + +export const explainSizeBoxDTO = SizeBoxEntityFactory.createExplainFunctionOfDTO(); + +export const isSizeBoxDTOOrUndefined = SizeBoxEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainSizeBoxDTOOrUndefined = SizeBoxEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseSizeBoxEntity = SizeBoxEntityFactory.createEntityType(); + + + +/** + * SizeBox entity. + */ +export class SizeBoxEntity + extends BaseSizeBoxEntity + implements SizeBox +{ + + public static create () : SizeBoxEntity; + + /** + * Creates a size box entity. + * + * @param topAndBottom + * @param rightAndLeft + */ + public static create ( + topAndBottom : SizeDTO, + rightAndLeft : SizeDTO, + ) : SizeBoxEntity; + + /** + * Creates a size box entity. + * + * @param top + * @param right + * @param bottom + * @param left + */ + public static create ( + top : SizeDTO, + right : SizeDTO, + bottom : SizeDTO, + left : SizeDTO, + ) : SizeBoxEntity; + + /** + * Creates a size box entity. + * + * @param top + * @param right + * @param bottom + * @param left + */ + public static create ( + top ?: SizeDTO | SizeBoxDTO | SizeBoxEntity, + right ?: SizeDTO, + bottom ?: SizeDTO, + left ?: SizeDTO, + ) : SizeBoxEntity { + if ( top === undefined && right === undefined && bottom === undefined && left === undefined ) { + return new SizeBoxEntity(); + } else if ( isSizeBoxDTO(top) ) { + return new SizeBoxEntity(top); + } else if ( isSizeBoxEntity(top) ) { + return new SizeBoxEntity(top.getDTO()); + } else if ( isSizeBox(top) ) { + return new SizeBoxEntity(top.getDTO()); + } else if ( isSizeDTO(top) && isSizeDTO(right) && bottom === undefined && left === undefined ) { + return new SizeBoxEntity( {top, right, bottom: top, left: right} ); + } else if ( isSizeDTO(top) && isSizeDTO(right) && isSizeDTO(bottom) && isSizeDTO(left) ) { + return new SizeBoxEntity( {top, right, bottom, left} ); + } else { + throw new TypeError(`Invalid arguments for create: ${top}, ${right}, ${bottom}, ${left}`); + } + } + + /** + * Creates a size box entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : SizeBoxDTO, + ) : SizeBoxEntity { + return new SizeBoxEntity(value); + } + + public static merge ( + ...values: readonly (SizeBoxDTO | SizeBox | SizeBoxEntity)[] + ) : SizeBoxEntity { + return SizeBoxEntity.createFromDTO( + reduce( + values, + ( + prev: SizeBoxDTO, + item: SizeBoxDTO | SizeBox | SizeBoxEntity, + ) : SizeBoxDTO => { + const dto : SizeBoxDTO = this.toDTO(item); + return { + ...prev, + ...dto, + }; + }, + {}, + ) + ); + } + + public static toDTO ( + value: SizeBoxDTO | SizeBox | SizeBoxEntity, + ) : SizeBoxDTO { + if (isSizeBoxEntity(value)) { + return value.getDTO(); + } else if (isSizeBox(value)) { + return value.getDTO(); + } else { + return value; + } + } + + protected constructor ( + value ?: SizeBoxDTO | SizeBox, + ) { + super( + isSizeBoxDTO(value) + ? value + : ( + isSizeBox(value) + ? value.getDTO() + : undefined + ) + ); + } + + /** + * @inheritDoc + */ + public getCssStyles (): string { + const top = (this.getTop() ?? SizeEntity.createZero()).getCssStyles(); + const right = (this.getRight() ?? SizeEntity.createZero()).getCssStyles(); + const bottom = (this.getBottom() ?? SizeEntity.createZero()).getCssStyles(); + const left = (this.getLeft() ?? SizeEntity.createZero()).getCssStyles(); + if ( top === bottom && right === left ) { + if ( top === right ) { + return `${ top }`; + } + return `${ top } ${ right }`; + } + return `${ top } ${ right } ${ bottom } ${ left }`; + } + +} + +export function isSizeBoxEntity (value: unknown): value is SizeBoxEntity { + return value instanceof SizeBoxEntity; +} diff --git a/entities/sizeDimensions/SizeDimensions.ts b/entities/sizeDimensions/SizeDimensions.ts new file mode 100644 index 0000000..de92d5e --- /dev/null +++ b/entities/sizeDimensions/SizeDimensions.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { Size } from "../size/Size"; +import { + SizeDTO, + +} from "../size/SizeDTO"; +import { ReadonlyJsonObject } from "../../Json"; +import { SpecialSize } from "../size/SpecialSize"; +import { + SizeDimensionsDTO, +} from "./SizeDimensionsDTO"; +import { SizeEntity } from "../size/SizeEntity"; +import { Entity } from "../types/Entity"; +import { UnitType } from "../types/UnitType"; + +/** + * Presents dimensions of a box (e.g. width, height) + */ +export interface SizeDimensions + extends Entity { + + /** + * Returns the DTO object. + */ + getDTO () : SizeDimensionsDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : string; + + + /** + */ + getWidth () : SizeEntity | undefined; + + /** + */ + getWidthDTO () : SizeDTO | undefined; + + /** + * @param value + */ + setWidth ( + value : SpecialSize.AUTO, + ) : this; + + + /** + * @param value + */ + setWidth ( + value : SizeDTO | Size | SizeEntity, + ) : this; + + /** + * Set a top as a unit. + * + * @param value + * @param unit + */ + setWidth ( + value ?: number | undefined, + unit ?: UnitType | undefined, + ) : this; + + /** + * @param value + */ + width ( + value : SizeDTO | Size | SizeEntity, + ) : this; + + /** + * @param value + */ + width ( + value : SpecialSize.AUTO, + ) : this; + + /** + * @param value + * @param unit + */ + width ( + value ?: number | undefined, + unit ?: UnitType | undefined, + ) : this; + + + /** + */ + getHeight () : SizeEntity | undefined; + + /** + * Get height as a DTO. + */ + getHeightDTO () : SizeDTO | undefined; + + /** + * Set a height value as auto + * + * @param value + */ + setHeight ( + value : SpecialSize.AUTO, + ) : this; + + /** + * Set a height as a unit. + * + * @param value + * @param unit + */ + setHeight ( + value ?: number | undefined, + unit ?: UnitType | undefined, + ) : this; + + /** + * Set a height value as auto + * + * @param value + */ + height ( + value : SpecialSize.AUTO, + ) : this; + + /** + * Set a height as a unit. + * + * @param value + * @param unit + */ + height ( + value ?: number | undefined, + unit ?: UnitType | undefined, + ) : this; + +} diff --git a/entities/sizeDimensions/SizeDimensionsDTO.ts b/entities/sizeDimensions/SizeDimensionsDTO.ts new file mode 100644 index 0000000..75eaf41 --- /dev/null +++ b/entities/sizeDimensions/SizeDimensionsDTO.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + SizeDTO, +} from "../size/SizeDTO"; +import { DTO } from "../types/DTO"; + +export interface SizeDimensionsDTO extends DTO { + readonly width ?: SizeDTO; + readonly height ?: SizeDTO; +} + +/** + * + * @deprecated + */ +export function createSizeDimensionsDTO ( + width : SizeDTO | undefined, + height : SizeDTO | undefined, +) : SizeDimensionsDTO { + return { + width, + height, + }; +} + diff --git a/entities/sizeDimensions/SizeDimensionsEntity.ts b/entities/sizeDimensions/SizeDimensionsEntity.ts new file mode 100644 index 0000000..f55cfeb --- /dev/null +++ b/entities/sizeDimensions/SizeDimensionsEntity.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { VariableType } from "../types/VariableType"; +import { + createSizeDimensionsDTO, + SizeDimensionsDTO, +} from "./SizeDimensionsDTO"; +import { + SizeDTO, +} from "../size/SizeDTO"; +import { reduce } from "../../functions/reduce"; +import { + isSizeDTO, + SizeEntity, +} from "../size/SizeEntity"; +import { + SizeDimensions, +} from "./SizeDimensions"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; + +export const SizeDimensionsEntityFactory = ( + EntityFactoryImpl.create('SizeDimensions') + .add( EntityFactoryImpl.createProperty("width").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("height").setTypes(SizeEntity, VariableType.UNDEFINED) ) +); + +export const BaseSizeDimensionsEntity = SizeDimensionsEntityFactory.createEntityType(); + +export const isSizeDimensionsDTO = SizeDimensionsEntityFactory.createTestFunctionOfDTO(); + +export const isSizeDimensions = SizeDimensionsEntityFactory.createTestFunctionOfInterface(); + +export const explainSizeDimensionsDTO = SizeDimensionsEntityFactory.createExplainFunctionOfDTO(); + +export const isSizeDimensionsDTOOrUndefined = SizeDimensionsEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainSizeDimensionsDTOOrUndefined = SizeDimensionsEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +/** + * SizeDimensions entity. + */ +export class SizeDimensionsEntity + extends BaseSizeDimensionsEntity + implements SizeDimensions +{ + + public static create () : SizeDimensionsEntity; + + /** + * Creates a size box entity. + * + * @param width + * @param height + * @param bottom + * @param left + */ + public static create ( + width : SizeDTO, + height : SizeDTO, + ) : SizeDimensionsEntity; + + /** + * Creates a size box entity. + * + * @param width + * @param height + */ + public static create ( + width ?: SizeDTO | SizeDimensionsDTO | SizeDimensionsEntity, + height ?: SizeDTO, + ) : SizeDimensionsEntity { + if ( width === undefined && height === undefined ) { + return new SizeDimensionsEntity(); + } else if ( isSizeDimensionsDTO(width) ) { + return new SizeDimensionsEntity(width); + } else if ( isSizeDimensionsEntity(width) ) { + return new SizeDimensionsEntity(width.getDTO()); + } else if ( isSizeDimensions(width) ) { + return new SizeDimensionsEntity(width.getDTO()); + } else if ( isSizeDTO(width) && isSizeDTO(height) ) { + return new SizeDimensionsEntity( { width, height } ); + } else { + throw new TypeError(`Invalid arguments for create: ${width}, ${height}`); + } + } + + /** + * Creates a size box entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : SizeDimensionsDTO, + ) : SizeDimensionsEntity { + return new SizeDimensionsEntity(value); + } + + public static merge ( + ...values: readonly (SizeDimensionsDTO | SizeDimensions | SizeDimensionsEntity)[] + ) : SizeDimensionsEntity { + return SizeDimensionsEntity.createFromDTO( + reduce( + values, + ( + prev: SizeDimensionsDTO, + item: SizeDimensionsDTO | SizeDimensions | SizeDimensionsEntity, + ) : SizeDimensionsDTO => { + const dto : SizeDimensionsDTO = this.toDTO(item); + return { + ...prev, + ...dto, + }; + }, + createSizeDimensionsDTO( + undefined, + undefined, + ), + ) + ); + } + + public static toDTO ( + value: SizeDimensionsDTO | SizeDimensions | SizeDimensionsEntity, + ) : SizeDimensionsDTO { + if (isSizeDimensionsEntity(value)) { + return value.getDTO(); + } else if (isSizeDimensions(value)) { + return value.getDTO(); + } else { + return value; + } + } + + protected constructor ( + value ?: SizeDimensionsDTO | SizeDimensions, + ) { + super( + isSizeDimensionsDTO(value) + ? value + : ( + isSizeDimensions(value) + ? value.getDTO() + : undefined + ) + ); + } + + /** + * @inheritDoc + */ + public getCssStyles (): string { + const width = (this.getWidth() ?? SizeEntity.createZero()).getCssStyles(); + const height = (this.getHeight() ?? SizeEntity.createZero()).getCssStyles(); + if ( width === height ) { + return `${ width }`; + } + return `${ width } ${ height }`; + } + +} + +export function isSizeDimensionsEntity (value: unknown): value is SizeDimensionsEntity { + return value instanceof SizeDimensionsEntity; +} diff --git a/entities/style/Style.ts b/entities/style/Style.ts new file mode 100644 index 0000000..19aa521 --- /dev/null +++ b/entities/style/Style.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { BorderBoxDTO } from "../borderBox/BorderBoxDTO"; +import { SizeBoxDTO } from "../sizeBox/SizeBoxDTO"; +import { ReadonlyJsonObject } from "../../Json"; +import { BackgroundDTO } from "../background/BackgroundDTO"; +import { BorderDTO } from "../border/BorderDTO"; +import { ColorDTO } from "../color/ColorDTO"; +import { FontDTO } from "../font/FontDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { StyleDTO } from "./StyleDTO"; +import { TextDecorationDTO } from "../textDecoration/TextDecorationDTO"; +import { BoxSizing } from "../types/BoxSizing"; +import { TextAlign } from "../types/TextAlign"; +import { BackgroundEntity } from "../background/BackgroundEntity"; +import { BorderBoxEntity } from "../borderBox/BorderBoxEntity"; +import { BorderEntity } from "../border/BorderEntity"; +import { ColorEntity } from "../color/ColorEntity"; +import { FontEntity } from "../font/FontEntity"; +import { SizeBoxEntity } from "../sizeBox/SizeBoxEntity"; +import { SizeEntity } from "../size/SizeEntity"; +import { TextDecorationEntity } from "../textDecoration/TextDecorationEntity"; +import { Background } from "../background/Background"; +import { Border } from "../border/Border"; +import { BorderBox } from "../borderBox/BorderBox"; +import { Entity } from "../types/Entity"; +import { Font } from "../font/Font"; +import { Size } from "../size/Size"; +import { SizeBox } from "../sizeBox/SizeBox"; +import { TextDecoration } from "../textDecoration/TextDecoration"; + +/** + * Interface for Style entities. + */ +export interface Style + extends Entity +{ + + /** + * Returns the DTO object. + */ + getDTO () : StyleDTO; + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + + /** + * Get text color as entity. + */ + getTextColor () : ColorEntity | undefined; + + /** + * Get text color as DTO. + */ + getTextColorDTO () : ColorDTO | undefined; + + /** + * Set text color. + * + * @param value + */ + setTextColor (value: ColorEntity | ColorDTO | string | undefined) : this; + + + /** + * Get text alignment. + */ + getTextAlign () : TextAlign | undefined; + + /** + * Set text alignment. + * + * @param value + */ + setTextAlign (value: TextAlign | undefined) : this; + + + /** + * Get box sizing. + */ + getBoxSizing () : BoxSizing | undefined; + + /** + * Set box sizing. + * + * @param value + */ + setBoxSizing (value: BoxSizing | undefined) : this; + + + /** + * Get background color + */ + getBackgroundColor () : ColorEntity | undefined; + + /** + * Get background color DTO + */ + getBackgroundColorDTO () : ColorDTO | undefined; + + /** + * Set background color. + * + * @param value + */ + setBackgroundColor (value: ColorEntity | ColorDTO | string | undefined) : this; + + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + getMargin () : Size | SizeBox | undefined; + getTopMargin () : Size | undefined; + getBottomMargin () : Size | undefined; + getRightMargin () : Size | undefined; + getLeftMargin () : Size | undefined; + + setMargin (value: SizeEntity | Size | SizeBox | number | undefined) : this; + setTopMargin (value: SizeEntity | Size | number | undefined) : this; + setBottomMargin (value: SizeEntity | Size | number | undefined) : this; + setRightMargin (value: SizeEntity | Size | number | undefined) : this; + setLeftMargin (value: SizeEntity | Size | number | undefined) : this; + + + getPadding () : Size | SizeBox | undefined; + getPadding () : SizeDTO | SizeBoxDTO | undefined; + setPadding (value: SizeEntity | SizeBoxEntity | SizeDTO | SizeBoxDTO | number | undefined) : this; + + getTopPadding () : Size | undefined; + getBottomPadding () : Size | undefined; + getRightPadding () : Size | undefined; + getLeftPadding () : Size | undefined; + + setTopPadding (value: SizeEntity | Size | number | undefined) : this; + setBottomPadding (value: SizeEntity | Size | number | undefined) : this; + setRightPadding (value: SizeEntity | Size | number | undefined) : this; + setLeftPadding (value: SizeEntity | Size | number | undefined) : this; + + + getBorder () : Border | BorderBox | undefined; + getBorderDTO () : BorderDTO | BorderBoxDTO | undefined; + setBorder (value : BorderEntity | BorderDTO | BorderBoxEntity | BorderBoxDTO | number | undefined) : this; + + getTopBorder () : Border | undefined; + getBottomBorder () : Border | undefined; + getRightBorder () : Border | undefined; + getLeftBorder () : Border | undefined; + + setTopBorder (value: Border | BorderEntity | number | undefined) : this; + setBottomBorder (value: Border | BorderEntity | number | undefined) : this; + setRightBorder (value: Border | BorderEntity | number | undefined) : this; + setLeftBorder (value: Border | BorderEntity | number | undefined) : this; + + getFontDTO () : FontDTO | undefined; + getFont () : Font | undefined; + setFont (value: FontEntity | Font | string | number | undefined) : this; + + /** + * Get text decorations. + */ + getTextDecoration () : TextDecorationEntity | undefined; + + /** + * Get text decorations as a DTO. + */ + getTextDecorationDTO () : TextDecorationDTO | undefined; + + /** + * Set text decorations. + * + * @param value + */ + setTextDecoration (value: TextDecoration | TextDecorationEntity | TextDecorationDTO | undefined) : this; + + getWidth () : SizeEntity | undefined; + getWidthDTO () : SizeDTO | undefined; + setWidth (value: Size | SizeEntity | number | undefined) : this; + + getHeight () : SizeEntity | undefined; + getHeightDTO () : SizeDTO | undefined; + setHeight (value: Size | SizeEntity | number | undefined) : this; + + getMinWidth () : SizeEntity | undefined; + getMinWidthDTO () : SizeDTO | undefined; + setMinWidth (value: Size | SizeEntity | number | undefined) : this; + + getMinHeight () : SizeEntity | undefined; + getMinHeightDTO () : SizeDTO | undefined; + setMinHeight (value: Size | SizeEntity | number | undefined) : this; + + getMaxWidth () : SizeEntity | undefined; + getMaxWidthDTO () : SizeDTO | undefined; + setMaxWidth (value: Size | SizeEntity | number | undefined) : this; + + getMaxHeight () : SizeEntity | undefined; + getMaxHeightDTO () : SizeDTO | undefined; + setMaxHeight (value: Size | SizeEntity | number | undefined) : this; + + getBackground () : Background | undefined; + getBackgroundDTO () : BackgroundDTO | undefined; + setBackground (value: Background | BackgroundDTO | BackgroundEntity | number | undefined) : this; + +} diff --git a/entities/style/StyleDTO.ts b/entities/style/StyleDTO.ts new file mode 100644 index 0000000..255e295 --- /dev/null +++ b/entities/style/StyleDTO.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { BackgroundDTO } from "../background/BackgroundDTO"; +import { BorderDTO } from "../border/BorderDTO"; +import { ColorDTO } from "../color/ColorDTO"; +import { FontDTO } from "../font/FontDTO"; +import { SizeBoxDTO } from "../sizeBox/SizeBoxDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { TextDecorationDTO } from "../textDecoration/TextDecorationDTO"; +import { BoxSizing} from "../types/BoxSizing"; +import { DTO } from "../types/DTO"; +import { TextAlign } from "../types/TextAlign"; + +export interface StyleDTO extends DTO { + readonly textAlign ?: TextAlign; + readonly textColor ?: ColorDTO; + readonly width ?: SizeDTO; + readonly height ?: SizeDTO; + readonly margin ?: SizeDTO | SizeBoxDTO; + readonly padding ?: SizeDTO | SizeBoxDTO; + readonly border ?: BorderDTO | [BorderDTO, BorderDTO] | [BorderDTO, BorderDTO, BorderDTO, BorderDTO]; + readonly font ?: FontDTO; + readonly textDecoration ?: TextDecorationDTO; + readonly background ?: BackgroundDTO; + readonly minWidth ?: SizeDTO; + readonly minHeight ?: SizeDTO; + readonly maxWidth ?: SizeDTO; + readonly maxHeight ?: SizeDTO; + readonly boxSizing ?: BoxSizing; +} diff --git a/entities/style/StyleEntity.test.ts b/entities/style/StyleEntity.test.ts new file mode 100644 index 0000000..eca73d5 --- /dev/null +++ b/entities/style/StyleEntity.test.ts @@ -0,0 +1,1431 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + describe, + expect, + it, +} from '@jest/globals'; +import { BackgroundDTO } from "../background/BackgroundDTO"; +import { ColorDTO } from "../color/ColorDTO"; +import { ColorEntity } from "../color/ColorEntity"; +import { TextDecorationEntity } from "../textDecoration/TextDecorationEntity"; +import { BoxSizing } from "../types/BoxSizing"; +import { TextAlign } from "../types/TextAlign"; +import { TextDecorationStyle } from "../types/TextDecorationStyle"; +import { UnitType } from "../types/UnitType"; +import { StyleDTO } from "./StyleDTO"; +import { StyleEntity } from "./StyleEntity"; + +describe('StyleEntity', () => { + + let whiteColor : ColorDTO; + let blackColor : ColorDTO; + + beforeEach(() => { + whiteColor = ColorEntity.create('#fff').getDTO(); + blackColor = ColorEntity.create('#000').getDTO(); + }); + + describe('Static methods', () => { + + describe('#create', () => { + it('can create style entities', () => { + let obj = StyleEntity.create(); + expect(obj.getDTO()).toStrictEqual({}); + }); + }); + + describe('#isDTO', () => { + + it('can test a DTO', () => { + const backgroundColor : ColorDTO = { + value: "#222222" + }; + const background : BackgroundDTO = { + color: backgroundColor + }; + const textColor: ColorDTO = { + value: "#ffffff" + }; + const dto : StyleDTO = { + background, + textColor + }; + expect( StyleEntity.isDTO(dto) ).toStrictEqual(true); + }); + + it('can test invalid DTOs', () => { + expect( StyleEntity.isDTO(false) ).toStrictEqual(false); + expect( StyleEntity.isDTO(true) ).toStrictEqual(false); + expect( StyleEntity.isDTO([]) ).toStrictEqual(false); + expect( StyleEntity.isDTO(null) ).toStrictEqual(false); + expect( StyleEntity.isDTO(123) ).toStrictEqual(false); + expect( StyleEntity.isDTO(-100) ).toStrictEqual(false); + expect( StyleEntity.isDTO(0) ).toStrictEqual(false); + expect( StyleEntity.isDTO("hello world") ).toStrictEqual(false); + expect( StyleEntity.isDTO("") ).toStrictEqual(false); + expect( StyleEntity.isDTO({ + foobar: true, + }) ).toStrictEqual(false); + expect( StyleEntity.isDTO({ + color: ColorEntity.create(), + }) ).toStrictEqual(false); + }); + + }); + + describe('#merge', () => { + it('can merge multiple styles', () => { + + const styles = StyleEntity.merge( + StyleEntity.create().setWidth(100), + StyleEntity.create().setHeight(200), + ); + + expect(styles.getWidthDTO()?.value).toBe(100); + expect(styles.getWidthDTO()?.unit).toBe(UnitType.PX); + expect(styles.getHeightDTO()?.value).toBe(200); + expect(styles.getHeightDTO()?.unit).toBe(UnitType.PX); + + }); + }); + + }); + + describe('Standard methods', () => { + + describe('.getDTO', () => { + it('can get DTO', () => { + let obj = StyleEntity.create().setWidth(100); + expect(obj.getDTO()).toEqual({ + width: { + value: 100, + unit: UnitType.PX + } + }); + }); + }); + + describe('.valueOf', () => { + it('can get value', () => { + let obj = StyleEntity.create().setWidth(100); + expect(obj.valueOf()).toEqual({ + width: { + value: 100, + unit: UnitType.PX + } + }); + }); + }); + + describe('.toJSON', () => { + it('can get value', () => { + let obj = StyleEntity.create().setWidth(100); + expect(obj.toJSON()).toEqual({ + width: { + value: 100, + unit: UnitType.PX + } + }); + }); + }); + + }); + + describe('Text color methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + textColor: whiteColor + }); + }); + + describe('.getTextColor', () => { + it('can get text color', () => { + expect(entity.getTextColor()?.getDTO()).toStrictEqual(whiteColor); + }); + }); + + describe('.getTextColorDTO', () => { + it('can get text color', () => { + expect(entity.getTextColorDTO()).toStrictEqual(whiteColor); + }); + }); + + describe('.setTextColor', () => { + it('can set text color', () => { + entity.setTextColor(blackColor); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + textColor: blackColor + }) + ); + }); + }); + + }); + + describe('Text align methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + textAlign: TextAlign.CENTER + }); + }); + + describe('.getTextAlign', () => { + it('can get text align', () => { + expect(entity.getTextAlign()).toStrictEqual(TextAlign.CENTER); + }); + }); + + describe('.setTextAlign', () => { + it('can set text align', () => { + entity.setTextAlign(TextAlign.RIGHT); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + textAlign: TextAlign.RIGHT + }) + ); + }); + }); + + }); + + describe('Box sizing methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + boxSizing: BoxSizing.BORDER_BOX + }); + }); + + describe('.getBoxSizing', () => { + it('can get box sizing', () => { + expect(entity.getBoxSizing()).toStrictEqual(BoxSizing.BORDER_BOX); + }); + }); + + describe('.setBoxSizing', () => { + it('can set box sizing', () => { + entity.setBoxSizing(BoxSizing.CONTENT_BOX); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + boxSizing: BoxSizing.CONTENT_BOX + }) + ); + }); + }); + + }); + + describe('Background color methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + background: { + color: whiteColor + } + }); + }); + + describe('.getBackgroundColor', () => { + it('can get background color', () => { + expect(entity.getBackgroundColor()?.getDTO()).toStrictEqual(whiteColor); + }); + }); + + describe('.getBackgroundColorDTO', () => { + it('can get background color', () => { + expect(entity.getBackgroundColorDTO()).toStrictEqual(whiteColor); + }); + }); + + describe('.setBackgroundColor', () => { + it('can set background color', () => { + entity.setBackgroundColor(blackColor); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + background: expect.objectContaining({ + color: blackColor + }) + }) + ); + }); + }); + + }); + + describe('CSS methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + background: { + color: whiteColor + } + }); + }); + + describe('.getCssStyles', () => { + it('can get css values', () => { + expect(entity.getCssStyles()).toEqual({ + backgroundColor: '#fff' + }); + }); + }); + + }); + + describe('Margin methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + margin: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getMargin', () => { + + it('can set margin by number', () => { + expect(entity.getMargin()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.getTopMargin', () => { + it('can get top margin by number', () => { + expect(entity.getTopMargin()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getBottomMargin', () => { + it('can get bottom margin by number', () => { + expect(entity.getBottomMargin()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getRightMargin', () => { + it('can get right margin by number', () => { + expect(entity.getRightMargin()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getLeftMargin', () => { + it('can get left margin by number', () => { + expect(entity.getLeftMargin()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.setMargin', () => { + it('can set margin by number', () => { + entity.setMargin(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + margin: { + value: 100, + unit: UnitType.PX, + } + }) + ); + }); + }); + + describe('.setTopMargin', () => { + it('can set top margin by number', () => { + entity.setTopMargin(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + margin: expect.objectContaining({ + top: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }), + right: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + bottom: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + left: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + }) + }) + ); + }); + }); + + describe('.setRightMargin', () => { + it('can set top margin by number', () => { + entity.setRightMargin(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + margin: expect.objectContaining({ + top: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + right: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }), + bottom: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + left: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + }) + }) + ); + }); + }); + + describe('.setBottomMargin', () => { + it('can set top margin by number', () => { + entity.setBottomMargin(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + margin: expect.objectContaining({ + top: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + right: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + bottom: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }), + left: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + }) + }) + ); + }); + }); + + describe('.setLeftMargin', () => { + it('can set top margin by number', () => { + entity.setLeftMargin(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + margin: expect.objectContaining({ + top: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + right: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + bottom: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + left: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }), + }) + }) + ); + }); + }); + + + }); + + describe('Padding methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + padding: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getPadding', () => { + + it('can set padding by number', () => { + expect(entity.getPadding()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.getTopPadding', () => { + it('can get top padding by number', () => { + expect(entity.getTopPadding()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getBottomPadding', () => { + it('can get bottom padding by number', () => { + expect(entity.getBottomPadding()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getRightPadding', () => { + it('can get right padding by number', () => { + expect(entity.getRightPadding()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getLeftPadding', () => { + it('can get left padding by number', () => { + expect(entity.getLeftPadding()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.setPadding', () => { + it('can set padding by number', () => { + entity.setPadding(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + padding: { + value: 100, + unit: UnitType.PX, + } + }) + ); + }); + }); + + describe('.setTopPadding', () => { + it('can set top padding by number', () => { + entity.setTopPadding(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + padding: expect.objectContaining({ + top: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }), + right: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + bottom: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + left: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + }) + }) + ); + }); + }); + + describe('.setRightPadding', () => { + it('can set top padding by number', () => { + entity.setRightPadding(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + padding: expect.objectContaining({ + top: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + right: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }), + bottom: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + left: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + }) + }) + ); + }); + }); + + describe('.setBottomPadding', () => { + it('can set top padding by number', () => { + entity.setBottomPadding(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + padding: expect.objectContaining({ + top: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + right: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + bottom: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }), + left: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + }) + }) + ); + }); + }); + + describe('.setLeftPadding', () => { + it('can set top padding by number', () => { + entity.setLeftPadding(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + padding: expect.objectContaining({ + top: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + right: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + bottom: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }), + left: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }), + }) + }) + ); + }); + }); + + + }); + + describe('Border methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + border: { + width: { + value: 10, + unit: UnitType.PX, + } + } + }); + }); + + describe('.getBorder', () => { + it('can get border width', () => { + expect(entity.getBorder()?.getDTO()).toEqual( + expect.objectContaining( { + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }) + ); + }); + }); + + describe('.getBorderDTO', () => { + it('can get border width', () => { + expect(entity.getBorderDTO()).toEqual( + expect.objectContaining( { + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }) + ); + }); + }); + + describe('.getTopBorder', () => { + it('can get top border width', () => { + expect(entity.getTopBorder()?.getWidth()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getBottomBorder', () => { + it('can get bottom border width', () => { + expect(entity.getBottomBorder()?.getWidth()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getRightBorder', () => { + it('can get right border width', () => { + expect(entity.getRightBorder()?.getWidth()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.getLeftBorder', () => { + it('can get left border width', () => { + expect(entity.getLeftBorder()?.getWidth()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + }); + + describe('.setBorder', () => { + it('can set border width', () => { + entity.setBorder(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + border: expect.objectContaining({ + width: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }) + }) + }) + ); + }); + }); + + describe('.setTopBorder', () => { + it('can set top border width', () => { + entity.setTopBorder(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + border: expect.objectContaining({ + top: expect.objectContaining({ + width: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }) + }), + right: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + bottom: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + left: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + }) + }) + ); + }); + }); + + describe('.setRightBorder', () => { + it('can set top border width', () => { + entity.setRightBorder(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + border: expect.objectContaining({ + top: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + right: expect.objectContaining({ + width: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }) + }), + bottom: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + left: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + }) + }) + ); + }); + }); + + describe('.setBottomBorder', () => { + it('can set top border width', () => { + entity.setBottomBorder(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + border: expect.objectContaining({ + top: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + right: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + bottom: expect.objectContaining({ + width: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }) + }), + left: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + }) + }) + ); + }); + }); + + describe('.setLeftBorder', () => { + it('can set top border width', () => { + entity.setLeftBorder(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + border: expect.objectContaining({ + top: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + right: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + bottom: expect.objectContaining({ + width: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }), + left: expect.objectContaining({ + width: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }) + }), + }) + }) + ); + }); + }); + + }); + + describe('Font methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + font: { + size: { + value: 10, + unit: UnitType.PX, + } + } + }); + }); + + describe('.getFont', () => { + it('can get font size by number', () => { + expect(entity.getFont()?.getDTO()).toEqual( + expect.objectContaining({ + size: expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + }) + ) + }); + }); + + describe('.setFont', () => { + it('can set font size by number', () => { + entity.setFont(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + font: expect.objectContaining({ + size: expect.objectContaining({ + value: 100, + unit: UnitType.PX, + }) + }) + }) + ); + }); + }); + + }); + + describe('TextDecoration methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + textDecoration: { + style: TextDecorationStyle.DASHED + } + }); + }); + + describe('.getTextDecoration', () => { + it('can get text decoration style', () => { + expect(entity.getTextDecoration()?.getDTO()).toEqual( + expect.objectContaining({ + style: TextDecorationStyle.DASHED + }) + ) + }); + }); + + describe('.getTextDecorationDTO', () => { + it('can get text decoration style', () => { + expect(entity.getTextDecorationDTO()).toEqual( + expect.objectContaining({ + style: TextDecorationStyle.DASHED + }) + ) + }); + }); + + describe('.setTextDecoration', () => { + it('can set text decoration style', () => { + entity.setTextDecoration({ + style: TextDecorationStyle.DOUBLE + }); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + textDecoration: expect.objectContaining({ + style: TextDecorationStyle.DOUBLE + }) + }) + ); + }); + }); + + }); + + describe('Width methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + width: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getWidth', () => { + + it('can get width entity', () => { + expect(entity.getWidth()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.getWidthDTO', () => { + + it('can get width DTO', () => { + expect(entity.getWidthDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.setWidth', () => { + + it('can set width by number', () => { + + entity.setWidth(100); + + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + width: { + value: 100, + unit: UnitType.PX, + } + }) + ); + }); + + }); + + }); + + describe('Height methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + height: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getHeight', () => { + + it('can get height entity', () => { + expect(entity.getHeight()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.getHeightDTO', () => { + + it('can get height dto', () => { + expect(entity.getHeightDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.setHeight', () => { + it('can set height by number', () => { + entity.setHeight(100); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + height: { + value: 100, + unit: UnitType.PX, + } + }) + ); + }); + }); + + }); + + describe('Min width methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + minWidth: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getMinWidth', () => { + + it('can get minWidth entity', () => { + expect(entity.getMinWidth()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.getMinWidthDTO', () => { + + it('can get minWidth DTO', () => { + expect(entity.getMinWidthDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.setMinWidth', () => { + + it('can set minWidth by number', () => { + + entity.setMinWidth(100); + + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + minWidth: { + value: 100, + unit: UnitType.PX, + } + }) + ); + }); + + }); + + }); + + describe('Min height methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + minHeight: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getMinHeight', () => { + + it('can get minHeight entity', () => { + expect(entity.getMinHeight()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.getMinHeightDTO', () => { + + it('can get minHeight DTO', () => { + expect(entity.getMinHeightDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.setMinHeight', () => { + + it('can set minHeight by number', () => { + + entity.setMinHeight(100); + + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + minHeight: { + value: 100, + unit: UnitType.PX, + } + }) + ); + }); + + }); + + }); + + describe('Max width methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + maxWidth: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getMaxWidth', () => { + + it('can get maxWidth entity', () => { + expect(entity.getMaxWidth()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.getMaxWidthDTO', () => { + + it('can get maxWidth DTO', () => { + expect(entity.getMaxWidthDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.setMaxWidth', () => { + + it('can set maxWidth by number', () => { + + entity.setMaxWidth(100); + + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + maxWidth: { + value: 100, + unit: UnitType.PX, + } + }) + ); + }); + + }); + + }); + + describe('Max height methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + maxHeight: { + value: 10, + unit: UnitType.PX, + } + }); + }); + + describe('.getMaxHeight', () => { + + it('can get maxHeight entity', () => { + expect(entity.getMaxHeight()?.getDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.getMaxHeightDTO', () => { + + it('can get maxHeight DTO', () => { + expect(entity.getMaxHeightDTO()).toEqual( + expect.objectContaining({ + value: 10, + unit: UnitType.PX, + }) + ) + }); + + }); + + describe('.setMaxHeight', () => { + + it('can set maxHeight by number', () => { + + entity.setMaxHeight(100); + + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + maxHeight: { + value: 100, + unit: UnitType.PX, + } + }) + ); + }); + + }); + + }); + + describe('Background methods', () => { + + let entity : StyleEntity; + + beforeEach(() => { + entity = StyleEntity.create({ + background: { + color: blackColor, + } + }); + }); + + describe('.getBackground', () => { + it('can get background entity', () => { + expect(entity.getBackground()?.getDTO()).toEqual( + expect.objectContaining({ + color: blackColor, + }) + ) + }); + }); + + describe('.getBackgroundDTO', () => { + + it('can get background dto', () => { + expect(entity.getBackgroundDTO()).toEqual( + expect.objectContaining({ + color: blackColor, + }) + ) + }); + + }); + + describe('.setBackground', () => { + it('can set background by DTO', () => { + const backgroundDTO : BackgroundDTO = { + color: whiteColor, + }; + entity.setBackground(backgroundDTO); + expect(entity.getDTO()).toEqual( + expect.objectContaining({ + background: expect.objectContaining({ + color: whiteColor, + }) + }) + ); + }); + }); + + }); + +}); diff --git a/entities/style/StyleEntity.ts b/entities/style/StyleEntity.ts new file mode 100644 index 0000000..d2c3650 --- /dev/null +++ b/entities/style/StyleEntity.ts @@ -0,0 +1,793 @@ +// Copyright (c) 2023-2024. Heusala Group Oy . All rights reserved. + +import { BackgroundDTO } from "../background/BackgroundDTO"; +import { + BorderDTO, +} from "../border/BorderDTO"; +import { BorderBox } from "../borderBox/BorderBox"; +import { + ColorDTO, +} from "../color/ColorDTO"; +import { + FontDTO, +} from "../font/FontDTO"; +import { + SizeDTO, +} from "../size/SizeDTO"; +import { SizeBox } from "../sizeBox/SizeBox"; +import { VariableType } from "../types/VariableType"; +import { + StyleDTO, +} from "./StyleDTO"; +import { BorderStyle } from "../types/BorderStyle"; +import { BoxSizing } from "../types/BoxSizing"; +import { TextAlign } from "../types/TextAlign"; +import { map } from "../../functions/map"; +import { reduce } from "../../functions/reduce"; +import { ReadonlyJsonObject } from "../../Json"; +import { isArray } from "../../types/Array"; +import { isNumber } from "../../types/Number"; +import { isString } from "../../types/String"; +import { + BackgroundEntity, + isBackgroundEntity, +} from "../background/BackgroundEntity"; +import { + BorderBoxEntity, + isBorderBox, + isBorderBoxEntity, +} from "../borderBox/BorderBoxEntity"; +import { + BorderEntity, + isBorderDTO, + isBorderEntity, +} from "../border/BorderEntity"; +import { + ColorEntity, +} from "../color/ColorEntity"; +import { + FontEntity, + isFontEntity, +} from "../font/FontEntity"; +import { + isSizeBox, + isSizeBoxEntity, + SizeBoxEntity, +} from "../sizeBox/SizeBoxEntity"; +import { + isSizeDTO, + isSizeEntity, + SizeEntity, +} from "../size/SizeEntity"; +import { + TextDecorationEntity, +} from "../textDecoration/TextDecorationEntity"; +import { + isBackground, +} from "../background/Background"; +import { + Border, + isBorder, +} from "../border/Border"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { + Font, + isFont, +} from "../font/Font"; +import { + isSize, + Size, +} from "../size/Size"; +import { + Style, +} from "./Style"; +import { UnitType } from "../types/UnitType"; + +const TOP_AND_BOTTOM_MARGIN_INDEX = 0; +const LEFT_AND_RIGHT_MARGIN_INDEX = 1; +const TOP_MARGIN_INDEX = 0; +const RIGHT_MARGIN_INDEX = 1; +const BOTTOM_MARGIN_INDEX = 2; +const LEFT_MARGIN_INDEX = 3; + +export const StyleEntityFactory = ( + EntityFactoryImpl.create('Style') + .add( EntityFactoryImpl.createProperty("textAlign").setTypes(TextAlign, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("textColor").setTypes(ColorEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("width").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("height").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("margin").setTypes(SizeEntity, SizeBoxEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("padding").setTypes(SizeEntity, SizeBoxEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("border").setTypes(BorderEntity, BorderBoxEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("font").setTypes(FontEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("textDecoration").setTypes(TextDecorationEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("background").setTypes(BackgroundEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("minWidth").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("minHeight").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("maxWidth").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("maxHeight").setTypes(SizeEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("boxSizing").setTypes(BoxSizing, VariableType.UNDEFINED) ) +); + +export const isStyleDTO = StyleEntityFactory.createTestFunctionOfDTO(); + +export const isStyle = StyleEntityFactory.createTestFunctionOfInterface(); + +export const explainStyleDTO = StyleEntityFactory.createExplainFunctionOfDTO(); + +export const isStyleDTOOrUndefined = StyleEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainStyleDTOOrUndefined = StyleEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const BaseStyleEntity = StyleEntityFactory.createEntityType(); + + + +/** + * Style entity. + */ +export class StyleEntity + extends BaseStyleEntity + implements Style +{ + + public static create ( + dto ?: StyleDTO, + ) : StyleEntity { + return new this(dto); + } + + /** + * Construct a style entity. + * + * @param style + */ + public static createFromDTO ( + style : StyleDTO, + ) : StyleEntity { + return new this( style ); + } + + public static prepareBackgroundDTO ( + value : BackgroundEntity | BackgroundDTO | undefined + ) : BackgroundDTO | undefined { + if (value === undefined) return undefined; + if (isBackgroundEntity(value)) return value.getDTO(); + if (isBackground(value)) return value.getDTO(); + return value; + } + + public static prepareSizeDTO ( + value : SizeEntity | Size | SizeDTO | number | undefined + ) : SizeDTO | undefined { + if (value === undefined) return undefined; + if (isNumber(value)) return {value, unit: UnitType.PX}; + if (isSizeEntity(value)) return value.getDTO(); + if (isSize(value)) return value.getDTO(); + return value; + } + + public static prepareFontDTO ( + value : Font | FontEntity | FontDTO | number | string | undefined + ) : FontDTO | undefined { + if (value === undefined) return undefined; + if (isNumber(value)) { + return FontEntity.create().setFontSize( SizeEntity.create(value).getDTO() ).getDTO(); + } + if (isString(value)) { + return FontEntity.create().setFontFamily( value ).getDTO(); + } + if (isFontEntity(value)) return value.getDTO(); + if (isFont(value)) return value.getDTO(); + return value; + } + + public static prepareBorderDTO ( + value : Border | BorderDTO | number | undefined + ) : BorderDTO | undefined { + if (value === undefined) return undefined; + if (isNumber(value)) { + return BorderEntity.create().setWidth(value).getDTO(); + } + if (isBorderEntity(value)) return value.getDTO(); + if (isBorder(value)) return value.getDTO(); + return value; + } + + public static prepareSizeListDTO ( + value : ( + SizeEntity + | [ + SizeEntity | SizeDTO | number | undefined, + SizeEntity | SizeDTO | number | undefined, + ] + | [ + SizeEntity | SizeDTO | number | undefined, + SizeEntity | SizeDTO | number | undefined, + SizeEntity | SizeDTO | number | undefined, + SizeEntity | SizeDTO | number | undefined, + ] + | SizeDTO + | number + | undefined + ) + ) : SizeDTO | [SizeDTO, SizeDTO, SizeDTO, SizeDTO] | undefined { + if (value === undefined) return undefined; + if (isNumber(value)) return {value, unit: UnitType.PX}; + if (isSizeEntity(value)) return value.getDTO(); + if (isArray(value)) { + + if (value.length === 2) { + const top_and_bottom : SizeDTO | undefined = StyleEntity.prepareSizeDTO(value[TOP_AND_BOTTOM_MARGIN_INDEX]); + if (!top_and_bottom) throw new TypeError(`prepareSizeListDTO: Invalid [undefined, *] array provided`); + const right_and_left: SizeDTO | undefined = StyleEntity.prepareSizeDTO(value[LEFT_AND_RIGHT_MARGIN_INDEX]); + if (!right_and_left) throw new TypeError(`prepareSizeListDTO: Invalid [SizeDTO, undefined] array provided`); + return [ + top_and_bottom, // top + right_and_left, // right + top_and_bottom, // bottom + right_and_left, // left + ]; + } + + if (value.length === 4) { + const top : SizeDTO | undefined = StyleEntity.prepareSizeDTO( value[TOP_MARGIN_INDEX] ); + if (!top) throw new TypeError(`prepareSizeListDTO: Invalid [undefined, *, *, *] array provided`); + const right : SizeDTO | undefined = StyleEntity.prepareSizeDTO( value[RIGHT_MARGIN_INDEX] ); + if (!right) throw new TypeError(`prepareSizeListDTO: Invalid [SizeDTO, undefined, *, *] array provided`); + const bottom : SizeDTO | undefined = StyleEntity.prepareSizeDTO( value[BOTTOM_MARGIN_INDEX] ); + if (!bottom) throw new TypeError(`prepareSizeListDTO: Invalid [SizeDTO, SizeDTO, undefined, *] array provided`); + const left : SizeDTO | undefined = StyleEntity.prepareSizeDTO( value[LEFT_MARGIN_INDEX] ); + if (!left) throw new TypeError(`prepareSizeListDTO: Invalid [SizeDTO, SizeDTO, SizeDTO, undefined] array provided`); + return [ + top, + right, + bottom, + left, + ]; + } + + // Runtime assert, should not happen. + // @ts-ignore + throw new TypeError(`prepareSizeListDTO: Incorrect array length: ${value.length}`); + + } + return value; + } + + public static prepareBorderListDTO ( + value : ( + Border + | [ + Border | BorderDTO | number | undefined, + Border | BorderDTO | number | undefined, + ] + | [ + Border | BorderDTO | number | undefined, + Border | BorderDTO | number | undefined, + Border | BorderDTO | number | undefined, + Border | BorderDTO | number | undefined, + ] + | BorderDTO + | number + | undefined + ) + ) : BorderDTO | [BorderDTO, BorderDTO, BorderDTO, BorderDTO] | undefined { + if (value === undefined) return undefined; + if (isNumber(value)) { + return BorderEntity.create().setWidth( SizeEntity.create(value) ).getDTO(); + } + if (isBorderEntity(value)) return value.getDTO(); + if (isBorder(value)) return value.getDTO(); + if (isArray(value)) { + + if (value.length === 2) { + const top_and_bottom : BorderDTO | undefined = StyleEntity.prepareBorderDTO(value[TOP_AND_BOTTOM_MARGIN_INDEX]); + if (!top_and_bottom) throw new TypeError(`prepareBorderListDTO: Invalid [undefined, *] array provided`); + const right_and_left: BorderDTO | undefined = StyleEntity.prepareBorderDTO(value[LEFT_AND_RIGHT_MARGIN_INDEX]); + if (!right_and_left) throw new TypeError(`prepareBorderListDTO: Invalid [BorderDTO, undefined] array provided`); + return [ + top_and_bottom, // top + right_and_left, // right + top_and_bottom, // bottom + right_and_left, // left + ]; + } + + if (value.length === 4) { + const top : BorderDTO | undefined = StyleEntity.prepareBorderDTO( value[0] ); + if (!top) throw new TypeError(`prepareBorderListDTO: Invalid [undefined, *, *, *] array provided`); + const right : BorderDTO | undefined = StyleEntity.prepareBorderDTO( value[1] ); + if (!right) throw new TypeError(`prepareBorderListDTO: Invalid [BorderDTO, undefined, *, *] array provided`); + const bottom : BorderDTO | undefined = StyleEntity.prepareBorderDTO( value[2] ); + if (!bottom) throw new TypeError(`prepareBorderListDTO: Invalid [BorderDTO, BorderDTO, undefined, *] array provided`); + const left : BorderDTO | undefined = StyleEntity.prepareBorderDTO( value[3] ); + if (!left) throw new TypeError(`prepareBorderListDTO: Invalid [BorderDTO, BorderDTO, BorderDTO, undefined] array provided`); + return [ + top, + right, + bottom, + left, + ]; + } + + // Runtime assert, should not happen. + // @ts-ignore + throw new TypeError(`prepareBorderListDTO: Incorrect array length: ${value.length}`); + + } + return value; + } + + public static prepareSizeListCssStyles ( + key : string, + value : SizeDTO | [SizeDTO, SizeDTO, SizeDTO, SizeDTO] | undefined + ) : ReadonlyJsonObject { + + if (isSizeDTO(value)) { + return { + [key]: SizeEntity.createFromDTO(value).getCssStyles() + }; + } + + if (isArray(value)) { + return { + [key]: map( + value, + (item: SizeDTO) : string => SizeEntity.createFromDTO(item).getCssStyles() + ).join(' ') + }; + } + + return {}; + + } + + public static prepareBorderListCssStyles ( + value : BorderDTO | [BorderDTO, BorderDTO, BorderDTO, BorderDTO] | undefined + ) : ReadonlyJsonObject { + + if (isBorderDTO(value)) { + return { + border: BorderEntity.create(value).getCssStyles() + }; + } + + if (isArray(value)) { + return { + borderStyle: map( + value, + (item: BorderDTO) : string => (BorderEntity.createFromDTO(item).getStyle() ?? BorderStyle.NONE), + ).join(' '), + borderWidth: map( + value, + (item: BorderDTO) : string => (BorderEntity.createFromDTO(item).getWidth() ?? SizeEntity.createZero()).getCssStyles(), + ).join(' '), + borderColor: map( + value, + (item: BorderDTO) : string => (BorderEntity.createFromDTO(item).getColor() ?? ColorEntity.createTransparent()).getCssStyles(), + ).join(' '), + }; + } + + return {}; + + } + + /** + * + * @param style + */ + public static getCssStyles ( + style: Style, + ) : ReadonlyJsonObject { + return style.getCssStyles(); + } + + public static merge ( + ...values: readonly (StyleDTO | Style | StyleEntity)[] + ) : StyleEntity { + return StyleEntity.createFromDTO( + reduce( + values, + ( + prev: StyleDTO, + item: StyleDTO | Style | StyleEntity, + ) : StyleDTO => { + const dto : StyleDTO = this.toDTO(item); + return { + ...prev, + ...dto, + }; + }, + {}, + ) + ); + } + + public static toDTO ( + value: StyleDTO | Style | StyleEntity, + ) : StyleDTO { + if (isStyleEntity(value)) { + return value.getDTO(); + } else if (isStyle(value)) { + return value.getDTO(); + } else { + return value; + } + } + + + + /** + * Construct a style entity. + */ + public constructor ( + style ?: StyleDTO | StyleEntity | Style, + ) { + super( + isStyleEntity(style) || isStyle(style) ? style.getDTO() : style + ); + } + + public getCssStyles () : ReadonlyJsonObject { + + const textColor = this.getTextColor(); + const textAlign = this.getTextAlign(); + const boxSizing = this.getBoxSizing(); + const background = this.getBackground(); + const width = this.getWidth(); + const height = this.getHeight(); + const minWidth = this.getMinWidth(); + const minHeight = this.getMinHeight(); + const maxWidth = this.getMaxWidth(); + const maxHeight = this.getMaxHeight(); + const margin = this.getMargin(); + const padding = this.getPadding(); + const border = this.getBorder(); + const font = this.getFont(); + const textDecoration = this.getTextDecoration(); + + return { + ...(textColor ? { color: textColor.getCssStyles() } : {}), + ...(textAlign ? { textAlign: textAlign } : {}), + ...(boxSizing ? { boxSizing: boxSizing } : {}), + ...(background ? background.getCssStyles() : {}), + ...(width ? { width: width.getCssStyles() } : {}), + ...(height ? { height: height.getCssStyles() } : {}), + ...(minWidth ? { minWidth: minWidth.getCssStyles() } : {}), + ...(minHeight ? { minHeight: minHeight.getCssStyles() } : {}), + ...(maxWidth ? { maxWidth: maxWidth.getCssStyles() } : {}), + ...(maxHeight ? { maxHeight: maxHeight.getCssStyles() } : {}), + ...(margin? { margin: margin.getCssStyles() } : {}), + ...(padding ? { padding : padding.getCssStyles() } : {}), + ...(border ? border.getCssStyles() : {}), + ...(font ? font.getCssStyles() : {}), + ...(textDecoration ? textDecoration.getCssStyles() : {}), + }; + } + + public getBackgroundColor () : ColorEntity | undefined { + const bg = this.getBackground(); + return bg ? bg.getColor() : undefined; + } + + public getBackgroundColorDTO () : ColorDTO | undefined { + const bg = this.getBackground(); + return bg ? bg.getColor()?.getDTO() : undefined; + } + + public setBackgroundColor ( value : ColorEntity | ColorDTO | string | undefined ) : this { + const color = value ? ColorEntity.toDTO(value) : undefined; + const bg = (this.getBackground() ?? BackgroundEntity.create()).setColor(color); + return this.setBackground( bg ); + } + + public getTopMargin () : Size | undefined { + const margin = this.getMargin(); + if (isSizeBox(margin)) { + return margin.getTop(); + } + return margin; + } + + public getBottomMargin () : Size | undefined { + const margin = this.getMargin(); + if (isSizeBox(margin)) { + return margin.getBottom(); + } + return margin; + } + + public getLeftMargin () : Size | undefined { + const margin = this.getMargin(); + if (isSizeBox(margin)) { + return margin.getLeft(); + } + return margin; + } + + public getRightMargin () : Size | undefined { + const margin = this.getMargin(); + if (isSizeBox(margin)) { + return margin.getRight(); + } + return margin; + } + + public setTopMargin ( value : SizeEntity | Size | number | undefined ) : this { + const dto : SizeDTO | undefined = SizeEntity.toDTO(value); + const current : Size | SizeBox | undefined = this.getMargin(); + if (isSizeBoxEntity(current)) { + return this.setMargin( dto ? current.setTop(dto) : current.setTop(undefined) ); + } + if (isSizeEntity(current)) { + const currentDTO : SizeDTO = current.getDTO(); + return this.setMargin( + dto + ? SizeBoxEntity.create().setRight(currentDTO).setBottom(currentDTO).setLeft(currentDTO).setTop(dto) + : SizeBoxEntity.create().setRight(currentDTO).setBottom(currentDTO).setLeft(currentDTO) + ); + } + return this.setMargin( dto ? SizeBoxEntity.create().setTop(dto) : SizeBoxEntity.create() ); + } + + public setRightMargin ( value : SizeEntity | Size | number | undefined ) : this { + const dto : SizeDTO | undefined = SizeEntity.toDTO(value); + const current : Size | SizeBox | undefined = this.getMargin(); + if (isSizeBoxEntity(current)) { + return this.setMargin( dto ? current.setRight(dto) : current.setRight(undefined) ); + } + if (isSizeEntity(current)) { + const currentDTO : SizeDTO = current.getDTO(); + return this.setMargin( + dto + ? SizeBoxEntity.create().setTop(currentDTO).setBottom(currentDTO).setLeft(currentDTO).setRight(dto) + : SizeBoxEntity.create().setTop(currentDTO).setBottom(currentDTO).setLeft(currentDTO) + ); + } + return this.setMargin( dto ? SizeBoxEntity.create().setRight(dto) : SizeBoxEntity.create() ); + } + + public setBottomMargin ( value : SizeEntity | Size | number | undefined ) : this { + const dto : SizeDTO | undefined = SizeEntity.toDTO(value); + const current : Size | SizeBox | undefined = this.getMargin(); + if (isSizeBoxEntity(current)) { + return this.setMargin( dto ? current.setBottom(dto) : current.setBottom(undefined) ); + } + if (isSizeEntity(current)) { + const currentDTO : SizeDTO = current.getDTO(); + return this.setMargin( + dto + ? SizeBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setLeft(currentDTO).setBottom(dto) + : SizeBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setLeft(currentDTO) + ); + } + return this.setMargin( dto ? SizeBoxEntity.create().setBottom(dto) : SizeBoxEntity.create() ); + } + + public setLeftMargin ( value : SizeEntity | Size | number | undefined ) : this { + const dto : SizeDTO | undefined = SizeEntity.toDTO(value); + const current : Size | SizeBox | undefined = this.getMargin(); + if (isSizeBoxEntity(current)) { + return this.setMargin( dto ? current.setLeft(dto) : current.setLeft(undefined) ); + } + if (isSizeEntity(current)) { + const currentDTO : SizeDTO = current.getDTO(); + return this.setMargin( + dto + ? SizeBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setBottom(currentDTO).setLeft(dto) + : SizeBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setBottom(currentDTO) + ); + } + return this.setMargin( dto ? SizeBoxEntity.create().setLeft(dto) : SizeBoxEntity.create() ); + } + + + public getTopPadding () : Size | undefined { + const padding = this.getPadding(); + if (isSizeBox(padding)) { + return padding.getTop(); + } + return padding; + } + + public getBottomPadding () : Size | undefined { + const padding = this.getPadding(); + if (isSizeBox(padding)) { + return padding.getBottom(); + } + return padding; + } + + public getLeftPadding () : Size | undefined { + const padding = this.getPadding(); + if (isSizeBox(padding)) { + return padding.getLeft(); + } + return padding; + } + + public getRightPadding () : Size | undefined { + const padding = this.getPadding(); + if (isSizeBox(padding)) { + return padding.getRight(); + } + return padding; + } + + public setTopPadding ( value : SizeEntity | Size | number | undefined ) : this { + const dto : SizeDTO | undefined = SizeEntity.toDTO(value); + const current : Size | SizeBox | undefined = this.getPadding(); + if (isSizeBoxEntity(current)) { + return this.setPadding( dto ? current.setTop(dto) : current.setTop(undefined) ); + } + if (isSizeEntity(current)) { + const currentDTO : SizeDTO = current.getDTO(); + return this.setPadding( + dto + ? SizeBoxEntity.create().setRight(currentDTO).setBottom(currentDTO).setLeft(currentDTO).setTop(dto) + : SizeBoxEntity.create().setRight(currentDTO).setBottom(currentDTO).setLeft(currentDTO) + ); + } + return this.setPadding( dto ? SizeBoxEntity.create().setTop(dto) : SizeBoxEntity.create() ); + } + + public setRightPadding ( value : SizeEntity | Size | number | undefined ) : this { + const dto : SizeDTO | undefined = SizeEntity.toDTO(value); + const current : Size | SizeBox | undefined = this.getPadding(); + if (isSizeBoxEntity(current)) { + return this.setPadding( dto ? current.setRight(dto) : current.setRight(undefined) ); + } + if (isSizeEntity(current)) { + const currentDTO : SizeDTO = current.getDTO(); + return this.setPadding( + dto + ? SizeBoxEntity.create().setTop(currentDTO).setBottom(currentDTO).setLeft(currentDTO).setRight(dto) + : SizeBoxEntity.create().setTop(currentDTO).setBottom(currentDTO).setLeft(currentDTO) + ); + } + return this.setPadding( dto ? SizeBoxEntity.create().setRight(dto) : SizeBoxEntity.create() ); + } + + public setBottomPadding ( value : SizeEntity | Size | number | undefined ) : this { + const dto : SizeDTO | undefined = SizeEntity.toDTO(value); + const current : Size | SizeBox | undefined = this.getPadding(); + if (isSizeBoxEntity(current)) { + return this.setPadding( dto ? current.setBottom(dto) : current.setBottom(undefined) ); + } + if (isSizeEntity(current)) { + const currentDTO : SizeDTO = current.getDTO(); + return this.setPadding( + dto + ? SizeBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setLeft(currentDTO).setBottom(dto) + : SizeBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setLeft(currentDTO) + ); + } + return this.setPadding( dto ? SizeBoxEntity.create().setBottom(dto) : SizeBoxEntity.create() ); + } + + public setLeftPadding ( value : SizeEntity | Size | number | undefined ) : this { + const dto : SizeDTO | undefined = SizeEntity.toDTO(value); + const current : Size | SizeBox | undefined = this.getPadding(); + if (isSizeBoxEntity(current)) { + return this.setPadding( dto ? current.setLeft(dto) : current.setLeft(undefined) ); + } + if (isSizeEntity(current)) { + const currentDTO : SizeDTO = current.getDTO(); + return this.setPadding( + dto + ? SizeBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setBottom(currentDTO).setLeft(dto) + : SizeBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setBottom(currentDTO) + ); + } + return this.setPadding( dto ? SizeBoxEntity.create().setLeft(dto) : SizeBoxEntity.create() ); + } + + + + public getTopBorder () : Border | undefined { + const border = this.getBorder(); + if (isBorderBox(border)) { + return border.getTop(); + } + return border; + } + + public getBottomBorder () : Border | undefined { + const border = this.getBorder(); + if (isBorderBox(border)) { + return border.getBottom(); + } + return border; + } + + public getLeftBorder () : Border | undefined { + const border = this.getBorder(); + if (isBorderBox(border)) { + return border.getLeft(); + } + return border; + } + + public getRightBorder () : Border | undefined { + const border = this.getBorder(); + if (isBorderBox(border)) { + return border.getRight(); + } + return border; + } + + public setTopBorder ( value : BorderEntity | Border | number | undefined ) : this { + const dto : BorderDTO | undefined = BorderEntity.toDTO(value); + const current : Border | BorderBox | undefined = this.getBorder(); + if (isBorderBoxEntity(current)) { + return this.setBorder( dto ? current.setTop(dto) : current.setTop(undefined) ); + } + if (isBorderEntity(current)) { + const currentDTO : BorderDTO = current.getDTO(); + return this.setBorder( + dto + ? BorderBoxEntity.create().setRight(currentDTO).setBottom(currentDTO).setLeft(currentDTO).setTop(dto) + : BorderBoxEntity.create().setRight(currentDTO).setBottom(currentDTO).setLeft(currentDTO) + ); + } + return this.setBorder( dto ? BorderBoxEntity.create().setTop(dto) : BorderBoxEntity.create() ); + } + + public setRightBorder ( value : BorderEntity | Border | number | undefined ) : this { + const dto : BorderDTO | undefined = BorderEntity.toDTO(value); + const current : Border | BorderBox | undefined = this.getBorder(); + if (isBorderBoxEntity(current)) { + return this.setBorder( dto ? current.setRight(dto) : current.setRight(undefined) ); + } + if (isBorderEntity(current)) { + const currentDTO : BorderDTO = current.getDTO(); + return this.setBorder( + dto + ? BorderBoxEntity.create().setTop(currentDTO).setBottom(currentDTO).setLeft(currentDTO).setRight(dto) + : BorderBoxEntity.create().setTop(currentDTO).setBottom(currentDTO).setLeft(currentDTO) + ); + } + return this.setBorder( dto ? BorderBoxEntity.create().setRight(dto) : BorderBoxEntity.create() ); + } + + public setBottomBorder ( value : BorderEntity | Border | number | undefined ) : this { + const dto : BorderDTO | undefined = BorderEntity.toDTO(value); + const current : Border | BorderBox | undefined = this.getBorder(); + if (isBorderBoxEntity(current)) { + return this.setBorder( dto ? current.setBottom(dto) : current.setBottom(undefined) ); + } + if (isBorderEntity(current)) { + const currentDTO : BorderDTO = current.getDTO(); + return this.setBorder( + dto + ? BorderBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setLeft(currentDTO).setBottom(dto) + : BorderBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setLeft(currentDTO) + ); + } + return this.setBorder( dto ? BorderBoxEntity.create().setBottom(dto) : BorderBoxEntity.create() ); + } + + public setLeftBorder ( value : BorderEntity | Border | number | undefined ) : this { + const dto : BorderDTO | undefined = BorderEntity.toDTO(value); + const current : Border | BorderBox | undefined = this.getBorder(); + if (isBorderBoxEntity(current)) { + return this.setBorder( dto ? current.setLeft(dto) : current.setLeft(undefined) ); + } + if (isBorderEntity(current)) { + const currentDTO : BorderDTO = current.getDTO(); + return this.setBorder( + dto + ? BorderBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setBottom(currentDTO).setLeft(dto) + : BorderBoxEntity.create().setTop(currentDTO).setRight(currentDTO).setBottom(currentDTO) + ); + } + return this.setBorder( dto ? BorderBoxEntity.create().setLeft(dto) : BorderBoxEntity.create() ); + } + +} + +export function isStyleEntity (value: unknown): value is StyleEntity { + return value instanceof StyleEntity; +} diff --git a/entities/textDecoration/TextDecoration.ts b/entities/textDecoration/TextDecoration.ts new file mode 100644 index 0000000..8139c11 --- /dev/null +++ b/entities/textDecoration/TextDecoration.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { ColorDTO } from "../color/ColorDTO"; +import { TextDecorationDTO } from "./TextDecorationDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { ColorEntity } from "../color/ColorEntity"; +import { SizeEntity } from "../size/SizeEntity"; +import { Color } from "../color/Color"; +import { Entity } from "../types/Entity"; +import { Size } from "../size/Size"; +import { TextDecorationLineType } from "../types/TextDecorationLineType"; +import { TextDecorationStyle } from "../types/TextDecorationStyle"; + +/** + * Presents a font value. + */ +export interface TextDecoration + extends Entity +{ + + /** + * @inheritDoc + */ + valueOf () : ReadonlyJsonObject; + + /** + * + */ + getDTO () : TextDecorationDTO; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns CSS styles. + */ + getCssStyles () : ReadonlyJsonObject; + + + /** + * Get a text decoration color. + */ + getLineType () : TextDecorationLineType | undefined; + + /** + * Set a text decoration color. + * + * @param value + */ + setLineType (value : TextDecorationLineType | undefined) : this; + + + /** + * Get a text decoration style. + */ + getStyle () : TextDecorationStyle | undefined; + + /** + * Set a font style. + * + * @param value + */ + setStyle (value : TextDecorationStyle | undefined) : this; + + + /** + * Get a text decoration color. + */ + getColor () : Color | undefined; + + /** + * Get a text decoration color as a DTO. + */ + getColorDTO () : ColorDTO | undefined; + + /** + * Set a text decoration color. + * + * @param value + */ + setColor (value : Color | ColorEntity | ColorDTO | undefined) : this; + + + /** + * Get a text decoration thickness. + */ + getThickness () : Size | undefined; + + /** + * Get a text decoration thickness as a SizeDTO. + */ + getThicknessDTO () : SizeDTO | undefined; + + /** + * Set a text decoration thickness. + * + * @param value + */ + setThickness (value : SizeDTO | Size | SizeEntity | number | undefined) : this; + +} + +export function isTextDecoration (value : unknown) : value is TextDecoration { + return ( + isObject(value) + && isFunction(value?.valueOf) + && isFunction(value?.getDTO) + && isFunction(value?.toJSON) + && isFunction(value?.getCssStyles) + + && isFunction(value?.getLineType) + && isFunction(value?.setLineType) + + && isFunction(value?.getStyle) + && isFunction(value?.setStyle) + + && isFunction(value?.getColor) + && isFunction(value?.getColorDTO) + && isFunction(value?.setColor) + + && isFunction(value?.getThickness) + && isFunction(value?.getThicknessDTO) + && isFunction(value?.setThickness) + ); +} diff --git a/entities/textDecoration/TextDecorationDTO.ts b/entities/textDecoration/TextDecorationDTO.ts new file mode 100644 index 0000000..1d021c1 --- /dev/null +++ b/entities/textDecoration/TextDecorationDTO.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + TextDecorationLineType, +} from "../types/TextDecorationLineType"; +import { + TextDecorationStyle, +} from "../types/TextDecorationStyle"; +import { + ColorDTO, +} from "../color/ColorDTO"; +import { + SizeDTO, +} from "../size/SizeDTO"; +import { DTO } from "../types/DTO"; + +export interface TextDecorationDTO extends DTO { + readonly lineType ?: TextDecorationLineType; + readonly color ?: ColorDTO; + readonly style ?: TextDecorationStyle; + readonly thickness ?: SizeDTO; +} diff --git a/entities/textDecoration/TextDecorationEntity.ts b/entities/textDecoration/TextDecorationEntity.ts new file mode 100644 index 0000000..5269daf --- /dev/null +++ b/entities/textDecoration/TextDecorationEntity.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { + ColorDTO, +} from "../color/ColorDTO"; +import { VariableType } from "../types/VariableType"; +import { + TextDecorationDTO, +} from "./TextDecorationDTO"; +import { SizeDTO } from "../size/SizeDTO"; +import { + ColorEntity, +} from "../color/ColorEntity"; +import { SizeEntity } from "../size/SizeEntity"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { TextDecoration } from "./TextDecoration"; +import { + isTextDecorationLineType, + TextDecorationLineType, +} from "../types/TextDecorationLineType"; +import { TextDecorationStyle } from "../types/TextDecorationStyle"; + + +export const TextDecorationEntityFactory = ( + EntityFactoryImpl.create('TextDecoration') + .add( EntityFactoryImpl.createProperty("lineType").setTypes(TextDecorationLineType, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("color").setTypes(ColorEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("style").setTypes(TextDecorationStyle, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("thickness").setTypes(SizeEntity, VariableType.UNDEFINED) ) +); + +export const BaseTextDecorationEntity = TextDecorationEntityFactory.createEntityType(); + +export const isTextDecorationDTO = TextDecorationEntityFactory.createTestFunctionOfDTO(); + +export const isTextDecoration = TextDecorationEntityFactory.createTestFunctionOfInterface(); + +export const explainTextDecorationDTO = TextDecorationEntityFactory.createExplainFunctionOfDTO(); + +export const isTextDecorationDTOOrUndefined = TextDecorationEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainTextDecorationDTOOrUndefined = TextDecorationEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + + +/** + * Text decoration entity. + */ +export class TextDecorationEntity + extends BaseTextDecorationEntity +{ + + /** + * Creates a text decoration entity. + * + * @param lineType + */ + public static create ( + lineType ?: TextDecorationLineType | undefined, + ) : TextDecorationEntity { + return new TextDecorationEntity( + lineType, + undefined, + undefined, + undefined, + ); + } + + /** + * Creates a font entity from DTO. + * + * @param value + */ + public static createFromDTO ( + value : TextDecorationDTO, + ) : TextDecorationEntity { + return new TextDecorationEntity( value ); + } + + public constructor (); + + public constructor ( + dto: TextDecorationDTO + ); + + public constructor ( + lineType : TextDecorationLineType | undefined, + color : ColorDTO | undefined, + style : TextDecorationStyle | undefined, + thickness : SizeDTO | undefined, + ); + + public constructor ( + lineType ?: TextDecorationLineType | TextDecorationDTO | TextDecoration | undefined, + color ?: ColorDTO | undefined, + style ?: TextDecorationStyle | undefined, + thickness ?: SizeDTO | undefined, + ) { + if ( lineType === undefined && color === undefined && style === undefined && thickness === undefined ) { + super(); + } else if ( isTextDecorationDTO(lineType) && color === undefined && style === undefined && thickness === undefined ) { + super( lineType ); + } else if ( isTextDecoration(lineType) && color === undefined && style === undefined && thickness === undefined ) { + super( lineType.getDTO() ); + } else if ( isTextDecorationLineType(lineType) ) { + super( + { + lineType, + color, + style, + thickness, + } + ); + } else { + throw new TypeError(`Unknown new TextDecorationEntity() signature: ${lineType}, ${color}, ${style}, ${thickness}`); + } + } + + /** + * @inheritDoc + */ + public getCssStyles (): ReadonlyJsonObject { + const lineType = this.getLineType(); + const color = this.getColorDTO(); + const style = this.getStyle(); + const thickness = this.getThicknessDTO(); + return { + ...(lineType ? { textDecorationLine: lineType } : {}), + ...(color ? { textDecorationColor: ColorEntity.createFromDTO(color).getCssStyles() } : {}), + ...(style ? { textDecorationStyle: style } : {}), + ...(thickness ? { textDecorationThickness: SizeEntity.createFromDTO( thickness ).getCssStyles() } : {}), + }; + } + +} + +export function isTextDecorationEntity (value: unknown): value is TextDecorationEntity { + return value instanceof TextDecorationEntity; +} diff --git a/entities/types/BackgroundAttachment.ts b/entities/types/BackgroundAttachment.ts new file mode 100644 index 0000000..d55e3cf --- /dev/null +++ b/entities/types/BackgroundAttachment.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BackgroundAttachment { + SCROLL = "scroll", + FIXED = "fixed", + LOCAL = "local", + INHERIT = "inherit", + INITIAL = "initial", + REVERT = "revert", + REVERT_LAYER = "revert-layer", + UNSET = "unset", +} + +export function isBackgroundAttachment (value: unknown) : value is BackgroundAttachment { + return isEnum(BackgroundAttachment, value); +} + +export function explainBackgroundAttachment (value : unknown) : string { + return explainEnum("BackgroundAttachment", BackgroundAttachment, isBackgroundAttachment, value); +} + +export function stringifyBackgroundAttachment (value : BackgroundAttachment) : string { + return stringifyEnum(BackgroundAttachment, value); +} + +export function parseBackgroundAttachment (value: any) : BackgroundAttachment | undefined { + return parseEnum(BackgroundAttachment, value) as BackgroundAttachment | undefined; +} + +export function isBackgroundAttachmentOrUndefined (value: unknown): value is BackgroundAttachment | undefined { + return isUndefined(value) || isBackgroundAttachment(value); +} + +export function explainBackgroundAttachmentOrUndefined (value: unknown): string { + return isBackgroundAttachmentOrUndefined(value) ? explainOk() : explainNot(explainOr(['BackgroundAttachment', 'undefined'])); +} diff --git a/entities/types/BackgroundBlendMode.ts b/entities/types/BackgroundBlendMode.ts new file mode 100644 index 0000000..186c9bf --- /dev/null +++ b/entities/types/BackgroundBlendMode.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BackgroundBlendMode { + NORMAL = "normal", + MULTIPLY = "multiply", + HARD_LIGHT = "hard-light", + DIFFERENCE = "difference", + SCREEN = "screen", + DARKEN = "darken", + LUMINOSITY = "luminosity", + INHERIT = "inherit", + INITIAL = "initial", + REVERT_LAYER = "revert-layer", + UNSET = "unset", +} + +export function isBackgroundBlendMode (value: unknown) : value is BackgroundBlendMode { + return isEnum(BackgroundBlendMode, value); +} + +export function explainBackgroundBlendMode (value : unknown) : string { + return explainEnum("BackgroundBlendMode", BackgroundBlendMode, isBackgroundBlendMode, value); +} + +export function stringifyBackgroundBlendMode (value : BackgroundBlendMode) : string { + return stringifyEnum(BackgroundBlendMode, value); +} + +export function parseBackgroundBlendMode (value: any) : BackgroundBlendMode | undefined { + return parseEnum(BackgroundBlendMode, value) as BackgroundBlendMode | undefined; +} + +export function isBackgroundBlendModeOrUndefined (value: unknown): value is BackgroundBlendMode | undefined { + return isUndefined(value) || isBackgroundBlendMode(value); +} + +export function explainBackgroundBlendModeOrUndefined (value: unknown): string { + return isBackgroundBlendModeOrUndefined(value) ? explainOk() : explainNot(explainOr(['BackgroundBlendMode', 'undefined'])); +} diff --git a/entities/types/BackgroundClip.ts b/entities/types/BackgroundClip.ts new file mode 100644 index 0000000..ea9c453 --- /dev/null +++ b/entities/types/BackgroundClip.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BackgroundClip { + + /** + * The background extends to the outside edge of the border (but underneath + * the border in z-ordering). + */ + BORDER_BOX = "border-box", + + /** + * The background extends to the outside edge of the padding. No background + * is drawn beneath the border. + */ + PADDING_BOX = "padding-box", + + /** + * The background is painted within (clipped to) the content box. + */ + CONTENT_BOX = "content-box", + + /** + * The background is painted within (clipped to) the foreground text. + */ + TEXT = "text", + INHERIT = "inherit", + INITIAL = "initial", + REVERT = "revert", + REVERT_LAYER = "revert-layer", + UNSET = "unset", +} + +export function isBackgroundClip (value: unknown) : value is BackgroundClip { + return isEnum(BackgroundClip, value); +} + +export function explainBackgroundClip (value : unknown) : string { + return explainEnum("BackgroundClip", BackgroundClip, isBackgroundClip, value); +} + +export function stringifyBackgroundClip (value : BackgroundClip) : string { + return stringifyEnum(BackgroundClip, value); +} + +export function parseBackgroundClip (value: any) : BackgroundClip | undefined { + return parseEnum(BackgroundClip, value) as BackgroundClip | undefined; +} + +export function isBackgroundClipOrUndefined (value: unknown): value is BackgroundClip | undefined { + return isUndefined(value) || isBackgroundClip(value); +} + +export function explainBackgroundClipOrUndefined (value: unknown): string { + return isBackgroundClipOrUndefined(value) ? explainOk() : explainNot(explainOr(['BackgroundClip', 'undefined'])); +} diff --git a/entities/types/BackgroundOrigin.ts b/entities/types/BackgroundOrigin.ts new file mode 100644 index 0000000..6c45d6e --- /dev/null +++ b/entities/types/BackgroundOrigin.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BackgroundOrigin { + + /** + * The background is positioned relative to the border box. + */ + BORDER_BOX = "border-box", + + /** + * The background is positioned relative to the padding box. + */ + PADDING_BOX = "padding-box", + + /** + * The background is positioned relative to the content box. + */ + CONTENT_BOX = "content-box", + + INHERIT = "inherit", + INITIAL = "initial", + REVERT = "revert", + REVERT_LAYER = "revert-layer", + UNSET = "unset", +} + +export function isBackgroundOrigin (value: unknown) : value is BackgroundOrigin { + return isEnum(BackgroundOrigin, value); +} + +export function explainBackgroundOrigin (value : unknown) : string { + return explainEnum("BackgroundOrigin", BackgroundOrigin, isBackgroundOrigin, value); +} + +export function stringifyBackgroundOrigin (value : BackgroundOrigin) : string { + return stringifyEnum(BackgroundOrigin, value); +} + +export function parseBackgroundOrigin (value: any) : BackgroundOrigin | undefined { + return parseEnum(BackgroundOrigin, value) as BackgroundOrigin | undefined; +} + +export function isBackgroundOriginOrUndefined (value: unknown): value is BackgroundOrigin | undefined { + return isUndefined(value) || isBackgroundOrigin(value); +} + +export function explainBackgroundOriginOrUndefined (value: unknown): string { + return isBackgroundOriginOrUndefined(value) ? explainOk() : explainNot(explainOr(['BackgroundOrigin', 'undefined'])); +} diff --git a/entities/types/BackgroundPositionOptions.ts b/entities/types/BackgroundPositionOptions.ts new file mode 100644 index 0000000..b4996e6 --- /dev/null +++ b/entities/types/BackgroundPositionOptions.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { isPairArrayOf, isTetradArrayOf, isTripletArrayOf } from "../../types/Array"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { + isSizeDTO, + SizeEntity, +} from "../size/SizeEntity"; +import { SizeDTO } from "../size/SizeDTO"; +import { BackgroundPositionValue, isBackgroundPositionValue } from "./BackgroundPositionValue"; + +export type BackgroundPositionOptions = ( + BackgroundPositionValue + | [ BackgroundPositionValue, BackgroundPositionValue ] + | [ BackgroundPositionValue, SizeDTO, BackgroundPositionValue ] + | [ BackgroundPositionValue, BackgroundPositionValue, SizeDTO ] + | [ BackgroundPositionValue, SizeDTO ] + | [ SizeDTO, SizeDTO ] + | [ BackgroundPositionValue, SizeDTO, BackgroundPositionValue, SizeDTO ] +); + +/** + * + * @param a + */ +export function createBackgroundPositionOptions ( + a : BackgroundPositionValue, +) : BackgroundPositionValue; + +/** + * + * @param a + * @param b + */ +export function createBackgroundPositionOptions ( + a : BackgroundPositionValue, + b : BackgroundPositionValue, +) : [ BackgroundPositionValue, BackgroundPositionValue ]; + +/** + * + * @param a + * @param aSize + * @param c + */ +export function createBackgroundPositionOptions ( + a : BackgroundPositionValue, + aSize : SizeDTO, + c : BackgroundPositionValue, +) : [ BackgroundPositionValue, SizeDTO, BackgroundPositionValue ]; + +/** + * + * @param a + * @param b + * @param bSize + */ +export function createBackgroundPositionOptions ( + a : BackgroundPositionValue, + b : BackgroundPositionValue, + bSize : SizeDTO, +) : [ BackgroundPositionValue, BackgroundPositionValue, SizeDTO ]; + +/** + * + * @param a + * @param size + */ +export function createBackgroundPositionOptions ( + a : BackgroundPositionValue, + size : SizeDTO, +) : [ BackgroundPositionValue, SizeDTO ]; + +/** + * + * @param aSize + * @param bSize + */ +export function createBackgroundPositionOptions ( + aSize : SizeDTO, + bSize : SizeDTO, +) : [ SizeDTO, SizeDTO ]; + +/** + * + * @param a + * @param aSize + * @param b + * @param bSize + */ +export function createBackgroundPositionOptions ( + a : BackgroundPositionValue, + aSize : SizeDTO, + b : BackgroundPositionValue, + bSize : SizeDTO, +) : [ BackgroundPositionValue, SizeDTO, BackgroundPositionValue, SizeDTO ]; + +export function createBackgroundPositionOptions ( + a : BackgroundPositionValue | SizeDTO, + b ?: BackgroundPositionValue | SizeDTO, + c ?: BackgroundPositionValue | SizeDTO, + d ?: BackgroundPositionValue | SizeDTO, +) : BackgroundPositionOptions { + if (isBackgroundPositionValue(a)) { + if ( b === undefined && c === undefined && d === undefined ) { + return a; + } else if ( isBackgroundPositionValue(b) ) { + if ( c === undefined && d === undefined ) { + return [a, b]; + } else if ( isSizeDTO(c) && d === undefined ) { + return [a, b, c]; + } + } else if ( isSizeDTO(b) ) { + if ( c === undefined && d === undefined ) { + return [a, b]; + } else if ( isBackgroundPositionValue(c) ) { + if ( d === undefined ) { + return [a, b, c]; + } else if ( isSizeDTO(d) ) { + return [a, b, c, d]; + } + } + } + } else if ( isSizeDTO(a) && isSizeDTO(b) && c === undefined && d === undefined ) { + return [a, b]; + } + throw new TypeError(`Unsupported arguments provided: ${a}, ${b}, ${c}, ${d}`); +} + +export function isBackgroundPositionOptions (value: unknown) : value is BackgroundPositionOptions { + return ( + isBackgroundPositionValue(value) + || isPairArrayOf(value, isSizeDTO, isSizeDTO) + || isPairArrayOf(value, isBackgroundPositionValue, isSizeDTO) + || isPairArrayOf(value, isBackgroundPositionValue, isBackgroundPositionValue) + || isTripletArrayOf(value, isBackgroundPositionValue, isBackgroundPositionValue, isSizeDTO) + || isTripletArrayOf(value, isBackgroundPositionValue, isSizeDTO, isBackgroundPositionValue) + || isTetradArrayOf(value, isBackgroundPositionValue, isSizeDTO, isBackgroundPositionValue, isSizeDTO) + ); +} + +export function explainBackgroundPositionOptions (value: any) : string { + return isBackgroundPositionOptions(value) ? explainOk() : explainNot( + explainOr( + [ + 'BackgroundPosition', + '[SizeDTO, SizeDTO]', + '[BackgroundPosition, SizeDTO]', + '[BackgroundPosition, BackgroundPosition]', + '[BackgroundPosition, BackgroundPosition, SizeDTO]', + '[BackgroundPosition, SizeDTO, BackgroundPosition]', + '[BackgroundPosition, SizeDTO, BackgroundPosition, SizeDTO]', + ] + ) + ); +} + +export function stringifyBackgroundPositionOptions (value : BackgroundPositionOptions) : string { + return `BackgroundPositionOptions(${value})`; +} + +export function parseBackgroundPositionOptions (value: unknown) : BackgroundPositionOptions | undefined { + if (isBackgroundPositionOptions(value)) return value; + return undefined; +} + +export function isBackgroundPositionOptionsOrUndefined (value: unknown): value is BackgroundPositionOptions | undefined { + return isUndefined(value) || isBackgroundPositionOptions(value); +} + +export function explainBackgroundPositionOptionsOrUndefined (value: unknown): string { + return isBackgroundPositionOptionsOrUndefined(value) ? explainOk() : explainNot(explainOr([ + 'BackgroundPosition', + '[SizeDTO, SizeDTO]', + '[BackgroundPosition, SizeDTO]', + '[BackgroundPosition, BackgroundPosition]', + '[BackgroundPosition, BackgroundPosition, SizeDTO]', + '[BackgroundPosition, SizeDTO, BackgroundPosition]', + '[BackgroundPosition, SizeDTO, BackgroundPosition, SizeDTO]', + 'undefined' + ])); +} + +export function getCssStylesForBackgroundPosition ( + value : BackgroundPositionOptions, +) : string { + + if (isBackgroundPositionValue(value)) { + return `${value}`; + } + + if (isPairArrayOf(value, isSizeDTO, isSizeDTO)) { + return `${ + SizeEntity.createFromDTO(value[0]).getCssStyles() + } ${ + SizeEntity.createFromDTO(value[1]).getCssStyles() + }`; + } + + if (isPairArrayOf(value, isBackgroundPositionValue, isSizeDTO)) { + return `${ + value[0] + } ${ + SizeEntity.createFromDTO(value[1]).getCssStyles() + }`; + } + + if (isPairArrayOf(value, isBackgroundPositionValue, isBackgroundPositionValue)) { + return `${ + value[0] + } ${ + value[1] + }`; + } + + if (isTripletArrayOf(value, isBackgroundPositionValue, isBackgroundPositionValue, isSizeDTO)) { + return `${ + value[0] + } ${ + value[1] + } ${ + SizeEntity.createFromDTO(value[2]).getCssStyles() + }`; + } + + if (isTripletArrayOf(value, isBackgroundPositionValue, isSizeDTO, isBackgroundPositionValue)) { + return `${ + value[0] + } ${ + SizeEntity.createFromDTO(value[1]).getCssStyles() + } ${ + value[2] + }`; + } + + if (isTetradArrayOf(value, isBackgroundPositionValue, isSizeDTO, isBackgroundPositionValue, isSizeDTO)) { + return `${ + value[0] + } ${ + SizeEntity.createFromDTO(value[1]).getCssStyles() + } ${ + value[2] + } ${ + SizeEntity.createFromDTO(value[3]).getCssStyles() + }`; + } + + throw new TypeError(`getCssStylesForBackgroundPosition: Could not prepare CSS styles for ${value}`); +} diff --git a/entities/types/BackgroundPositionValue.ts b/entities/types/BackgroundPositionValue.ts new file mode 100644 index 0000000..9592a5e --- /dev/null +++ b/entities/types/BackgroundPositionValue.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BackgroundPositionValue { + TOP = "top", + BOTTOM = "bottom", + LEFT = "left", + RIGHT = "right", + CENTER = "center", +} + +export function isBackgroundPositionValue ( value: unknown) : value is BackgroundPositionValue { + return isEnum(BackgroundPositionValue, value); +} + +export function explainBackgroundPositionValue ( value : unknown) : string { + return explainEnum("BackgroundPosition", BackgroundPositionValue, isBackgroundPositionValue, value); +} + +export function stringifyBackgroundPositionValue ( value : BackgroundPositionValue) : string { + return stringifyEnum(BackgroundPositionValue, value); +} + +export function parseBackgroundPositionValue ( value: any) : BackgroundPositionValue | undefined { + return parseEnum(BackgroundPositionValue, value) as BackgroundPositionValue | undefined; +} + +export function isBackgroundPositionValueOrUndefined ( value: unknown): value is BackgroundPositionValue | undefined { + return isUndefined(value) || isBackgroundPositionValue(value); +} + +export function explainBackgroundPositionValueOrUndefined ( value: unknown): string { + return isBackgroundPositionValueOrUndefined(value) ? explainOk() : explainNot(explainOr(['BackgroundPosition', 'undefined'])); +} + diff --git a/entities/types/BackgroundRepeatType.ts b/entities/types/BackgroundRepeatType.ts new file mode 100644 index 0000000..ead2708 --- /dev/null +++ b/entities/types/BackgroundRepeatType.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BackgroundRepeatType { + REPEAT = "repeat", + SPACE = "space", + ROUND = "round", + NO_REPEAT = "no-repeat", +} + +export function isBackgroundRepeatType (value: unknown) : value is BackgroundRepeatType { + return isEnum(BackgroundRepeatType, value); +} + +export function explainBackgroundRepeatType (value : unknown) : string { + return explainEnum("BackgroundRepeat", BackgroundRepeatType, isBackgroundRepeatType, value); +} + +export function stringifyBackgroundRepeatType (value : BackgroundRepeatType) : string { + return stringifyEnum(BackgroundRepeatType, value); +} + +export function parseBackgroundRepeatType (value: any) : BackgroundRepeatType | undefined { + return parseEnum(BackgroundRepeatType, value) as BackgroundRepeatType | undefined; +} + +export function isBackgroundRepeatTypeOrUndefined (value: unknown): value is BackgroundRepeatType | undefined { + return isUndefined(value) || isBackgroundRepeatType(value); +} + +export function explainBackgroundRepeatTypeOrUndefined (value: unknown): string { + return isBackgroundRepeatTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['BackgroundRepeat', 'undefined'])); +} diff --git a/entities/types/BackgroundSize.ts b/entities/types/BackgroundSize.ts new file mode 100644 index 0000000..dbbe13a --- /dev/null +++ b/entities/types/BackgroundSize.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BackgroundSize { + COVER = "cover", + CONTAIN = "contain", + INHERIT = "inherit", + INITIAL = "initial", + REVERT = "revert", + REVERT_LAYER = "revert-layer", + UNSET = "unset", +} + +export function isBackgroundSize (value: unknown) : value is BackgroundSize { + return isEnum(BackgroundSize, value); +} + +export function explainBackgroundSize (value : unknown) : string { + return explainEnum("BackgroundSize", BackgroundSize, isBackgroundSize, value); +} + +export function stringifyBackgroundSize (value : BackgroundSize) : string { + return stringifyEnum(BackgroundSize, value); +} + +export function parseBackgroundSize (value: any) : BackgroundSize | undefined { + return parseEnum(BackgroundSize, value) as BackgroundSize | undefined; +} + +export function isBackgroundSizeOrUndefined (value: unknown): value is BackgroundSize | undefined { + return isUndefined(value) || isBackgroundSize(value); +} + +export function explainBackgroundSizeOrUndefined (value: unknown): string { + return isBackgroundSizeOrUndefined(value) ? explainOk() : explainNot(explainOr(['BackgroundSize', 'undefined'])); +} diff --git a/entities/types/BackgroundSizeOptions.ts b/entities/types/BackgroundSizeOptions.ts new file mode 100644 index 0000000..a9bb331 --- /dev/null +++ b/entities/types/BackgroundSizeOptions.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + isSizeDTO, + SizeEntity, +} from "../size/SizeEntity"; +import { SizeDTO } from "../size/SizeDTO"; +import { SizeDimensionsDTO } from "../sizeDimensions/SizeDimensionsDTO"; +import { isSizeDimensionsDTO } from "../sizeDimensions/SizeDimensionsEntity"; +import { BackgroundSize, isBackgroundSize } from "./BackgroundSize"; + +export type BackgroundSizeOptions = ( + BackgroundSize + | SizeDTO + | SizeDimensionsDTO +); + +export function getCssStylesForBackgroundSizeOptions (value : BackgroundSizeOptions) : string { + + if ( isBackgroundSize(value) ) { + return `${value}`; + } + + if ( isSizeDTO(value) ) { + return `${ SizeEntity.createFromDTO(value).getCssStyles() }`; + } + + if ( isSizeDimensionsDTO(value) && value.width && value.height ) { + return `${ + SizeEntity.createFromDTO(value.width).getCssStyles() + } ${ + SizeEntity.createFromDTO(value.height).getCssStyles() + }`; + } + + throw new TypeError(`getCssStylesForBackgroundSizeOptions: Unsupported value: ${value}`); + +} diff --git a/entities/types/BaseEntity.ts b/entities/types/BaseEntity.ts new file mode 100644 index 0000000..077a9a8 --- /dev/null +++ b/entities/types/BaseEntity.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { has } from "../../functions/has"; +import { + isReadonlyJsonAny, + isReadonlyJsonObject, + ReadonlyJsonAny, + ReadonlyJsonObject, +} from "../../Json"; +import { DTO } from "./DTO"; +import { Entity } from "./Entity"; +import { EntityType } from "./EntityType"; + +export abstract class BaseEntity< + D extends DTO, + T extends Entity, +> + implements Entity { + + private _dto : D; + + public constructor ( + dto : D, + ) { + if (!isReadonlyJsonObject(dto)) { + throw new TypeError(`BaseEntity.constructor: The DTO must be JSON serializable object: ${dto}`); + } + this._dto = dto; + } + + protected _setPropertyValue ( + propertyName : string, + value : ReadonlyJsonAny | undefined + ) : this { + if ( isReadonlyJsonAny(value) || value === undefined ) { + this._dto = { + ...this._dto, + [propertyName]: value, + }; + } else { + throw new TypeError(`${this.getEntityType().getEntityName()}.${propertyName}: The type of value not supported: ${value}`); + } + return this; + } + + protected _getPropertyValue ( + propertyName : string, + ) : ReadonlyJsonAny | undefined { + if (has(this._dto, propertyName)) { + return (this._dto as any)[propertyName]; + } else { + return undefined; + } + } + + public getDTO () : D { + return this._dto; + } + + public toJSON () : ReadonlyJsonObject { + return this._dto as unknown as ReadonlyJsonObject; + } + + public valueOf () : ReadonlyJsonObject { + return this.toJSON(); + } + + abstract getEntityType () : EntityType; + +} diff --git a/entities/types/BorderStyle.ts b/entities/types/BorderStyle.ts new file mode 100644 index 0000000..c220d35 --- /dev/null +++ b/entities/types/BorderStyle.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BorderStyle { + DOTTED = "dotted", + DASHED = "dashed", + SOLID = "solid", + DOUBLE = "double", + GROOVE = "groove", + RIDGE = "ridge", + INSET = "inset", + OUTSET = "outset", + NONE = "none", + HIDDEN = "hidden", +} + +export function isBorderStyle (value: unknown) : value is BorderStyle { + return isEnum(BorderStyle, value); +} + +export function explainBorderStyle (value : unknown) : string { + return explainEnum("BorderStyle", BorderStyle, isBorderStyle, value); +} + +export function stringifyBorderStyle (value : BorderStyle) : string { + return stringifyEnum(BorderStyle, value); +} + +export function parseBorderStyle (value: any) : BorderStyle | undefined { + return parseEnum(BorderStyle, value) as BorderStyle | undefined; +} + +export function isBorderStyleOrUndefined (value: unknown): value is BorderStyle | undefined { + return isUndefined(value) || isBorderStyle(value); +} + +export function explainBorderStyleOrUndefined (value: unknown): string { + return isBorderStyleOrUndefined(value) ? explainOk() : explainNot(explainOr(['BorderStyle', 'undefined'])); +} diff --git a/entities/types/BoxSizing.ts b/entities/types/BoxSizing.ts new file mode 100644 index 0000000..c82af95 --- /dev/null +++ b/entities/types/BoxSizing.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum BoxSizing { + BORDER_BOX = "border-box", + CONTENT_BOX = "content-box", + INHERIT = "inherit", + INITIAL = "initial", + REVERT = "revert", + REVERT_LAYER = "revert-layer", + UNSET = "unset", +} + +export function isBoxSizing (value: unknown) : value is BoxSizing { + return isEnum(BoxSizing, value); +} + +export function explainBoxSizing (value : unknown) : string { + return explainEnum("BoxSizing", BoxSizing, isBoxSizing, value); +} + +export function stringifyBoxSizing (value : BoxSizing) : string { + return stringifyEnum(BoxSizing, value); +} + +export function parseBoxSizing (value: any) : BoxSizing | undefined { + return parseEnum(BoxSizing, value) as BoxSizing | undefined; +} + +export function isBoxSizingOrUndefined (value: unknown): value is BoxSizing | undefined { + return isUndefined(value) || isBoxSizing(value); +} + +export function explainBoxSizingOrUndefined (value: unknown): string { + return isBoxSizingOrUndefined(value) ? explainOk() : explainNot(explainOr(['BoxSizing', 'undefined'])); +} diff --git a/entities/types/ChainOperation.ts b/entities/types/ChainOperation.ts new file mode 100644 index 0000000..2f8f84f --- /dev/null +++ b/entities/types/ChainOperation.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + explainEnum, + isEnum, + parseEnum, + stringifyEnum, +} from "../../types/Enum"; +import { + explainNot, + explainOk, + explainOr, +} from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum ChainOperation { + AND = "AND", + OR = "OR", +} + +export function isChainOperation (value: unknown) : value is ChainOperation { + return isEnum(ChainOperation, value); +} + +export function explainChainOperation (value : unknown) : string { + return explainEnum("ChainOperation", ChainOperation, isChainOperation, value); +} + +export function stringifyChainOperation (value : ChainOperation) : string { + return stringifyEnum(ChainOperation, value); +} + +export function parseChainOperation (value: any) : ChainOperation | undefined { + return parseEnum(ChainOperation, value) as ChainOperation | undefined; +} + +export function isChainOperationOrUndefined (value: unknown): value is ChainOperation | undefined { + return isUndefined(value) || isChainOperation(value); +} + +export function explainChainOperationOrUndefined (value: unknown): string { + return isChainOperationOrUndefined(value) ? explainOk() : explainNot(explainOr(['ChainOperation', 'undefined'])); +} + +export type ChainOperationFunction = (a: boolean, b: boolean) => boolean; + +export const andChainOperation : ChainOperationFunction = (a: boolean, b: boolean) => a && b; + +export const orChainOperation : ChainOperationFunction = (a: boolean, b: boolean) => a || b; + +export function getChainOperationFunction ( + op : ChainOperation +) : ChainOperationFunction { + switch (op) { + case ChainOperation.AND: return andChainOperation; + case ChainOperation.OR: return orChainOperation; + } + throw new TypeError(`getChainOperationFunction: Unsupported operation: ${op}`); +} diff --git a/entities/types/DTO.ts b/entities/types/DTO.ts new file mode 100644 index 0000000..29c5655 --- /dev/null +++ b/entities/types/DTO.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export interface DTO { + +} diff --git a/entities/types/DTOWithContent.ts b/entities/types/DTOWithContent.ts new file mode 100644 index 0000000..21d098e --- /dev/null +++ b/entities/types/DTOWithContent.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export interface DTOWithContent { + readonly content ?: string | T | readonly (string|T)[]; +} diff --git a/entities/types/DTOWithName.ts b/entities/types/DTOWithName.ts new file mode 100644 index 0000000..217501e --- /dev/null +++ b/entities/types/DTOWithName.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export interface DTOWithName { + + /** + * Name of the object. + */ + readonly name : string; + +} \ No newline at end of file diff --git a/entities/types/DTOWithOptionalExtend.ts b/entities/types/DTOWithOptionalExtend.ts new file mode 100644 index 0000000..57074d9 --- /dev/null +++ b/entities/types/DTOWithOptionalExtend.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export interface DTOWithOptionalExtend { + + /** + * Name of the object which to extend from. + */ + readonly extend ?: string; + +} \ No newline at end of file diff --git a/entities/types/DTOWithOptionalLanguage.ts b/entities/types/DTOWithOptionalLanguage.ts new file mode 100644 index 0000000..d4e5aa0 --- /dev/null +++ b/entities/types/DTOWithOptionalLanguage.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export interface DTOWithOptionalLanguage { + readonly language ?: string; +} \ No newline at end of file diff --git a/entities/types/DTOWithOptionalPublicUrl.ts b/entities/types/DTOWithOptionalPublicUrl.ts new file mode 100644 index 0000000..25312ad --- /dev/null +++ b/entities/types/DTOWithOptionalPublicUrl.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export interface DTOWithOptionalPublicUrl { + readonly publicUrl ?: string; +} diff --git a/entities/types/Entity.ts b/entities/types/Entity.ts new file mode 100644 index 0000000..96ddc16 --- /dev/null +++ b/entities/types/Entity.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { DTO } from "./DTO"; +import { EntityType } from "./EntityType"; +import { JsonSerializable } from "./JsonSerializable"; + +/** + * Entity interface. + */ +export interface Entity< + D extends DTO +> + extends JsonSerializable +{ + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * Returns the DTO. + */ + getDTO () : D; + + /** + * Returns the type of the entity + */ + getEntityType () : EntityType>; + +} + +export function isEntity (value : unknown) : value is Entity { + return ( + isObject(value) + && isFunction(value?.valueOf) + && isFunction(value?.toJSON) + && isFunction(value?.getDTO) + && isFunction(value?.getEntityType) + ); +} diff --git a/entities/types/EntityFactory.ts b/entities/types/EntityFactory.ts new file mode 100644 index 0000000..b35577f --- /dev/null +++ b/entities/types/EntityFactory.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { DTO } from "./DTO"; +import { BaseEntity } from "./BaseEntity"; +import { Entity } from "./Entity"; +import { EntityMethod } from "./EntityMethod"; +import { + EntityProperty, + +} from "./EntityProperty"; +import { EntityVariableType } from "./EntityVariableType"; +import { EntityType } from "./EntityType"; +import { IsDTOTestFunction } from "./IsDTOTestFunction"; + +export type ArrayMapMethod< + I = any, + R = any, +> = (item: I) => R; + +export type GetterMethod< + D extends DTO, + T extends BaseEntity, + R = any +> = (this: T) => R; + +export type SetterMethod< + D extends DTO, + T extends BaseEntity, + R = any +> = (this: T, value: R) => T; + +export interface TypeCheckFn { + (value: unknown): boolean; +} + +export interface TypeExplainFn { + (value: unknown): string; +} + +export interface PropertyTypeCheckFn { + (value: ReadonlyJsonObject): boolean; +} + +export interface MethodTypeCheckFn { + (value: Object): boolean; +} + +export interface CreateEntityTypeOpts { + readonly immutable ?: boolean, + readonly name ?: string, +} + +/** + * Factory for entity classes. + */ +export interface EntityFactory< + D extends DTO, + T extends Entity, +> { + + /** + * Get all defined properties. + */ + getProperties () : readonly EntityProperty[]; + + /** + * Create a new property object, to be used with `.add( .createProperty(name) ... )`. + * + * @param name + */ + createProperty (name : string) : EntityProperty; + + /** + * Add a property with name and type(s). + * + * @param name The name of the property + * @param types Type(s) of the property + */ + add (name: string, ...types : EntityVariableType[]) : this; + + /** + * Add a property with a property entity. + * + * @param item The property + */ + add (item: EntityProperty) : this; + + /** + * Creates a default DTO object. + */ + createDefaultDTO () : D; + + /** + * Creates a test function for DTO object. + */ + createTestFunctionOfDTO () : IsDTOTestFunction; + + /** + * Creates an entity constructor by default name. + */ + createEntityType () : EntityType; + + /** + * Creates an entity constructor by another name. + */ + createEntityType ( + name : string, + ) : EntityType; + + /** + * Creates an entity constructor by name and options. + * + * @param name + * @param opts + */ + createEntityType ( + name : string, + opts: CreateEntityTypeOpts, + ) : EntityType; + + /** + * Creates an entity constructor by default name and options. + * + * @param opts + */ + createEntityType ( + opts: CreateEntityTypeOpts, + ) : EntityType; + + getStaticMethods () : readonly EntityMethod[]; + + createMethod (name : string) : EntityMethod; + + addStaticMethod ( name : EntityMethod ) : this; + +} diff --git a/entities/types/EntityFactoryImpl.test.ts b/entities/types/EntityFactoryImpl.test.ts new file mode 100644 index 0000000..9bdb162 --- /dev/null +++ b/entities/types/EntityFactoryImpl.test.ts @@ -0,0 +1,1560 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from "@jest/globals"; +import "../../../testing/jest/matchers/index"; +import { factory } from "ts-jest/dist/transformers/hoist-jest"; +import { BaseEntity } from "./BaseEntity"; +import { DTO } from "./DTO"; +import { Entity } from "./Entity"; +import { EntityFactoryImpl } from "./EntityFactoryImpl"; +import { EntityMethodImpl } from "./EntityMethodImpl"; +import { EntityProperty } from "./EntityProperty"; +import { EntityType } from "./EntityType"; +import { VariableType } from "./VariableType"; + +describe('EntityFactoryImpl', () => { + + afterEach( () => { + EntityFactoryImpl.destroy(); + }); + + describe('#create', () => { + + it('can create an entity factory instance', () => { + const item = EntityFactoryImpl.create('Item'); + expect( item ).toBeDefined(); + expect( item ).toBeInstanceOf(EntityFactoryImpl); + }); + + it('can create an entity factory instance with a property', () => { + const item = ( + EntityFactoryImpl + .create('Item') + .add( "name", VariableType.STRING) + ); + const properties = item.getProperties(); + expect( properties?.length ).toBe(1); + expect( properties[0].getPropertyName() ).toBe("name"); + expect( properties[0].getTypes() ).toStrictEqual(["string"]); + }); + + it('can create an entity factory instance with an optional property', () => { + const item = ( + EntityFactoryImpl + .create('Item') + .add( "name", VariableType.STRING, VariableType.UNDEFINED) + ); + const properties = item.getProperties(); + expect( properties?.length ).toBe(1); + expect( properties[0].getPropertyName() ).toBe("name"); + expect( properties[0].getTypes() ).toStrictEqual(["string", "undefined"]); + }); + + it('can create an entity factory instance with an enum property', () => { + + enum CarType { + AUTOMATIC = "AUTOMATIC", + MANUAL = "MANUAL" + } + + const item = ( + EntityFactoryImpl + .create('Car') + .add( "name", VariableType.STRING) + .add( "type", CarType ) + ); + const properties = item.getProperties(); + expect( properties?.length ).toBe(2); + expect( properties[0].getPropertyName() ).toBe("name"); + expect( properties[0].getTypes() ).toStrictEqual(["string"]); + expect( properties[1].getPropertyName() ).toBe("type"); + expect( properties[1].getTypes() ).toStrictEqual([ + { + "AUTOMATIC" : "AUTOMATIC", + "MANUAL": "MANUAL", + } + ]); + }); + + + it('can create an entity factory instance with an array of enums', () => { + + enum CarType { + AUTOMATIC = "AUTOMATIC", + MANUAL = "MANUAL" + } + + const item = ( + EntityFactoryImpl + .create('Car') + .add( "name", VariableType.STRING) + .add( EntityFactoryImpl.createArrayProperty("types").setTypes(CarType) ) + ); + const properties = item.getProperties(); + expect( properties?.length ).toBe(2); + expect( properties[0].getPropertyName() ).toBe("name"); + expect( properties[0].getTypes() ).toStrictEqual(["string"]); + expect( properties[1].isArray() ).toBe(true); + expect( properties[1].getPropertyName() ).toBe("types"); + expect( properties[1].getTypes() ).toStrictEqual([ + { + "AUTOMATIC" : "AUTOMATIC", + "MANUAL": "MANUAL", + } + ]); + }); + + it('can create an entity factory instance with an optional enum property', () => { + + enum CarType { + AUTOMATIC = "AUTOMATIC", + MANUAL = "MANUAL" + } + + const item = ( + EntityFactoryImpl + .create('Car') + .add( "name", VariableType.STRING, VariableType.UNDEFINED) + .add( "type", CarType, VariableType.UNDEFINED) + ); + const properties = item.getProperties(); + expect( properties?.length ).toBe(2); + expect( properties[0].getPropertyName() ).toBe("name"); + expect( properties[0].getTypes() ).toStrictEqual(["string", "undefined"]); + expect( properties[1].getPropertyName() ).toBe("type"); + expect( properties[1].getTypes() ).toStrictEqual([ + { + "AUTOMATIC" : "AUTOMATIC", + "MANUAL": "MANUAL", + }, + "undefined" + ]); + }); + + }); + + describe('.createDefaultDTO', () => { + + it('can create a default DTO object with undefined value', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( "name", VariableType.STRING, VariableType.UNDEFINED) + ); + expect( item.createDefaultDTO() ).toStrictEqual({}); + }); + + it('can create a default DTO object with null value', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( "name", VariableType.STRING, VariableType.NULL) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ name: null }); + }); + + it('can create a default DTO object with null and undefined values', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( "name", VariableType.STRING, VariableType.NULL, VariableType.UNDEFINED) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ }); + }); + + it('can create a default DTO object with string value', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( "name", VariableType.STRING) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ name : '' }); + }); + + it('can create a default DTO object with number value', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( "name", VariableType.NUMBER) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ name : 0 }); + }); + + it('can create a default DTO object with boolean value', () => { + const item = ( + EntityFactoryImpl.create('Item') + .add( "name", VariableType.BOOLEAN) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ name : false }); + }); + + it('can create a default DTO object with integer value', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( "name", VariableType.INTEGER) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ name : 0 }); + }); + + it('can create a default DTO object with multiple properties', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( "age", VariableType.INTEGER) + .add( "name", VariableType.STRING) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ name : '', age: 0 }); + }); + + it('can create a default DTO object with custom default values', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ name : 'Smith', age: 30 }); + }); + + it('can create a default DTO object with non-optional array values', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createArrayProperty("firstNames").setTypes(VariableType.STRING).setDefaultValue(['John', 'Edward']) ) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ + firstNames : [ + 'John', + 'Edward' + ], + age: 30 + }); + }); + + it('can create a default DTO object with non-optional empty array values', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createArrayProperty("firstNames").setTypes(VariableType.STRING) ) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ + age: 30, + firstNames: [] + }); + }); + + it('can create a default DTO object with non-defined optional array values', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createOptionalArrayProperty("firstNames").setTypes(VariableType.STRING) ) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ + age: 30 + }); + }); + + it('can create a default DTO object with defined optional array values', () => { + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createOptionalArrayProperty("firstNames").setTypes(VariableType.STRING).setDefaultValue(['John', 'Edward']) ) + ); + expect( item.createDefaultDTO() ).toStrictEqual({ + firstNames : [ + 'John', + 'Edward' + ], + age: 30 + }); + }); + + }); + + describe('.createTestFunctionOfDTO', () => { + + it('can create a test function for DTOs', () => { + + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createTestFunctionOfDTO(); + + expect( fn({name : 'John', age: 20}) ).toBe(true); + + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + + }); + + }); + + describe('.createExplainFunctionOfDTO', () => { + + it('can create an explain function for DTOs', () => { + + const item = ( + EntityFactoryImpl.create('Person') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createExplainFunctionOfDTO(); + + expect( fn({name : 'John', age: 20}) ).toBe('OK'); + + expect( fn({name : 'John', age: null}) ).toBe(`not Person DTO: \n property "age" not integer`); + expect( fn({name : 123, age: 30}) ).toBe(`not Person DTO: \n property "name" not string`); + expect( fn({age: 30}) ).toBe(`not Person DTO: \n property "name" not string`); + expect( fn({name : 123}) ).toBe(`not Person DTO: \n property "age" not integer, \n property "name" not string`); + expect( fn(123) ).toBe(`not Person DTO: not regular object`); + expect( fn(null) ).toBe(`not Person DTO: not regular object`); + expect( fn(undefined) ).toBe(`not Person DTO: not regular object`); + expect( fn({}) ).toBe(`not Person DTO: \n property "age" not integer, \n property "name" not string`); + expect( fn([]) ).toBe(`not Person DTO: not regular object`); + expect( fn(true) ).toBe(`not Person DTO: not regular object`); + expect( fn(false) ).toBe(`not Person DTO: not regular object`); + expect( fn("hello world") ).toBe(`not Person DTO: not regular object`); + + }); + + }); + + describe('.createTestFunctionOfDTOorOneOf', () => { + + it('can create a test function for DTOs and undefined', () => { + + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + + expect( fn({name : 'John', age: 20}) ).toBe(true); + expect( fn(undefined) ).toBe(true); + + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + + }); + + it('can create a test function for DTOs, undefined, and null', () => { + + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED, VariableType.NULL); + + expect( fn({name : 'John', age: 20}) ).toBe(true); + expect( fn(undefined) ).toBe(true); + expect( fn(null) ).toBe(true); + + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + + }); + + it('can create a test function for DTOs, number, undefined, and null', () => { + + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createTestFunctionOfDTOorOneOf(VariableType.NUMBER, VariableType.UNDEFINED, VariableType.NULL); + + expect( fn({name : 'John', age: 20}) ).toBe(true); + expect( fn(undefined) ).toBe(true); + expect( fn(null) ).toBe(true); + expect( fn(123) ).toBe(true); + + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + + }); + + it('can create a test function for DTOs, string, number, undefined, and null', () => { + + const item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createTestFunctionOfDTOorOneOf( + VariableType.STRING, + VariableType.NUMBER, + VariableType.UNDEFINED, + VariableType.NULL, + ); + + expect( fn({name : 'John', age: 20}) ).toBe(true); + expect( fn(undefined) ).toBe(true); + expect( fn(null) ).toBe(true); + expect( fn(123) ).toBe(true); + expect( fn("hello world") ).toBe(true); + + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + + }); + + }); + + describe('.createExplainFunctionOfDTOorOneOf', () => { + + it('can create an explain function for DTOs or undefined', () => { + + const item = ( + EntityFactoryImpl.create('MyEntity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + + expect( fn({name : 'John', age: 20}) ).toBe('OK'); + expect( fn(undefined) ).toBe('OK'); + + expect( fn({name : 'John', age: null}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn({name : 123, age: 30}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn({age: 30}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn({name : 123}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn(123) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn(null) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn({}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn([]) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn(true) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn(false) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + expect( fn("hello world") ).toBe(`not one of:\n - DTO of MyEntity\n - undefined`); + + }); + + it('can create an explain function for DTOs, undefined, and null', () => { + + const item = ( + EntityFactoryImpl.create('MyEntity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED, VariableType.NULL); + + expect( fn({name : 'John', age: 20}) ).toBe('OK'); + expect( fn(undefined) ).toBe('OK'); + expect( fn(null) ).toBe('OK'); + + expect( fn({name : 'John', age: null}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn({name : 123, age: 30}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn({age: 30}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn({name : 123}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn(123) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn({}) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn([]) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn(true) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn(false) ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + expect( fn("hello world") ).toBe(`not one of:\n - DTO of MyEntity\n - undefined\n - null`); + + }); + + it('can create an explain function for DTOs, number, undefined, and null', () => { + + const item = ( + EntityFactoryImpl.create('MyEntity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createExplainFunctionOfDTOorOneOf(VariableType.NUMBER, VariableType.UNDEFINED, VariableType.NULL); + + expect( fn({name : 'John', age: 20}) ).toBe('OK'); + expect( fn(undefined) ).toBe('OK'); + expect( fn(null) ).toBe('OK'); + expect( fn(123) ).toBe('OK'); + + expect( fn({name : 'John', age: null}) ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + expect( fn({name : 123, age: 30}) ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + expect( fn({age: 30}) ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + expect( fn({name : 123}) ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + expect( fn({}) ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + expect( fn([]) ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + expect( fn(true) ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + expect( fn(false) ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + expect( fn("hello world") ).toBe(`not one of:\n - DTO of MyEntity\n - number\n - undefined\n - null`); + + }); + + it('can create an explain function for DTOs, string, number, undefined, and null', () => { + + const item : EntityFactoryImpl> = ( + EntityFactoryImpl.create('MyEntity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + const fn = item.createExplainFunctionOfDTOorOneOf( + VariableType.STRING, + VariableType.NUMBER, + VariableType.UNDEFINED, + VariableType.NULL, + ); + + expect( fn({name : 'John', age: 20}) ).toBe('OK'); + expect( fn(undefined) ).toBe('OK'); + expect( fn(null) ).toBe('OK'); + expect( fn(123) ).toBe('OK'); + expect( fn("hello world") ).toBe('OK'); + + expect( fn({name : 'John', age: null}) ).toBe(`not one of:\n - DTO of MyEntity\n - string\n - number\n - undefined\n - null`); + expect( fn({name : 123, age: 30}) ).toBe(`not one of:\n - DTO of MyEntity\n - string\n - number\n - undefined\n - null`); + expect( fn({age: 30}) ).toBe(`not one of:\n - DTO of MyEntity\n - string\n - number\n - undefined\n - null`); + expect( fn({name : 123}) ).toBe(`not one of:\n - DTO of MyEntity\n - string\n - number\n - undefined\n - null`); + expect( fn({}) ).toBe(`not one of:\n - DTO of MyEntity\n - string\n - number\n - undefined\n - null`); + expect( fn([]) ).toBe(`not one of:\n - DTO of MyEntity\n - string\n - number\n - undefined\n - null`); + expect( fn(true) ).toBe(`not one of:\n - DTO of MyEntity\n - string\n - number\n - undefined\n - null`); + expect( fn(false) ).toBe(`not one of:\n - DTO of MyEntity\n - string\n - number\n - undefined\n - null`); + + }); + + }); + + describe('.createTestFunctionOfInterface', () => { + + let item : EntityFactoryImpl; + let fn : any; + + beforeEach(() => { + + item = ( + EntityFactoryImpl.create('Entity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + + fn = item.createTestFunctionOfInterface(); + + }); + + it('can create a test function for valid interfaces', () => { + expect( fn({ + getEntityType () { return {}; }, + getDTO () { return {}; }, + valueOf () { return {}; }, + toJSON () { return {}; }, + + getAge () { return 30; }, + setAge (age: string) { return this; }, + age (age: string) { return this; }, + + getName () { return 'John'; }, + setName (name: string) { return this; }, + name (name: string) { return this; }, + + }) ).toBe(true); + }); + + it('can create a test function for interfaces which detects missing property methods', () => { + expect( fn({ + getEntityType () { return {}; }, + getDTO () { return {}; }, + valueOf () { return {}; }, + toJSON () { return {}; }, + + getAge () { return 30; }, + setAge (age: string) { return this; }, + age (age: string) { return this; }, + + getName () { return 'John'; }, + // Missing setName + name (name: string) { return this; }, + + }) ).toBe(false); + }); + + it('can create a test function for interfaces which detects missing base methods', () => { + expect( fn({ + + // Missing getEntityType, getDTO, valueOf and toJSON + + getAge () { return 30; }, + setAge (age: string) { return this; }, + age (age: string) { return this; }, + + getName () { return 'John'; }, + setName (name: string) { return this; }, + name (name: string) { return this; }, + + }) ).toBe(false); + }); + + it('can create a test function for invalid interfaces', () => { + expect( fn({ + + getEntityType : null, + getDTO () { return {}; }, + valueOf () { return {}; }, + toJSON () { return {}; }, + + getAge () { return 30; }, + setAge (age: string) { return this; }, + age (age: string) { return this; }, + + getName () { return 'John'; }, + setName (name: string) { return this; }, + name (name: string) { return this; }, + + }) ).toBe(false); + }); + + it('can create a test function for interfaces which detects invalid values', () => { + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + }); + + describe('.createEntityType', () => { + + interface MyDTO extends DTO { + readonly age : number; + readonly name : string; + } + + interface MyEntity extends Entity { + getAge () : number; + getName () : string; + setAge (age: number) : this; + setName (name: string) : this; + age (age: number) : this; + name (name: string) : this; + } + + interface MyReadonlyEntity extends Entity { + getAge () : number; + getName () : string; + } + + let factory : EntityFactoryImpl; + + beforeEach ( () => { + factory = ( + EntityFactoryImpl.create('MyEntity') + .add( EntityFactoryImpl.createProperty("age").setTypes(VariableType.INTEGER).setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING).setDefaultValue('Smith') ) + ); + }); + + describe('.getName and .getAge', () => { + + it('can create an entity constructor with .getName and .getAge', () => { + const MyType = factory.createEntityType('MyType'); + const entity = MyType.create(); + expect( entity.getName() ).toBe('Smith'); + expect( entity.getAge() ).toBe(30); + }); + + it('can create an entity constructor with readonly entities', () => { + const ReadonlyEntityType = factory.createEntityType( + 'ReadonlyEntityType', + { + immutable: true + }); + const entity = ReadonlyEntityType.create(); + expect( entity.getName() ).toBe('Smith'); + expect( entity.getAge() ).toBe(30); + expect( entity.setName ).toBe(undefined); + expect( entity.setAge ).toBe(undefined); + }); + + }); + + describe('.setName and .setAge', () => { + + it('can create an entity constructor with writable entities with .setName and .setAge', () => { + const EntityType = factory.createEntityType('EntityType'); + const entity = EntityType.create(); + expect( entity.getName() ).toBe('Smith'); + expect( entity.getAge() ).toBe(30); + entity.setName('Alice').setAge(18); + expect( entity.getName() ).toBe('Alice'); + expect( entity.getAge() ).toBe(18); + }); + + }); + + describe('.name and .age', () => { + + it('can create an entity constructor with writable entities with .name and .age', () => { + const EntityType = factory.createEntityType('EntityType'); + const entity = EntityType.create(); + expect( entity.getName() ).toBe('Smith'); + expect( entity.getAge() ).toBe(30); + entity.name('Alice').age(18); + expect( entity.getName() ).toBe('Alice'); + expect( entity.getAge() ).toBe(18); + }); + + }); + + describe('.getDTO', () => { + + it('can create an entity constructor with .getDTO', () => { + const EntityType = factory.createEntityType('EntityType'); + const entity = EntityType.create(); + expect( entity.getDTO() ).toStrictEqual({name: 'Smith', age: 30}); + }); + + }); + + describe('.toJSON', () => { + + it('can create an entity constructor with .toJSON', () => { + const EntityType = factory.createEntityType('EntityType'); + const entity = EntityType.create(); + expect( entity.toJSON() ).toStrictEqual({name: 'Smith', age: 30}); + }); + + }); + + describe('.valueOf', () => { + + it('can create an entity constructor with .valueOf', () => { + const EntityType = factory.createEntityType('EntityType'); + const entity = EntityType.create(); + expect( entity.valueOf() ).toStrictEqual({name: 'Smith', age: 30}); + }); + + }); + + describe('#isDTO', () => { + + it('can create a test function for readonly DTOs', () => { + + const ReadonlyEntityType = factory.createEntityType( + 'ReadonlyEntityType', + { + immutable: true + }); + + expect( ReadonlyEntityType.isDTO({name : 'John', age: 30}) ).toBe(true); + expect( ReadonlyEntityType.isDTO({name : 'John', age: null}) ).toBe(false); + expect( ReadonlyEntityType.isDTO({name : 123, age: 30}) ).toBe(false); + expect( ReadonlyEntityType.isDTO({age: 30}) ).toBe(false); + expect( ReadonlyEntityType.isDTO({name : 123}) ).toBe(false); + expect( ReadonlyEntityType.isDTO(123) ).toBe(false); + expect( ReadonlyEntityType.isDTO(null) ).toBe(false); + expect( ReadonlyEntityType.isDTO(undefined) ).toBe(false); + expect( ReadonlyEntityType.isDTO({}) ).toBe(false); + expect( ReadonlyEntityType.isDTO([]) ).toBe(false); + expect( ReadonlyEntityType.isDTO(true) ).toBe(false); + expect( ReadonlyEntityType.isDTO(false) ).toBe(false); + expect( ReadonlyEntityType.isDTO("hello world") ).toBe(false); + + }); + + it('can create a test function for DTOs', () => { + + const EntityType = factory.createEntityType('EntityType'); + + expect( EntityType.isDTO({name : 'John', age: 30}) ).toBe(true); + expect( EntityType.isDTO({name : 'John', age: null}) ).toBe(false); + expect( EntityType.isDTO({name : 123, age: 30}) ).toBe(false); + expect( EntityType.isDTO({age: 30}) ).toBe(false); + expect( EntityType.isDTO({name : 123}) ).toBe(false); + expect( EntityType.isDTO(123) ).toBe(false); + expect( EntityType.isDTO(null) ).toBe(false); + expect( EntityType.isDTO(undefined) ).toBe(false); + expect( EntityType.isDTO({}) ).toBe(false); + expect( EntityType.isDTO([]) ).toBe(false); + expect( EntityType.isDTO(true) ).toBe(false); + expect( EntityType.isDTO(false) ).toBe(false); + expect( EntityType.isDTO("hello world") ).toBe(false); + + }); + + }); + + describe('#is', () => { + + + it('can create a test function for readonly entities', () => { + + const ReadonlyEntityType = factory.createEntityType( + 'ReadonlyEntityType', + { + immutable: true + }); + const entity = ReadonlyEntityType.create(); + + expect( ReadonlyEntityType.isEntity(entity) ).toBe(true); + expect( ReadonlyEntityType.isEntity({name : 'John', age: 30}) ).toBe(false); + expect( ReadonlyEntityType.isEntity({name : 'John', age: null}) ).toBe(false); + expect( ReadonlyEntityType.isEntity({name : 123, age: 30}) ).toBe(false); + expect( ReadonlyEntityType.isEntity({age: 30}) ).toBe(false); + expect( ReadonlyEntityType.isEntity({name : 123}) ).toBe(false); + expect( ReadonlyEntityType.isEntity(123) ).toBe(false); + expect( ReadonlyEntityType.isEntity(null) ).toBe(false); + expect( ReadonlyEntityType.isEntity(undefined) ).toBe(false); + expect( ReadonlyEntityType.isEntity({}) ).toBe(false); + expect( ReadonlyEntityType.isEntity([]) ).toBe(false); + expect( ReadonlyEntityType.isEntity(true) ).toBe(false); + expect( ReadonlyEntityType.isEntity(false) ).toBe(false); + expect( ReadonlyEntityType.isEntity("hello world") ).toBe(false); + + }); + + it('can create a test function for entities', () => { + + const EntityType = factory.createEntityType('EntityType'); + const entity = EntityType.create(); + + expect( EntityType.isEntity(entity) ).toBe(true); + expect( EntityType.isEntity({name : 'John', age: 30}) ).toBe(false); + expect( EntityType.isEntity({name : 'John', age: null}) ).toBe(false); + expect( EntityType.isEntity({name : 123, age: 30}) ).toBe(false); + expect( EntityType.isEntity({age: 30}) ).toBe(false); + expect( EntityType.isEntity({name : 123}) ).toBe(false); + expect( EntityType.isEntity(123) ).toBe(false); + expect( EntityType.isEntity(null) ).toBe(false); + expect( EntityType.isEntity(undefined) ).toBe(false); + expect( EntityType.isEntity({}) ).toBe(false); + expect( EntityType.isEntity([]) ).toBe(false); + expect( EntityType.isEntity(true) ).toBe(false); + expect( EntityType.isEntity(false) ).toBe(false); + expect( EntityType.isEntity("hello world") ).toBe(false); + + }); + + }); + + describe('#createFromDTO', () => { + + it('can create an entity from DTO', () => { + const EntityType = factory.createEntityType('EntityType'); + const entity = EntityType.createFromDTO({name:'Jack', age:38}); + expect( entity.getName() ).toBe('Jack'); + expect( entity.getAge() ).toBe(38); + }); + + }); + + describe('#getProperties', () => { + + it('can get entity properties', () => { + const EntityType = factory.createEntityType('EntityType'); + const entity = EntityType.getProperties(); + expect( entity.length ).toBe(2); + expect( entity[0].getPropertyName()).toBe('age'); + expect( entity[1].getPropertyName()).toBe('name'); + }); + + }); + + + }); + + describe('with inner entities', () => { + + enum GearType { + AUTOMATIC = "AUTOMATIC", + MANUAL = "MANUAL" + } + + interface CarDTO extends DTO { + readonly model: string; + readonly gear: GearType; + } + + interface Car extends Entity { + getModel() : string; + setModel(model: string) : this; + getGear() : GearType; + setGear(model: GearType) : this; + } + + interface DriverDTO extends DTO { + readonly name : string; + readonly age : number; + readonly car : CarDTO; + } + + interface Driver extends Entity { + getCar() : Car; + getCarDTO() : CarDTO; + setCar(value: Car) : this; + setCarDTO(value: CarDTO) : this; + } + + let carFactory : EntityFactoryImpl; + let CarType : EntityType; + let driverFactory : EntityFactoryImpl; + let DriverType : EntityType; + + beforeEach(() => { + + carFactory = ( + EntityFactoryImpl.create('Car') + .add( EntityFactoryImpl.createProperty("model").setDefaultValue("Ford") ) + .add( EntityFactoryImpl.createProperty("gear").setTypes(GearType).setDefaultValue(GearType.MANUAL) ) + ); + CarType = carFactory.createEntityType('CarType'); + + driverFactory = ( + EntityFactoryImpl.create('Driver') + .add( EntityFactoryImpl.createProperty("age").setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setDefaultValue('Smith') ) + .add( EntityFactoryImpl.createProperty("car").setTypes(CarType) ) + ); + DriverType = driverFactory.createEntityType('DriverType'); + + }); + + describe('.createDefaultDTO', () => { + + it('can create a default DTO object with an inner entity value', () => { + expect( driverFactory.createDefaultDTO() ).toStrictEqual({ + name : 'Smith', + age: 30, + car: {model: "Ford", gear: "MANUAL"} + }); + }); + + }); + + describe('.create', () => { + + it('can create a entity with DTO getters for an inner entity property .getCar()', () => { + const driver : Driver = DriverType.create(); + expect( driver.getCar().getDTO() ).toStrictEqual({model: "Ford", gear: "MANUAL"}); + }); + + it('can create a entity with DTO getters for an inner entity property .getCarDTO()', () => { + const driver : Driver = DriverType.create(); + expect( driver.getCarDTO() ).toStrictEqual({model: "Ford", gear: "MANUAL"}); + }); + + it('can create a entity with DTO getters for an inner entity property .setCar()', () => { + const driver : Driver = DriverType.create(); + expect( driver.setCar( CarType.create().setModel('Tesla') ) ).toBe(driver); + expect( driver.getCar().getDTO() ).toStrictEqual({model: "Tesla", gear: "MANUAL"}); + }); + + it('can create a entity with DTO getters for an inner entity property .setCarDTO()', () => { + const driver : Driver = DriverType.create(); + expect( driver.setCar( CarType.create().setModel('Tesla') ) ).toBe(driver); + expect( driver.getCarDTO() ).toStrictEqual({ + model: "Tesla", + gear: "MANUAL", + }); + }); + + it('can create an type which can be used as a base class', () => { + + class MyDriver extends DriverType { + + public static create () : MyDriver { + return new MyDriver(); + } + + getFoo () : string { + return 'hello world' + } + + } + + const driver : MyDriver = MyDriver.create(); + + expect( driver.getFoo() ).toBe('hello world'); + expect( driver.setCar( CarType.create().setModel('Tesla') ) ).toBe(driver); + expect( driver.getCarDTO() ).toStrictEqual({ + model: "Tesla", + gear: "MANUAL", + }); + }); + + }); + + describe('.getDTO', () => { + + it('can create inner DTO with .getDTO', () => { + const entity = DriverType.create(); + expect( entity.getDTO() ).toStrictEqual({ + age: 30, + name: 'Smith', + car: { + gear: 'MANUAL', + model: 'Ford' + } + }); + }); + + }); + + + }); + + describe('with inner array entities', () => { + + interface CarDTO extends DTO { + readonly model: string; + } + + interface Car extends Entity { + getModel() : string; + setModel(model: string) : this; + } + + interface DriverDTO extends DTO { + readonly name : string; + readonly age : number; + readonly cars : readonly CarDTO[]; + readonly lendCars ?: readonly CarDTO[]; + } + + interface Driver extends Entity { + + getCars() : readonly Car[]; + getCarsDTO() : readonly CarDTO[]; + setCars(value: readonly (Car | CarDTO)[]) : this; + setCarsDTO(value: readonly CarDTO[]) : this; + + getLendCars() : readonly Car[] | undefined; + getLendCarsDTO() : readonly CarDTO[] | undefined; + setLendCars(value: readonly (Car | CarDTO)[] | undefined) : this; + setLendCarsDTO(value: readonly CarDTO[] | undefined) : this; + + } + + let carFactory : EntityFactoryImpl; + let CarType : EntityType; + let driverFactory : EntityFactoryImpl; + let DriverType : EntityType; + + beforeEach(() => { + + carFactory = ( + EntityFactoryImpl.create('Car') + .add( EntityFactoryImpl.createProperty("model").setDefaultValue("Ford") ) + ); + CarType = carFactory.createEntityType('CarType'); + + driverFactory = ( + EntityFactoryImpl.create('Driver') + .add( EntityFactoryImpl.createProperty("age").setDefaultValue(30) ) + .add( EntityFactoryImpl.createProperty("name").setDefaultValue('Smith') ) + .add( EntityFactoryImpl.createArrayProperty("cars").setTypes(CarType) ) + .add( EntityFactoryImpl.createOptionalArrayProperty("lendCars").setTypes(CarType) ) + ); + DriverType = driverFactory.createEntityType('DriverType'); + + }); + + describe('.createDefaultDTO', () => { + + it('can create a default DTO object with an inner entity value', () => { + expect( driverFactory.createDefaultDTO() ).toStrictEqual({ + name : 'Smith', + age: 30, + cars: [] + }); + }); + + }); + + describe('.create', () => { + + let tesla : Car; + let teslaDTO : CarDTO; + + beforeEach(() => { + tesla = CarType.create().setModel('Tesla'); + teslaDTO = tesla.getDTO(); + }); + + it('can create a empty entity and has .getCars() as [] by default', () => { + const driver : Driver = DriverType.create(); + expect( driver.getCars()?.length ).toStrictEqual(0); + expect( driver.getCars() ).toStrictEqual([]); + }); + + it('can create a empty entity and has .getCarDTO() as [] by default', () => { + const driver : Driver = DriverType.create(); + expect( driver.getCarsDTO()?.length ).toStrictEqual(0); + expect( driver.getCarsDTO() ).toStrictEqual([]); + }); + + it('can create a empty entity and has .getLendCars() as undefined by default', () => { + const driver : Driver = DriverType.create(); + expect( driver.getLendCars() ).toStrictEqual(undefined); + }); + + it('can create a empty entity and has .getLendCarsDTO() as undefined by default', () => { + const driver : Driver = DriverType.create(); + expect( driver.getLendCarsDTO() ).toStrictEqual(undefined); + }); + + it('can create an entity with .setCars( [Entity] ) and has .getCars()', () => { + const driver : Driver = DriverType.create(); + expect( driver.setCars( [tesla] ) ).toBe(driver); + + expect( driver.getCars()?.length ).toStrictEqual(1); + expect( driver.getCars()[0].getDTO() ).toStrictEqual({model: "Tesla"}); + }); + + it('can create an entity with .setCars( [Entity] ) and has .getCarsDTO()', () => { + const driver : Driver = DriverType.create(); + expect( driver.setCars( [tesla] ) ).toBe(driver); + + expect( driver.getCarsDTO()?.length ).toStrictEqual(1); + expect( driver.getCarsDTO()[0] ).toStrictEqual({model: "Tesla"}); + }); + + it('can create an entity with .setCars( [DTO] ) and has .getCars()', () => { + const driver : Driver = DriverType.create(); + expect( driver.setCars( [teslaDTO] ) ).toBe(driver); + + expect( driver.getCars()?.length ).toStrictEqual(1); + expect( driver.getCars()[0].getDTO() ).toStrictEqual({model: "Tesla"}); + }); + + }); + + }); + + describe('Getters and setters', () => { + + interface CarDTO extends DTO { + readonly model: string; + } + + interface Car extends Entity { + getModel() : string; + setModel(model: string) : this; + } + + let carFactory : EntityFactoryImpl; + let CarEntity : EntityType; + + interface TestDTO { + readonly name : string; + readonly age : number; + readonly alive : boolean; + readonly notDefined ?: undefined; + readonly undefinedValue : undefined; + readonly nullValue : null; + readonly car : CarDTO; + readonly secondCar ?: CarDTO; + readonly thirdCar ?: CarDTO; + readonly fourthCar ?: CarDTO | null; + readonly cars : CarDTO[]; + } + + class TestEntity extends BaseEntity { + + public static create () : TestEntity { + return new TestEntity({ + name: 'test', + age: 30, + alive: true, + nullValue: null, + undefinedValue: undefined, + car: CarEntity.create().getDTO(), + secondCar: CarEntity.create().setModel('Audi').getDTO(), + fourthCar: null, + cars: [], + }); + } + + public static createFromDTO (dto: TestDTO) : TestEntity { + return new TestEntity(dto); + } + + public static getProperties () : readonly EntityProperty[] { + return []; + } + + public static isEntity (value: unknown) : value is Entity { + return value instanceof TestEntity; + } + + public static isDTO (value: unknown) : value is TestDTO { + return false; + } + + public getEntityType () : EntityType { + return TestEntity as unknown as EntityType; + } + + } + + beforeEach(() => { + carFactory = ( + EntityFactoryImpl.create('Car') + .add( EntityFactoryImpl.createProperty("model").setDefaultValue("Ford") ) + ); + CarEntity = carFactory.createEntityType('CarEntity'); + }); + + describe('#createPropertyGetter', () => { + + it('can create string getter', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'name', + [VariableType.STRING] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toBe('test'); + }); + + it('can create number getter', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'age', + [VariableType.NUMBER] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toBe(30); + }); + + it('can create integer getter', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'age', + [VariableType.INTEGER] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toBe(30); + }); + + it('can create boolean getter', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'alive', + [VariableType.BOOLEAN] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toBe(true); + }); + + it('can create undefined getter with non-defined property', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'notDefined', + [VariableType.UNDEFINED] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toBe(undefined); + }); + + it('can create undefined getter with undefined value', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'undefinedValue', + [VariableType.UNDEFINED] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toBe(undefined); + }); + + it('can create null getter', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'nullValue', + [VariableType.NULL] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toBe(null); + }); + + it('can create a car getter', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'car', + [CarEntity] + ); + const entity = TestEntity.create(); + expect( fn.call(entity).getDTO() ).toStrictEqual({ + model: 'Ford' + }); + }); + + it('can create a car or undefined getter with a value', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'secondCar', + [ + CarEntity, + VariableType.UNDEFINED, + ] + ); + const entity = TestEntity.create(); + expect( fn.call(entity).getDTO() ).toStrictEqual({ + model: 'Audi' + }); + }); + + it('can create a car or undefined getter with an non-defined value', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'thirdCar', + [ + CarEntity, + VariableType.UNDEFINED, + ] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toStrictEqual(undefined); + }); + + it('can create a car or undefined getter with an null value', () => { + const fn = EntityFactoryImpl.createPropertyGetter( + 'fourthCar', + [ + CarEntity, + VariableType.UNDEFINED, + ] + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toStrictEqual(null); + }); + + it('can create property getter for arrays', () => { + const fn = EntityFactoryImpl.createArrayPropertyGetter( + 'cars', + [ + CarEntity, + VariableType.UNDEFINED, + ], + ); + const entity = TestEntity.create(); + expect( fn.call(entity) ).toStrictEqual([]); + }); + + }); + + describe('#createPropertySetter', () => { + + it('can create string setter', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'name', + [VariableType.STRING] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, 'New value') ).toBe(entity); + expect( entity.getDTO().name ).toBe('New value'); + }); + + it('can create number setter', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'age', + [VariableType.NUMBER] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, 12.5) ).toBe(entity); + expect( entity.getDTO().age ).toBe(12.5); + }); + + it('can create integer setter', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'age', + [VariableType.INTEGER] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, 12) ).toBe(entity); + expect( entity.getDTO().age ).toBe(12); + }); + + it('can create boolean setter', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'alive', + [VariableType.BOOLEAN] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, true) ).toBe(entity); + expect( entity.getDTO().alive ).toBe(true); + expect( fn.call(entity, false) ).toBe(entity); + expect( entity.getDTO().alive ).toBe(false); + }); + + it('can create undefined setter with non-defined property', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'notDefined', + [VariableType.UNDEFINED] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, undefined) ).toBe(entity); + expect( entity.getDTO().notDefined ).toBe(undefined); + }); + + it('can create undefined setter with undefined value', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'undefinedValue', + [VariableType.UNDEFINED] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, undefined) ).toBe(entity); + expect( entity.getDTO().undefinedValue ).toBe(undefined); + }); + + it('can create null setter', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'nullValue', + [VariableType.NULL] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, null) ).toBe(entity); + expect( entity.getDTO().nullValue ).toBe(null); + }); + + it('can create a car setter', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'car', + [CarEntity] + ); + const entity = TestEntity.create(); + const tesla = CarEntity.create().setModel('Tesla'); + expect( fn.call(entity, tesla) ).toBe(entity); + expect( entity.getDTO().car ).toStrictEqual({ + model: 'Tesla' + }); + }); + + it('can create a car or undefined setter with a value', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'secondCar', + [ + CarEntity, + VariableType.UNDEFINED, + ] + ); + const entity = TestEntity.create(); + + expect( fn.call(entity, undefined) ).toBe(entity); + expect( entity.getDTO().secondCar ).toStrictEqual(undefined); + + expect( fn.call(entity, CarEntity.create().setModel('Tesla')) ).toBe(entity); + expect( entity.getDTO().secondCar ).toStrictEqual({'model': 'Tesla'}); + + }); + + it('can create a car or undefined setter with an non-defined value', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'thirdCar', + [ + CarEntity, + VariableType.UNDEFINED, + ] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, undefined) ).toBe(entity); + expect( entity.getDTO().thirdCar ).toBe(undefined); + expect( fn.call(entity, CarEntity.create().setModel('Tesla')) ).toBe(entity); + expect( entity.getDTO().thirdCar ).toStrictEqual({'model': 'Tesla'}); + }); + + it('can create a car or undefined setter with an null value', () => { + const fn = EntityFactoryImpl.createPropertySetter( + 'fourthCar', + [ + CarEntity, + VariableType.UNDEFINED, + ] + ); + const entity = TestEntity.create(); + expect( fn.call(entity, undefined) ).toStrictEqual(entity); + expect( entity.getDTO().fourthCar ).toBe(undefined); + expect( fn.call(entity, CarEntity.create().setModel('Tesla')) ).toBe(entity); + expect( entity.getDTO().fourthCar ).toStrictEqual({'model': 'Tesla'}); + }); + + }); + + }); + + describe('Static method definitions', () => { + + let entity : EntityFactoryImpl; + let method : EntityMethodImpl; + + beforeEach(() => { + entity = EntityFactoryImpl.create('Entity'); + method = EntityMethodImpl.create('create').addArgument(VariableType.STRING, VariableType.NUMBER).returnType(VariableType.STRING); + }); + + describe('.addStaticMethod', () => { + + it('can add static method', () => { + expect( () => entity.addStaticMethod(method) ).not.toThrow(); + }); + + }); + + describe('.getStaticMethods', () => { + + beforeEach(() => { + entity.addStaticMethod(method); + }); + + it('can read static method added', () => { + expect( entity.getStaticMethods() ).toEqual( + expect.arrayContaining([ + method + ]) + ); + }); + + }); + + }); + +}); diff --git a/entities/types/EntityFactoryImpl.ts b/entities/types/EntityFactoryImpl.ts new file mode 100644 index 0000000..1996b23 --- /dev/null +++ b/entities/types/EntityFactoryImpl.ts @@ -0,0 +1,1832 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { filter } from "../../functions/filter"; +import { forEach } from "../../functions/forEach"; +import { has } from "../../functions/has"; +import { map } from "../../functions/map"; +import { reduce } from "../../functions/reduce"; +import { some } from "../../functions/some"; +import { uniq } from "../../functions/uniq"; +import { upperFirst } from "../../functions/upperFirst"; +import { + isReadonlyJsonAny, + isReadonlyJsonArray, + isReadonlyJsonObject, + ReadonlyJsonAny, + ReadonlyJsonObject, +} from "../../Json"; +import { LogService } from "../../LogService"; +import { LogUtils } from "../../LogUtils"; +import { + isArray, + isArrayOf, + isArrayOfOrUndefined, + isArrayOrUndefined, +} from "../../types/Array"; +import { + isBoolean, + isBooleanOrUndefined, +} from "../../types/Boolean"; +import { + Enum, + EnumType, + isEnumType, +} from "../../types/Enum"; +import { + explain, + explainNot, + explainOk, + explainOneOf, + explainProperty, +} from "../../types/explain"; +import { isFunction } from "../../types/Function"; +import { + isNumber, + isNumberOrUndefined, +} from "../../types/Number"; +import { + isObject, + isObjectOrUndefined, +} from "../../types/Object"; +import { + explainNoOtherKeysInDevelopment, + hasNoOtherKeysInDevelopment, +} from "../../types/OtherKeys"; +import { + explainRegularObject, + isRegularObject, + isRegularObjectOrUndefined, +} from "../../types/RegularObject"; +import { + isString, + isStringOrNumber, + isStringOrUndefined, +} from "../../types/String"; +import { + TestCallback, +} from "../../types/TestCallback"; +import { BaseEntity } from "./BaseEntity"; +import { ChainOperation } from "./ChainOperation"; +import { DTO } from "./DTO"; +import { + Entity, + isEntity, +} from "./Entity"; +import { + ArrayMapMethod, + CreateEntityTypeOpts, + EntityFactory, + GetterMethod, + MethodTypeCheckFn, + PropertyTypeCheckFn, + SetterMethod, + TypeCheckFn, +} from "./EntityFactory"; +import { + EntityMethod, + EntityMethodType, +} from "./EntityMethod"; +import { EntityMethodImpl } from "./EntityMethodImpl"; +import { EntityProperty } from "./EntityProperty"; +import { EntityPropertyImpl } from "./EntityPropertyImpl"; +import { + EntityType, + isEntityType, +} from "./EntityType"; +import { EntityTypeCheckFactory } from "./EntityTypeCheckFactory"; +import { EntityTypeCheckFactoryImpl } from "./EntityTypeCheckFactoryImpl"; +import { EntityTypeRegistry } from "./EntityTypeRegistry"; +import { EntityTypeRegistryImpl } from "./EntityTypeRegistryImpl"; +import { + EntityVariableType, + EntityVariableValue, +} from "./EntityVariableType"; +import { + IsDTOExplainFunction, + IsDTOOrTestFunction, + IsDTOTestFunction, + IsInterfaceOrTestFunction, + IsInterfaceTestFunction, +} from "./IsDTOTestFunction"; +import { TypeCheckFunctionUtils } from "./TypeCheckFunctionUtils"; +import { + isVariableType, + VariableType, +} from "./VariableType"; + +const LOG = LogService.createLogger( 'EntityFactoryImpl' ); + +export interface PropertyGetterOptions { + readonly entityAsDTO ?: boolean; + readonly isOptional ?: boolean; +} + +/** + * @inheritDoc + */ +export class EntityFactoryImpl< + D extends DTO, + T extends Entity, +> + implements EntityFactory { + + + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////// static private globals /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + private static _entityFactories : { + [key: string]: EntityFactory> + } = {}; + + + private static _entities : EntityTypeRegistry = EntityTypeRegistryImpl.create(); + private static _typeCheckFactory : EntityTypeCheckFactory = EntityTypeCheckFactoryImpl.create(this._entities); + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// #createProperty ////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * + * @param name + * @param aliases + */ + public static createProperty ( + name : string, + ...aliases : string[] + ): EntityProperty { + return EntityPropertyImpl.create( + this._typeCheckFactory, + name, + ...aliases + ); + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// #createMethod ////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * + * @param name + */ + public static createMethod ( + name : string + ): EntityMethod { + return EntityMethodImpl.create(name); + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////// #createArrayProperty /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * + * @param name + */ + public static createArrayProperty ( + name : string + ): EntityProperty { + return EntityPropertyImpl.createArray(this._typeCheckFactory, name); + } + + + /** + * + * @param name + */ + public static createOptionalArrayProperty ( + name : string + ): EntityProperty { + return EntityPropertyImpl.createOptionalArray(this._typeCheckFactory, name); + } + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////////// #create ///////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Create an entity factory. + */ + public static create< + D extends DTO, + T extends Entity, + > ( + name : string, + ) : EntityFactoryImpl { + if (has(this._entityFactories, name)) { + throw new TypeError(`EntityFactoryImpl.create(): Factory exists by name: ${name}`); + } + const factory = new EntityFactoryImpl(name); + this._entityFactories[name] = factory; + return factory; + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////////// #deleteFactory ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Unregister an entity factory by name. + */ + public static deleteFactory (name : string) : typeof EntityFactoryImpl { + if ( has( this._entityFactories, name ) ) { + delete this._entityFactories[name]; + } + return this; + } + + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////////// #destroy //////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Destroy global state. + */ + public static destroy () : typeof EntityFactoryImpl { + this._entityFactories = {}; + this._entities.destroy(); + return this; + } + + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// #createPropertyGetter ///////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * + * @param propertyName + * @param types + * @param opts + */ + public static createPropertyGetter< + D extends DTO, + T extends BaseEntity, + > ( + propertyName : string, + types : readonly EntityVariableType[], + opts ?: PropertyGetterOptions | undefined, + ) : GetterMethod { + const entityAsDTO = !!opts?.entityAsDTO; + return reduce( + types, + ( + prev: GetterMethod | undefined, + item: EntityVariableType + ) : GetterMethod => { + + if ( isString(item) && !isVariableType(item) ) { + const Type = this._entities.findType(item); + if ( Type ) { + item = Type; + } else { + throw new TypeError(`EntityFactoryImpl.createPropertyGetter(): Could not initialize entity by name: ${item}`); + } + } + + let fn : GetterMethod; + if ( isEntityType(item) && !entityAsDTO ) { + fn = this.createEntityPropertyGetter( + propertyName, + item, + ); + } else if (isEnumType(item, isStringOrNumber)) { + fn = this.createEnumPropertyGetter( + propertyName, + item, + ); + } else if ( isVariableType(item) || entityAsDTO ) { + fn = this.createScalarPropertyGetter( + propertyName, + item, + ); + } else { + throw new TypeError(`createPropertyGetter(): Unsupported type: ${item}`); + } + + if (prev === undefined) return fn; + + return function ( + this: T, + ) : any { + return prev.call(this) ?? fn.call(this); + }; + }, + undefined + ) ?? function (this: T) : any { return undefined; }; + } + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////// #createArrayPropertyGetter /////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * + * @param propertyName + * @param types + * @param opts + */ + public static createArrayPropertyGetter< + D extends DTO, + T extends BaseEntity, + > ( + propertyName : string, + types : readonly EntityVariableType[], + opts ?: PropertyGetterOptions | undefined, + ) : GetterMethod { + + const entityAsDTO = !!opts?.entityAsDTO; + const entityIsOptional = !!opts?.isOptional; + + if (!types.length) { + throw new TypeError(`EntityFactoryImpl.createArrayPropertyGetter(): There must be at least one type defined`); + } + + const iterator: ArrayMapMethod | undefined = reduce( + types, + ( + prev: ArrayMapMethod | undefined, + item: EntityVariableType, + ) : ArrayMapMethod => { + + if ( isString(item) && !isVariableType(item) ) { + const Type = this._entities.findType(item); + if ( Type ) { + item = Type; + } else { + throw new TypeError(`EntityFactoryImpl.createArrayPropertyGetter(): Could not initialize entity by name: ${item}`); + } + } + + const fn = this.createArrayItemGetter( + item, + { entityAsDTO } + ); + + if (prev === undefined) { + return fn; + } + + return ( + item : any, + ) : any | undefined => prev(item) ?? fn(item); + + }, + undefined + ); + + if (!iterator) { + throw new TypeError(`EntityFactoryImpl.createArrayPropertyGetter(): Could not create array iterator`); + } + + if ( entityIsOptional ) { + return function optionalArrayGetterMethod ( + this: T, + ) : EntityVariableValue | null | undefined { + const list = this._getPropertyValue(propertyName); + if (isArrayOrUndefined(list)) { + return list ? map(list, (item) => iterator(item)) : undefined; + } else { + return null; + } + }; + } + + return function arrayGetterMethod ( + this: T, + ) : EntityVariableValue | null | undefined { + const list = this._getPropertyValue(propertyName); + if (isArrayOrUndefined(list)) { + return list ? map(list, (item) => iterator(item)) : []; + } else { + return null; + } + }; + + } + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////// #createScalarPropertyGetter //////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Implementation. + * + * @param propertyName + * @param type + */ + public static createScalarPropertyGetter< + D extends DTO, + T extends BaseEntity, + > ( + propertyName : string, + type: EntityType | VariableType, + ): GetterMethod { + return function scalarGetterMethod ( + this: T, + ) : any { + return this._getPropertyValue(propertyName); + }; + } + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////// #createScalarPropertyGetter //////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Implementation. + * + * @param propertyName + * @param type + */ + public static createEnumPropertyGetter< + D extends DTO, + T extends BaseEntity, + > ( + propertyName : string, + type: EnumType, + ): GetterMethod { + return function enumGetterMethod ( + this: T, + ) : any { + return this._getPropertyValue(propertyName); + }; + } + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////// #createEntityPropertyGetter //////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Implementation. + * + * @param propertyName + * @param type + */ + public static createEntityPropertyGetter< + D extends DTO, + T extends BaseEntity, + > ( + propertyName : string, + type: EntityType, + ): GetterMethod { + return function entityGetterMethod ( + this: T, + ) : Entity | undefined { + const dto = this._getPropertyValue(propertyName); + return dto ? type.createFromDTO(dto) : undefined; + }; + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////// #createArrayItemGetter ////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Implementation. + * + * @param Type + * @param opts + */ + public static createArrayItemGetter ( + Type: EntityType | EnumType | VariableType, + opts ?: PropertyGetterOptions | undefined, + ): ArrayMapMethod { + const entityAsDTO = !!opts?.entityAsDTO; + if ( isEntityType(Type) && !entityAsDTO ) { + return (item : EntityVariableValue) : EntityVariableValue => { + return Type.isDTO(item) ? Type.createFromDTO(item) : null; + }; + } + return (item : EntityVariableValue) : EntityVariableValue => item; + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////// #createPropertySetter /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + public static createPropertySetter< + D extends DTO, + T extends BaseEntity, + > ( + propertyName : string, + types : readonly EntityVariableType[] + ) : SetterMethod { + + const methodName = `set${upperFirst(propertyName)}`; + + try { + + const entityTypes : (EntityType> | string)[] = filter( + types, + (item: EntityVariableType) : boolean => isEntityType(item) || (isString(item) && !isVariableType(item)) + ) as (EntityType> | string)[]; + + const entityTypesOnly : EntityType>[] = uniq(map( + entityTypes, + ( item: string | EntityType> ) : EntityType> => { + if (isString(item)) { + const Type : EntityType> | undefined = this._entities.findType(item); + if (Type === undefined) { + throw new TypeError(`EntityFactoryImpl.createPropertySetter(${propertyName}): Could not find entity type for: ${item}`); + } + return Type; + } + return item; + } + )); + + /** + * These are entity types that can be delivered from other type using the create method (e.g. string, number, etc.). + */ + const deliverableEntityTypes: EntityVariableType[] = uniq(reduce( + entityTypesOnly, + (prev : EntityVariableType[], item : EntityType>) : EntityVariableType[] => { + + const staticMethods = item.getStaticMethods(); + + const simpleCreateMethods : EntityMethod[] = filter( + staticMethods, + (item: EntityMethod) : boolean => { + return item.getMethodName() === 'create' && item.getArguments().length === 1; + } + ); + + if (simpleCreateMethods.length) { + + const deliverableTypes : EntityMethodType[] = uniq(reduce( + simpleCreateMethods, + (prev : EntityMethodType[], item: EntityMethod) : EntityMethodType[] => { + const types = item.getArguments()[0]; + return [ + ...prev, + ...types, + ]; + }, + [] + )); + + return [ + ...prev, + ...deliverableTypes, + ]; + + } + + return prev; + + }, + [] + )); + + const otherTypes : readonly (Enum | VariableType)[] = filter( + types, + (item) => !(isEntityType(item) || (isString(item) && !isVariableType(item))) + ) as (Enum | VariableType)[]; + + type IsOurEntityCallback = (value: unknown) => value is Entity; + + const isOurEntity : IsOurEntityCallback | undefined = entityTypesOnly.length ? ( + this._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + entityTypesOnly, + false, + ) as IsOurEntityCallback + ) : undefined; + + type IsOurDTOCallback = (value: unknown) => value is DTO; + + const isOurDTO : IsOurDTOCallback | undefined = entityTypesOnly.length ? ( + this._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + entityTypesOnly, + true, + ) as IsOurEntityCallback + ) : undefined; + + const isOtherTypes = otherTypes.length ? this._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + otherTypes, + false, + ) : undefined; + + const isDeliverableEntity : IsOurEntityCallback | undefined = isOurEntity && deliverableEntityTypes.length ? ( + this._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + deliverableEntityTypes, + false, + ) as IsOurEntityCallback + ) : undefined; + + const deliverableEntityCallback : SetterMethod | undefined = isDeliverableEntity && entityTypesOnly?.length ? reduce( + entityTypesOnly, + (prev: SetterMethod | undefined, item: EntityType>) : SetterMethod | undefined => { + + const staticMethods = item.getStaticMethods(); + const simpleCreateMethods : EntityMethod[] = filter( + staticMethods, + (item: EntityMethod) : boolean => { + return item.getMethodName() === 'create' && item.getArguments().length === 1; + } + ); + + if (!simpleCreateMethods.length) { + return prev; + } + + const deliverableTypes : EntityMethodType[] = uniq(reduce( + simpleCreateMethods, + (prev : EntityMethodType[], item: EntityMethod) : EntityMethodType[] => { + const types = item.getArguments()[0]; + return [ + ...prev, + ...types, + ]; + }, + [] + )); + + const isDeliverableEntity = deliverableTypes.length ? ( + this._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + deliverableTypes, + false, + ) + ) : undefined; + + if (!isDeliverableEntity) { + return prev; + } + + const Type = item; + + if (prev) { + return function deliverableEntitySetterMethod ( + this: T, + value: unknown + ) : T { + + if (isDeliverableEntity(value)) { + return this._setPropertyValue( + propertyName, + // @ts-ignore + Type.create(value).getDTO() + ); + } + + return prev.call(this, value); + }; + } + + return function deliverableEntitySetterMethod ( + this: T, + value: unknown + ) : T { + + if (isDeliverableEntity(value)) { + return this._setPropertyValue( + propertyName, + // @ts-ignore + Type.create(value).getDTO() + ); + } + + throw new TypeError(`${Type.getEntityName()}.${methodName}: Invalid argument provided: ${LogUtils.stringifyValue(value)}`); + }; + + + }, + undefined + ) : undefined; + + if ( isOurEntity && isOtherTypes && isOurDTO ) { + + if ( isDeliverableEntity && deliverableEntityCallback ) { + return function entitySetterMethodWithTypesWithDeliverableEntitiesWithDeliverableEntities ( + this: T, + value: unknown + ) : T { + if ( isOurEntity(value) ) { + return this._setPropertyValue( propertyName, value.getDTO() as unknown as ReadonlyJsonObject ); + } else if ( isOurDTO(value) ) { + return this._setPropertyValue( propertyName, value as unknown as ReadonlyJsonObject ); + } else if ( isDeliverableEntity(value) ) { + return deliverableEntityCallback.call(this, value); + } else if ( isOtherTypes(value) ) { + return this._setPropertyValue( propertyName, value as unknown as ReadonlyJsonObject ); + } else { + throw new TypeError(`${this.getEntityType().getEntityName()}.${methodName}: Invalid argument provided: ${LogUtils.stringifyValue(value)}`); + } + }; + } + + return function entitySetterMethodWithTypes ( + this: T, + value: unknown + ) : T { + if ( isOurEntity(value) ) { + return this._setPropertyValue( propertyName, value.getDTO() as unknown as ReadonlyJsonObject ); + } else if ( isOurDTO(value) ) { + return this._setPropertyValue( propertyName, value as unknown as ReadonlyJsonObject ); + } else if ( isOtherTypes(value) ) { + return this._setPropertyValue( propertyName, value as unknown as ReadonlyJsonObject ); + } else { + throw new TypeError(`${this.getEntityType().getEntityName()}.${methodName}: Invalid argument provided: ${LogUtils.stringifyValue(value)}`); + } + }; + } + + if ( isOtherTypes ) { + return function setterMethodWithTypes ( + this: T, + value: unknown + ) : T { + if ( isOtherTypes(value) ) { + return this._setPropertyValue( propertyName, value as unknown as ReadonlyJsonObject ); + } else { + throw new TypeError(`${this.getEntityType().getEntityName()}.${methodName}: Invalid argument provided: ${LogUtils.stringifyValue(value)}`); + } + }; + } + + if ( isOurEntity && isOurDTO ) { + + if ( isDeliverableEntity && deliverableEntityCallback ) { + return function entitySetterMethod ( + this: T, + value: unknown + ) : T { + if ( isOurEntity(value) ) { + return this._setPropertyValue( propertyName, value.getDTO() as unknown as ReadonlyJsonObject ); + } else if ( isOurDTO(value) ) { + return this._setPropertyValue( propertyName, value as unknown as ReadonlyJsonObject ); + } else if (isDeliverableEntity(value)) { + return deliverableEntityCallback.call(this, value); + } else { + throw new TypeError(`${this.getEntityType().getEntityName()}.${methodName}: Invalid argument provided: ${LogUtils.stringifyValue(value)}`); + } + }; + } + + return function entitySetterMethod ( + this: T, + value: unknown + ) : T { + if ( isOurEntity(value) ) { + return this._setPropertyValue( propertyName, value.getDTO() as unknown as ReadonlyJsonObject ); + } else if ( isOurDTO(value) ) { + return this._setPropertyValue( propertyName, value as unknown as ReadonlyJsonObject ); + } else { + throw new TypeError(`${this.getEntityType().getEntityName()}.${methodName}: Invalid argument provided: ${LogUtils.stringifyValue(value)}`); + } + }; + + } + + return function setterMethod ( + this: T, + value: unknown + ) : T { + throw new TypeError(`${this.getEntityType().getEntityName()}.${methodName}: Invalid argument provided: ${LogUtils.stringifyValue(value)}`); + }; + + } catch (err) { + LOG.debug(`Error in createPropertySetter(): `, err); + throw new TypeError(`${methodName}: ${err}`); + } + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////// #createArrayPropertySetter //////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + public static createArrayPropertySetter< + D extends DTO, + T extends BaseEntity, + > ( + propertyName : string, + types : readonly EntityVariableType[] + ) : SetterMethod { + + /** + * Array of entity types included. + */ + const entityTypes : EntityType>[] = filter(types, isEntityType); + + /** + * Type of check function for entity. + */ + type IsOurEntityCallback = (value: unknown) => value is Entity; + + /** + * Returns an optional test function to test if the entity is one of + * our defined entity types. + */ + const isOurEntity : IsOurEntityCallback | undefined = entityTypes.length ? reduce( + entityTypes, + (prev: IsOurEntityCallback | undefined, Type: EntityType>) : IsOurEntityCallback => { + if (prev === undefined) { + return (value: unknown) : value is Entity => Type.isEntity(value); + } + return (value: unknown) : value is Entity => prev(value) || Type.isEntity(value); + }, + undefined, + ) : undefined; + + if ( isOurEntity ) { + return function arrayEntitySetterMethod ( + this: T, + value: unknown + ) : T { + const list : ReadonlyJsonAny[] = map( + isArray(value) ? value : [ value ], + (item : unknown) : ReadonlyJsonAny => { + if ( isOurEntity(item) ) { + return item.getDTO() as unknown as ReadonlyJsonObject; + } else if (isReadonlyJsonAny(item)) { + return item; + } else { + throw new TypeError(`${this.getEntityType().getEntityName()}.${propertyName}: Invalid argument provided: ${LogUtils.stringifyValue(item)}`); + } + } + ); + return this._setPropertyValue(propertyName, list); + }; + } + + return function arraySetterMethod ( + this: T, + value: unknown + ) : T { + + if (value === undefined) { + return this._setPropertyValue(propertyName, undefined); + } + + return this._setPropertyValue( + propertyName, + map( + isArray(value) ? value : [ value ], + (item: unknown) : ReadonlyJsonAny => { + if (isReadonlyJsonAny(item)) { + return item; + } else { + throw new TypeError(`${this.getEntityType().getEntityName()}.${propertyName}: Invalid argument provided: ${LogUtils.stringifyValue(item)}`); + } + } + ) + ); + }; + + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// private properties //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + /** + * The default name of the entity. + * + * @private + */ + private readonly _name : string; + + /** + * Internal properties. + * + * @private + */ + private readonly _properties : EntityProperty[]; + + /** + * Static methods, e.g. methods available on the type of the entity like + * `.create()`. + * + * @private + */ + private readonly _staticMethods : EntityMethod[]; + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////////// new constructor ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Construct an entity factory. + * + * @protected + */ + protected constructor (name : string) { + this._name = name; + this._properties = []; + this._staticMethods = []; + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////////// public methods ////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + public getName () : string { + return this._name; + } + + /** + * @inheritDoc + */ + public getProperties () : readonly EntityProperty[] { + return map( + this._properties, + (item : EntityProperty) => item + ); + } + + /** + * @inheritDoc + */ + public createProperty (name : string) : EntityProperty { + return EntityFactoryImpl.createProperty(name); + } + + /** + * @inheritDoc + */ + public getStaticMethods () : readonly EntityMethod[] { + return map( + this._staticMethods, + (item : EntityMethod) => item + ); + } + + /** + * @inheritDoc + */ + public createMethod (name : string) : EntityMethod { + return EntityFactoryImpl.createMethod(name); + } + + /** + * @inheritDoc + */ + public addStaticMethod ( name : EntityMethod ) : this { + this._staticMethods.push( name ); + return this; + } + + /** + * @inheritDoc + */ + public add ( + name : EntityProperty | string, + ...types : EntityVariableType[] + ) : this { + if ( isString(name) ) { + this._properties.push( this.createProperty(name).types(...types) ); + } else { + this._properties.push( name ); + } + return this; + } + + /** + * @inheritDoc + * @fixme Cache the value and use it so that multiple calls do not generate new ones unless state changes + */ + public createTestFunctionOfDTO () : IsDTOTestFunction { + try { + + const properties : readonly EntityProperty[] = this.getProperties(); + + const propertyNames : readonly string[] = map( + properties, + (item : EntityProperty) : string => item.getPropertyName() + ); + + const checkProperties : PropertyTypeCheckFn = TypeCheckFunctionUtils.createChainedFunction( + ChainOperation.AND, + map( + properties, + (item : EntityProperty): TypeCheckFn => { + const propertyName = item.getPropertyName(); + const itemIsArray = item.isArray(); + const itemIsOptional = item.isOptional(); + + const isType = EntityFactoryImpl._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + item.getTypes(), + true, + ); + + if (itemIsArray && itemIsOptional) { + return (value: unknown) : boolean => isArrayOfOrUndefined((value as ReadonlyJsonObject)[propertyName], isType); + } + + if (itemIsArray) { + return (value: unknown) : boolean => isArrayOf((value as ReadonlyJsonObject)[propertyName], isType); + } + + return (value: unknown) : boolean => isType((value as ReadonlyJsonObject)[propertyName]); + } + ) + ) as PropertyTypeCheckFn; + + return (value : unknown) : value is D => { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, propertyNames) + && checkProperties(value) + ); + }; + + } catch (err) { + LOG.debug(`Error in createTestFunctionOfDTO(): `, err); + throw new Error(`createTestFunctionOfDTO: ${this.getName()}: ${err}`); + } + } + + /** + * @inheritDoc + * @fixme Cache the value and use it so that multiple calls do not generate new ones unless state changes + */ + public createExplainFunctionOfDTO () : IsDTOExplainFunction { + const typeName = this.getName(); + try { + + const properties : readonly EntityProperty[] = this.getProperties(); + + const propertyNames : readonly string[] = map( + properties, + (item : EntityProperty) : string => item.getPropertyName() + ); + + const explainProperties = map( + properties, + (item: EntityProperty): IsDTOExplainFunction => { + const propertyName = item.getPropertyName(); + const explainFunction = EntityFactoryImpl._typeCheckFactory.createChainedTypeExplainFunction( + ChainOperation.OR, + item.getTypes(), + true, + ); + return (value : unknown) : string => { + if (!isRegularObject(value)) return 'parent not object'; + return explainProperty(propertyName, explainFunction((value as any)[propertyName])) + }; + } + ) + + const ok = explainOk(); + + return (value : unknown) : string => { + const regularResult = explainRegularObject(value); + if (regularResult !== ok) { + return explainNot(typeName + ' DTO: ' + regularResult); + } + const result = explain( + [ + explainNoOtherKeysInDevelopment(value, propertyNames), + ...map( + explainProperties, + (item) => item(value) + ), + ] + ); + return result === ok ? explainOk() : explainNot(typeName + ' DTO: ' + result); + }; + } catch (err) { + LOG.debug(`Error in createExplainFunctionOfDTO(): `, err); + throw new Error(`createExplainFunctionOfDTO: ${typeName}: ${err}`); + } + } + + /** + * @inheritDoc + * @fixme Cache the value and use it so that multiple calls do not generate new ones unless state changes + */ + public createTestFunctionOfDTOorOneOf ( ...types : EntityVariableType[] ) : IsDTOOrTestFunction { + const isDTO = this.createTestFunctionOfDTO(); + const anotherFn = EntityFactoryImpl._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + types, + true, + ) as unknown as IsDTOOrTestFunction; + return (value: unknown) : value is D | T => isDTO( value ) || anotherFn( value ); + } + + /** + * @inheritDoc + * @fixme Cache the value and use it so that multiple calls do not generate new ones unless state changes + */ + public createExplainFunctionOfDTOorOneOf ( ...types : EntityVariableType[] ) : IsDTOExplainFunction { + const name = this.getName(); + try { + const testDTO = this.createTestFunctionOfDTO(); + const testOtherTypes = EntityFactoryImpl._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + types, + true, + ); + const ok = explainOk(); + const typeNames : string[] = EntityFactoryImpl._typeCheckFactory.getTypeNameList(types); + const notOk = explainNot( + explainOneOf( + [ + `DTO of ${name}`, + ...typeNames, + ] + ) + ); + return (value: unknown) : string => ( + testDTO( value ) || testOtherTypes( value ) ? ok : notOk + ); + } catch (err) { + LOG.debug(`Passed on error: `, err); + throw new Error(`createExplainFunctionOfDTOorOneOf() for ${name}: ${err}`); + } + } + + /** + * @inheritDoc + */ + public createTestFunctionOfInterface () : IsInterfaceTestFunction { + + const properties : readonly EntityProperty[] = this.getProperties(); + + const methodNames : string[] = uniq(reduce( + properties, + (prev: string[], item: EntityProperty) : string[] => { + return [ + ...prev, + ...item.getGetterNames(), + ...item.getSetterNames(), + ...item.getMethodAliases(), + ]; + }, + [ + 'valueOf', + 'toJSON', + 'getDTO', + 'getEntityType', + ] + )); + + const checkFunctions : MethodTypeCheckFn | undefined = reduce( + methodNames, + (prev: MethodTypeCheckFn | undefined, methodName: string): MethodTypeCheckFn => { + if (prev === undefined) { + return (value: any) : boolean => isFunction(value[methodName]); + } else { + return (value: any) : boolean => prev(value) && isFunction(value[methodName]); + } + }, + undefined + ); + + if (checkFunctions === undefined) { + return (value : unknown) : value is T => isObject(value); + } + + return (value : unknown) : value is T => isObject(value) && checkFunctions(value); + } + + public createTestFunctionOfInterfaceOrOneOf ( ...types : EntityVariableType[] ) : IsInterfaceOrTestFunction { + const isInterface = this.createTestFunctionOfInterface(); + const anotherFn = EntityFactoryImpl._typeCheckFactory.createChainedTypeCheckFunction( + ChainOperation.OR, + types, + true, + ); + return (value: unknown) : value is T | X => isInterface( value ) || anotherFn( value ); + } + + + /** + * @inheritDoc + */ + public createDefaultDTO () : D { + const properties : readonly EntityProperty[] = this.getProperties(); + return reduce( + properties, + (prev: D, item : EntityProperty): D => { + + let defValue : EntityVariableValue = item.getDefaultValue(); + + if (item.isArray()) { + if (isArray(defValue)) { + + defValue = map( + defValue, + (item) => isEntity(item) ? item.getDTO() as Entity : item + ); + + if (item.isOptional() && defValue.length === 0) { + defValue = undefined; + } + + } else { + defValue = item.isOptional() ? undefined : []; + } + + } else { + defValue = isEntity(defValue) ? defValue.getDTO() as Entity : defValue; + } + + return { + ...prev, + ...(defValue !== undefined ? { [item.getPropertyName()] : defValue } : {}) + }; + }, + {} as unknown as D, + ); + } + + + /** + * @inheritDoc + */ + public createEntityType ( + arg1 ?: CreateEntityTypeOpts | string | undefined, + arg2 ?: CreateEntityTypeOpts | undefined, + ) : EntityType { + + const arg1IsString= isString(arg1); + const opts : CreateEntityTypeOpts | undefined = ( + arg2 !== undefined ? arg2 : ( + arg1IsString || arg1 === undefined ? undefined : arg1 + ) + ); + const name : string = (arg1IsString ? arg1 : opts?.name) ?? this.getName(); + const immutable : boolean = !!(opts?.immutable); + + if (EntityFactoryImpl._entities.hasType(name)) { + throw new TypeError(`EntityFactoryImpl.createEntityType(): The entity by this name exists already: ${name}`); + } + + const staticMethods : readonly EntityMethod[] = this.getStaticMethods(); + const properties : readonly EntityProperty[] = this.getProperties(); + + /** + * This is a "hack" which initializes the entity type in order to + * have the instance registered in the global state before other parts + * of code uses it, e.g. `this.createTestFunctionOfDTO()`. + * + * @param Type + */ + const typeInitializer = (Type: any) : boolean => { + EntityFactoryImpl._entities.registerType(name, Type); + return true; + }; + + const isDtoInitializer = () : IsDTOTestFunction => { + return this.createTestFunctionOfDTO(); + }; + + const createDefaultDtoInitializer = () : D => { + return this.createDefaultDTO(); + }; + + const isOk = explainOk(); + const notMyEntity = explainNot(name); + + /** + * @see EntityType as well, which describes the static API. + */ + class FinalType + extends BaseEntity + implements Entity + { + + private static _initialized : boolean = typeInitializer(FinalType); + private static _isDTO : IsDTOTestFunction = isDtoInitializer(); + private static _defaultDto : D = createDefaultDtoInitializer(); + + public static create () : FinalType { + return new FinalType(); + } + + public static createFromDTO ( + dto : D, + ) : Entity { + return new FinalType(dto); + } + + public static getProperties () : EntityProperty[] { + return map(properties, (item: EntityProperty) : EntityProperty => item); + } + + public static getStaticMethods () : EntityMethod[] { + return map(staticMethods, (item: EntityMethod) : EntityMethod => item); + } + + public static getEntityName () : string { + return name; + } + + public static isEntity (value: unknown) : value is FinalType { + return value instanceof FinalType; + } + + public static explainEntity (value: unknown) : string { + return this.isEntity(value) ? isOk : notMyEntity; + } + + public static isDTO (value: unknown) : value is D { + return this._isDTO(value); + } + + public constructor ( + dto ?: D | undefined, + ) { + super( dto ?? FinalType._defaultDto ); + } + + public getEntityType () : EntityType { + return FinalType as unknown as EntityType; + } + + } + + const installMethods = ( + method : ((this: FinalType, ...args: any) => any), + ...methodNames : readonly string[] + ) : void => { + forEach( + methodNames, + (methodName: string) : void => { + if ( !has( FinalType.prototype, methodName ) ) { + (FinalType.prototype as any)[methodName] = method; + } + } + ); + }; + + forEach( + properties, + (item: EntityProperty) : void => { + const propertyName : string = item.getPropertyName(); + const itemIsArray : boolean = item.isArray(); + const itemIsOptional : boolean = item.isOptional(); + const types : readonly EntityVariableType[] = item.getTypes(); + + const hasJsonType : boolean = some(types, (type) => type === VariableType.JSON); + + const hasEntityType : boolean = some(types, (item) => { + if ( isString(item) && !isVariableType(item) ) { + return true; + } + return isEntityType(item); + }); + + const getterMethodNames : readonly string[] = item.getGetterNames(); + const getterMethodName : string = getterMethodNames[0]; + + const dtoGetterMethodNames : readonly string[] = map(getterMethodNames, (item: string) : string => `${item}DTO`); + const dtoGetterMethodName : string = dtoGetterMethodNames[0]; + + const setterMethodNames : readonly string[] = immutable ? [] : item.getSetterNames(); + const setterMethodName : string = setterMethodNames[0]; + + const hasPropertyMethodNames = hasJsonType ? [ + `has${upperFirst(propertyName)}Property`, + `${propertyName}HasProperty`, + `${propertyName}Has`, + ] : []; + + const getPropertyMethodNames = hasJsonType ? [ + `get${upperFirst(propertyName)}Property`, + `${propertyName}GetProperty`, + ] : []; + + const getStringPropertyMethodNames = hasJsonType ? [ + `get${upperFirst(propertyName)}String`, + `${propertyName}GetString`, + ] : []; + + const setStringPropertyMethodNames = hasJsonType ? [ + `set${upperFirst(propertyName)}String`, + `${propertyName}SetString`, + ] : []; + + + const getNumberPropertyMethodNames = hasJsonType ? [ + `get${upperFirst(propertyName)}Number`, + `${propertyName}GetNumber`, + ] : []; + + const setNumberPropertyMethodNames = hasJsonType ? [ + `set${upperFirst(propertyName)}Number`, + `${propertyName}SetNumber`, + ] : []; + const setNumberPropertyMethodName = setNumberPropertyMethodNames[0]; + + + const getBooleanPropertyMethodNames = hasJsonType ? [ + `get${upperFirst(propertyName)}Boolean`, + `${propertyName}GetBoolean`, + ] : []; + + const setBooleanPropertyMethodNames = hasJsonType ? [ + `set${upperFirst(propertyName)}Boolean`, + `${propertyName}SetBoolean`, + ] : []; + const setBooleanPropertyMethodName = setBooleanPropertyMethodNames[0]; + + + const getArrayPropertyMethodNames = hasJsonType ? [ + `get${upperFirst(propertyName)}Array`, + `${propertyName}GetArray`, + ] : []; + + const getArrayOfPropertyMethodNames = hasJsonType ? [ + `get${upperFirst(propertyName)}ArrayOf`, + `${propertyName}GetArrayOf`, + ] : []; + + const setArrayPropertyMethodNames = hasJsonType ? [ + `set${upperFirst(propertyName)}Array`, + `${propertyName}SetArray`, + ] : []; + const setArrayPropertyMethodName = setArrayPropertyMethodNames[0]; + + + const getObjectPropertyMethodNames = hasJsonType ? [ + `get${upperFirst(propertyName)}Object`, + `${propertyName}GetObject`, + ] : []; + + const setObjectPropertyMethodNames = hasJsonType ? [ + `set${upperFirst(propertyName)}Object`, + `${propertyName}SetObject`, + ] : []; + const setObjectPropertyMethodName = setObjectPropertyMethodNames[0]; + + const addMethodNames = hasEntityType ? [ + `add${upperFirst(propertyName)}`, + ] : []; + const addMethodName : string | undefined = addMethodNames.length ? addMethodNames[0] : undefined; + + const getterMethod = getterMethodNames?.length ? ( + itemIsArray + ? EntityFactoryImpl.createArrayPropertyGetter( + propertyName, + types, + { isOptional : itemIsOptional } + ) + : EntityFactoryImpl.createPropertyGetter( + propertyName, + types, + ) + ) : undefined; + + const setterMethod = setterMethodNames?.length ? ( + itemIsArray + ? EntityFactoryImpl.createArrayPropertySetter( + propertyName, + types, + ) + : EntityFactoryImpl.createPropertySetter( + propertyName, + types, + ) + ) : undefined; + + const addToEntityMethod = dtoGetterMethodName && setterMethodName && addMethodNames.length && !itemIsArray ? function addToEntityMethod ( + this: FinalType, + newValues : any, + ) : any | undefined { + const value : any = (this as any)[dtoGetterMethodName](); + + if (isEntity(newValues)) { + return (this as any)[setterMethodName]({ + ...(value ? value : {}), + ...newValues.getDTO(), + }); + } + + if (isReadonlyJsonObject(newValues)) { + return (this as any)[setterMethodName]({ + ...(value ? value : {}), + ...newValues, + }); + } + + throw new TypeError(`${name}.${addMethodName}(): The argument was not valid: ${LogUtils.stringifyValue(newValues)}`); + + } : undefined; + + const addToArrayMethod = dtoGetterMethodName && setterMethodName && addMethodNames.length && itemIsArray ? function addToArrayMethod ( + this: FinalType, + newValues : unknown, + ) : any | undefined { + const values : any[] = (this as any)[dtoGetterMethodName](); + + if (!isArray(newValues)) { + newValues = [newValues]; + } + + if (isReadonlyJsonArray(newValues)) { + return (this as any)[setterMethodName]([ + ...(isArray(values) ? values : []), + ...newValues, + ]); + } + + throw new TypeError(`${name}.${addMethodName}(): The argument was not valid: ${LogUtils.stringifyValue(newValues)}`); + + } : undefined; + + + const hasPropertyByKey = getterMethodName && getterMethod && hasPropertyMethodNames.length ? function hasPropertyByKey ( + this: FinalType, + key : string, + ) : boolean { + const value : any = (this as any)[getterMethodName](); + return value ? has(value, key) : false; + } : undefined; + + const getPropertyByKey = getterMethodName && getterMethod && getPropertyMethodNames.length ? function getPropertyByKey ( + this: FinalType, + key : string, + ) : any | undefined { + const value : any = (this as any)[getterMethodName](); + return value ? value[key] : undefined; + } : undefined; + + const setPropertyByKey = setterMethodName && getterMethodName ? function setPropertyByKey ( + this: FinalType, + key : string, + newValue : any, + ) : any | undefined { + const item : any = (this as any)[getterMethodName](); + if (!isRegularObjectOrUndefined(item)) { + throw new TypeError(`${name}.${getterMethodName}(): The property value was not regular object or undefined: ${LogUtils.stringifyValue(item)}`); + } + (this as any)[setterMethodName]({ + ...(item ? item : {}), + [key]: newValue, + }); + return this; + } : undefined; + + + const getStringPropertyByKey = getPropertyByKey && getStringPropertyMethodNames.length ? function getStringPropertyByKey ( + this: FinalType, + key : string, + ) : string | undefined { + const value = getPropertyByKey.call(this, key); + return isStringOrUndefined(value) ? value : `${value}`; + } : undefined; + + const setStringPropertyByKey = setPropertyByKey && setStringPropertyMethodNames.length ? function setStringPropertyByKey ( + this: FinalType, + key : string, + value : unknown, + ) : string | undefined { + if (!isString(value)) { + throw new TypeError(`${name}.${setterMethodName}(): The new property value was not a string: ${ LogUtils.stringifyValue(value) }`); + } + return setPropertyByKey.call(this, key, value); + } : undefined; + + + const getNumberPropertyByKey = getPropertyByKey && getNumberPropertyMethodNames.length ? function getNumberPropertyByKey ( + this: FinalType, + key : string, + ) : number | null | undefined { + const value = getPropertyByKey.call(this, key); + return isNumberOrUndefined(value) ? value : null; + } : undefined; + + const setNumberPropertyByKey = setPropertyByKey && setNumberPropertyMethodNames.length ? function setNumberPropertyByKey ( + this: FinalType, + key : string, + value : unknown, + ) : string | undefined { + if (!isNumber(value)) { + throw new TypeError(`${name}.${setNumberPropertyMethodName}(): The new property value was not a number: ${ LogUtils.stringifyValue(value) }`); + } + return setPropertyByKey.call(this, key, value); + } : undefined; + + + const getBooleanPropertyByKey = getPropertyByKey && getBooleanPropertyMethodNames.length ? function getBooleanPropertyByKey ( + this: FinalType, + key : string, + ) : boolean | null | undefined { + const value = getPropertyByKey.call(this, key); + return isBooleanOrUndefined(value) ? value : null; + } : undefined; + + const setBooleanPropertyByKey = setPropertyByKey && setBooleanPropertyMethodNames.length ? function setBooleanPropertyByKey ( + this: FinalType, + key : string, + value : unknown, + ) : string | undefined { + if (!isBoolean(value)) { + throw new TypeError(`${name}.${setBooleanPropertyMethodName}(): The new property value was not a boolean: ${ LogUtils.stringifyValue(value) }`); + } + return setPropertyByKey.call(this, key, value); + } : undefined; + + + const getArrayPropertyByKey = getPropertyByKey && getArrayPropertyMethodNames.length ? function getArrayPropertyByKey ( + this: FinalType, + key : string, + ) : readonly any[] | null | undefined { + const value = getPropertyByKey.call(this, key); + return isArrayOrUndefined(value) ? value : null; + } : undefined; + + const getArrayOfPropertyByKey = getPropertyByKey && getArrayOfPropertyMethodNames.length ? function getArrayOfPropertyByKey ( + this: FinalType, + key : string, + isItemOf : TestCallback, + ) : readonly any[] | null | undefined { + const value = getPropertyByKey.call(this, key); + return isArrayOfOrUndefined(value, isItemOf) ? value : null; + } : undefined; + + const setArrayPropertyByKey = setPropertyByKey && setArrayPropertyMethodNames.length ? function setArrayPropertyByKey ( + this: FinalType, + key : string, + value : unknown, + ) : readonly any[] | undefined { + if (!isArray(value)) { + throw new TypeError(`${name}.${setArrayPropertyMethodName}(): The new property value was not an array: ${ LogUtils.stringifyValue(value) }`); + } + return setPropertyByKey.call(this, key, value); + } : undefined; + + + const getObjectPropertyByKey = getPropertyByKey && getObjectPropertyMethodNames.length ? function getObjectPropertyByKey ( + this: FinalType, + key : string, + ) : ReadonlyJsonObject | null | undefined { + const value = getPropertyByKey.call(this, key); + return isObjectOrUndefined(value) ? value : null; + } : undefined; + + const setObjectPropertyByKey = setPropertyByKey && setObjectPropertyMethodNames.length ? function setObjectPropertyByKey ( + this: FinalType, + key : string, + value : unknown, + ) : ReadonlyJsonObject | undefined { + if (!isObject(value)) { + throw new TypeError(`${name}.${setObjectPropertyMethodName}(): The new property value was not an object: ${ LogUtils.stringifyValue(value) }`); + } + return setPropertyByKey.call(this, key, value); + } : undefined; + + // const addObjectPropertyByKey = setPropertyByKey && setObjectPropertyMethodNames.length ? function addObjectPropertyByKey ( + // this: FinalType, + // key : string, + // value : unknown, + // ) : ReadonlyJsonObject | undefined { + // if (!isObject(value)) { + // throw new TypeError(`${name}.${setObjectPropertyMethodName}(): The new property value was not an object: ${ LogUtils.stringifyValue(value) }`); + // } + // return setPropertyByKey.call(this, key, value); + // } : undefined; + + + const dtoGetterMethod = ( + hasEntityType + ? ( + itemIsArray + ? EntityFactoryImpl.createArrayPropertyGetter( + propertyName, + types, + { + entityAsDTO: true, + isOptional: itemIsOptional, + } + ) + : EntityFactoryImpl.createPropertyGetter( + propertyName, + types, + { + entityAsDTO: true + } + ) + ) + : undefined + ); + + + if (getterMethod && getterMethodNames.length) { + installMethods(getterMethod, ...getterMethodNames); + } + + if (dtoGetterMethod && dtoGetterMethodNames.length) { + installMethods(dtoGetterMethod, ...dtoGetterMethodNames); + } + + if (setterMethod && setterMethodNames.length) { + installMethods(setterMethod, ...setterMethodNames); + } + + if (hasPropertyByKey && hasPropertyMethodNames.length) { + installMethods(hasPropertyByKey, ...hasPropertyMethodNames ); + } + + if (getPropertyByKey && getPropertyMethodNames.length) { + installMethods(getPropertyByKey, ...getPropertyMethodNames); + } + + if (getStringPropertyByKey && getStringPropertyMethodNames.length) { + installMethods(getStringPropertyByKey, ...getStringPropertyMethodNames); + } + + if (setStringPropertyByKey && setStringPropertyMethodNames.length) { + installMethods(setStringPropertyByKey, ...setStringPropertyMethodNames); + } + + if (getNumberPropertyByKey && getNumberPropertyMethodNames.length) { + installMethods(getNumberPropertyByKey, ...getNumberPropertyMethodNames); + } + + if (setNumberPropertyByKey && setNumberPropertyMethodNames.length) { + installMethods(setNumberPropertyByKey, ...setNumberPropertyMethodNames); + } + + if (getBooleanPropertyByKey && getBooleanPropertyMethodNames.length) { + installMethods(getBooleanPropertyByKey, ...getBooleanPropertyMethodNames); + } + + if (setBooleanPropertyByKey && setBooleanPropertyMethodNames.length) { + installMethods(setBooleanPropertyByKey, ...setBooleanPropertyMethodNames); + } + + if (getArrayPropertyByKey && getArrayPropertyMethodNames.length) { + installMethods(getArrayPropertyByKey, ...getArrayPropertyMethodNames); + } + + if (getArrayOfPropertyByKey && getArrayOfPropertyMethodNames.length) { + installMethods(getArrayOfPropertyByKey, ...getArrayOfPropertyMethodNames); + } + + if (setArrayPropertyByKey && setArrayPropertyMethodNames.length) { + installMethods(setArrayPropertyByKey, ...setArrayPropertyMethodNames); + } + + if (getObjectPropertyByKey && getObjectPropertyMethodNames.length) { + installMethods(getObjectPropertyByKey, ...getObjectPropertyMethodNames); + } + + if (setObjectPropertyByKey && setObjectPropertyMethodNames.length) { + installMethods(setObjectPropertyByKey, ...setObjectPropertyMethodNames); + } + + if (addToEntityMethod && addMethodNames.length) { + installMethods(addToEntityMethod, ...addMethodNames); + } + + if (addToArrayMethod && addMethodNames.length) { + installMethods(addToArrayMethod, ...addMethodNames); + } + + } + ); + + return FinalType as unknown as EntityType; + + } + + +} diff --git a/entities/types/EntityMethod.ts b/entities/types/EntityMethod.ts new file mode 100644 index 0000000..0120a09 --- /dev/null +++ b/entities/types/EntityMethod.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { Enum } from "../../types/Enum"; +import { Entity } from "./Entity"; +import { EntityType } from "./EntityType"; +import { VariableType } from "./VariableType"; + +/** + * + */ +export type EntityMethodType = EntityType> | Enum | VariableType | string; + +/** + * Presents a property of an entity or entity DTO with a name and type(s). + */ +export interface EntityMethod { + + /** + * The name of property + */ + getMethodName () : string; + + /** + * Returns a list of method aliases. These are in the format of properties, + * e.g. `"unitType"` creates `getUnitType()` alias for `getUnit()`. + */ + getMethodAliases () : readonly string[]; + + /** + * Accepted argument type(s) of the method + */ + getArguments () : readonly (readonly EntityMethodType[])[]; + + /** + * Add a new argument as type(s). + * + * @param types + */ + addArgument ( + ...types : readonly EntityMethodType[] + ): this; + + /** + * Accepted type of the return type. + */ + getReturnType () : readonly EntityMethodType[]; + + /** + * Set types. + * + * @param types + */ + setReturnType ( + ...types : readonly EntityMethodType[] + ): this; + + /** + * Alternative alias for .setArgs() + * + * @param types + */ + returnType ( + ...types : readonly EntityMethodType[] + ) : this; + + /** + * Returns `true` if this method may be undefined. + */ + isOptional () : boolean; + +} diff --git a/entities/types/EntityMethodImpl.ts b/entities/types/EntityMethodImpl.ts new file mode 100644 index 0000000..c053216 --- /dev/null +++ b/entities/types/EntityMethodImpl.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { + EntityMethod, + EntityMethodType, +} from "./EntityMethod"; + +export class EntityMethodImpl + implements EntityMethod { + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// #create /////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Create an entity property. + * + * @param name The name of the property + * @param aliases The method aliases of the property + */ + public static create ( + name : string, + ...aliases : readonly string[] + ) : EntityMethodImpl { + return new EntityMethodImpl( + name, + aliases, + false, + ); + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////// private properties ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * The name of the property. + * + * @private + */ + private readonly _name : string; + + /** + * Alias names of methods. + * + * These aliases are only used for methods. It will not create support for + * alias DTO properties. + * + * @private + */ + private readonly _methodAliases : readonly string[]; + + /** + * Type(s) of the arguments. + * + * @private + */ + private _args : (readonly EntityMethodType[])[]; + + /** + * Type of the return value. + * + * @private + */ + private _returnType : readonly EntityMethodType[]; + + /** + * `true` if this property is may be undefined. + * + * @private + */ + private readonly _isOptional : boolean; + + //////////////////////////////////////////////////////////////////////////// + //////////////////////// protected constructor /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Construct a method entity. + * + * @param name The name of the method + * @param methodAliases Alternative names for the method + * @param isOptional True if this property may be undefined. + * @protected + */ + protected constructor ( + name : string, + methodAliases : readonly string[], + isOptional : boolean, + ) { + this._name = name; + this._methodAliases = methodAliases; + this._isOptional = isOptional; + this._args = []; + this._returnType = []; + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// public methods /////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + + /** + * @inheritDoc + */ + public getMethodName () : string { + return this._name; + } + + /** + * @inheritDoc + */ + public getMethodAliases () : readonly string[] { + return this._methodAliases; + } + + /** + * @inheritDoc + */ + public isOptional () : boolean { + return this._isOptional; + } + + + /** + * @inheritDoc + */ + public getArguments () : readonly (readonly EntityMethodType[])[] { + return this._args; + } + + /** + * @inheritDoc + */ + public addArgument ( + ...types : readonly EntityMethodType[] + ): this { + this._args.push( types ); + return this; + } + + /** + * @inheritDoc + */ + public getReturnType () : readonly EntityMethodType[] { + return this._returnType; + } + + /** + * @inheritDoc + */ + public setReturnType ( + ...types : readonly EntityMethodType[] + ): this { + this._returnType = types; + return this; + } + + /** + * @inheritDoc + */ + public returnType ( + ...types : readonly EntityMethodType[] + ) : this { + return this.setReturnType(...types); + } + +} diff --git a/entities/types/EntityProperty.ts b/entities/types/EntityProperty.ts new file mode 100644 index 0000000..19999de --- /dev/null +++ b/entities/types/EntityProperty.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { + EntityVariableType, + EntityVariableValue, +} from "./EntityVariableType"; + +/** + * Presents a property of an entity or entity DTO with a name and type(s). + */ +export interface EntityProperty { + + /** + * The name of property + */ + getPropertyName () : string; + + /** + * Returns a list of method aliases. These are in the format of properties, + * e.g. `"unitType"` creates `getUnitType()` alias for `getUnit()`. + */ + getMethodAliases () : readonly string[]; + + /** + * Accepted type(s) of the property. + */ + getTypes () : readonly EntityVariableType[]; + + /** + * Set types. + * + * @param types + */ + setTypes ( + ...types : readonly EntityVariableType[] + ): this; + + /** + * Alternative alias for .setTypes() + * + * @param types + */ + types ( + ...types : readonly EntityVariableType[] + ) : this; + + /** + * + */ + getDefaultValue () : EntityVariableValue; + + /** + * + * @param value + */ + setDefaultValue (value: EntityVariableValue) : this; + + /** + * Alternative name for .setDefaultValue() + * + * @param value + */ + defaultValue (value: EntityVariableValue) : this; + + /** + * Returns names for getter functions. + */ + getGetterNames () : readonly string[]; + + /** + * Returns names for setter functions. + */ + getSetterNames () : readonly string[]; + + /** + * Returns `true` if this property is an array. + */ + isArray () : boolean; + + /** + * Returns `true` if this property may be undefined. + */ + isOptional () : boolean; + +} diff --git a/entities/types/EntityPropertyImpl.test.ts b/entities/types/EntityPropertyImpl.test.ts new file mode 100644 index 0000000..b9eb8a0 --- /dev/null +++ b/entities/types/EntityPropertyImpl.test.ts @@ -0,0 +1,310 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { jest, describe, beforeEach, afterEach, expect } from '@jest/globals'; +import { ChainOperation } from "./ChainOperation"; +import { DTO } from "./DTO"; +import { Entity } from "./Entity"; +import { + TypeCheckFn, + TypeExplainFn, +} from "./EntityFactory"; +import { EntityFactoryImpl } from "./EntityFactoryImpl"; +import { EntityPropertyImpl } from "./EntityPropertyImpl"; +import { EntityType } from "./EntityType"; +import { EntityTypeCheckFactory } from "./EntityTypeCheckFactory"; +import { + EntityVariableType, + EntityVariableValue, +} from "./EntityVariableType"; +import { VariableType } from "./VariableType"; + +describe('EntityPropertyImpl', () => { + + let typeCheckFactory : EntityTypeCheckFactory; + + beforeEach(() => { + typeCheckFactory = { + + createDefaultValueFromTypes: jest.fn<( + types: readonly EntityVariableType[], + ) => EntityVariableValue>().mockImplementation( () => undefined ), + + createChainedTypeCheckFunction: jest.fn<( + op: ChainOperation, + types: readonly EntityVariableType[], + useDTO: boolean | "both", + ) => TypeCheckFn>().mockImplementation( () => { + return () : boolean => false; + } ), + + createChainedTypeExplainFunction: jest.fn<( + op: ChainOperation, + types: readonly EntityVariableType[], + useDTO: boolean | "both", + ) => TypeExplainFn>().mockImplementation( () => { + return () : string => ''; + } ), + + createTypeCheckFunction: jest.fn<( + item: EntityVariableType, + useDTO: boolean | "both", + ) => TypeCheckFn>().mockImplementation( + () => { + return () : boolean => false; + } + ), + + getTypeNameList: jest.fn<( ...types: readonly EntityVariableType[] ) => string[]>().mockImplementation( + () : string[] => [] + ), + + getTypeName: jest.fn<( type: EntityVariableType ) => string>().mockImplementation( + () : string => '' + ), + + } + }); + + afterEach( () => { + EntityFactoryImpl.destroy(); + }); + + describe('#create', () => { + + it('can create a property', () => { + const item = EntityPropertyImpl.create(typeCheckFactory ,"test"); + expect( item ).toBeDefined(); + expect( item ).toBeInstanceOf(EntityPropertyImpl); + }); + + it('can create a property which may be optional', () => { + const item = EntityPropertyImpl.create(typeCheckFactory, "test"); + expect( item.isOptional() ).toBe(true); + }); + + it('can create a property which is not an array', () => { + const item = EntityPropertyImpl.create(typeCheckFactory, "test"); + expect( item.isArray() ).toBe(false); + }); + + it('can create property with a name', () => { + const item = EntityPropertyImpl.create(typeCheckFactory,"test"); + expect( item.getPropertyName() ).toBe("test"); + }); + + it('can create property with string type', () => { + const item = EntityPropertyImpl.create(typeCheckFactory,"test").setTypes(VariableType.STRING); + expect( item.getTypes() ).toStrictEqual(["string"]); + }); + + it('can create property with number type', () => { + const item = EntityPropertyImpl.create(typeCheckFactory,"test").setTypes(VariableType.NUMBER); + expect( item.getTypes() ).toStrictEqual(["number"]); + }); + + it('can create property with boolean type', () => { + const item = EntityPropertyImpl.create(typeCheckFactory,"test").setTypes(VariableType.BOOLEAN); + expect( item.getTypes() ).toStrictEqual(["boolean"]); + }); + + it('can create property with null type', () => { + const item = EntityPropertyImpl.create(typeCheckFactory,"test").setTypes(VariableType.NULL); + expect( item.getTypes() ).toStrictEqual(["null"]); + }); + + it('can create property with undefined type', () => { + const item = EntityPropertyImpl.create(typeCheckFactory,"test").setTypes(VariableType.UNDEFINED); + expect( item.getTypes() ).toStrictEqual(["undefined"]); + }); + + it('can create property with number and undefined types', () => { + const item = EntityPropertyImpl.create(typeCheckFactory,"test").setTypes(VariableType.NUMBER, VariableType.UNDEFINED); + expect( item.getTypes() ).toStrictEqual(["number", "undefined"]); + }); + + it('can create property with Entity type', () => { + const carFactory = ( + EntityFactoryImpl.create('Entity') + .add( EntityPropertyImpl.create(typeCheckFactory,"model").setTypes(VariableType.STRING).setDefaultValue("Ford") ) + ); + const CarType = carFactory.createEntityType(); + const item = EntityPropertyImpl.create(typeCheckFactory, + "test").setTypes(CarType); + expect( item.getTypes() ).toStrictEqual([CarType]); + }); + + }); + + describe('#createArray', () => { + + let item : EntityPropertyImpl; + + beforeEach(() => { + item = EntityPropertyImpl.createArray(typeCheckFactory,"test"); + }); + + it('can create a property', () => { + expect( item ).toBeDefined(); + expect( item ).toBeInstanceOf(EntityPropertyImpl); + }); + + it('can create a array property which is an array', () => { + expect( item.isArray() ).toBe(true); + }); + + it('can create a array property which is not optional', () => { + expect( item.isOptional() ).toBe(false); + }); + + it('can create a array property with default value as []', () => { + expect( item.getDefaultValue() ).toStrictEqual([]); + }); + + it('still has a default value after type has been set', () => { + item.setTypes(VariableType.STRING); + expect( item.getDefaultValue() ).toStrictEqual([]); + }); + + + }); + + describe('#createOptionalArray', () => { + + let item : EntityPropertyImpl; + + beforeEach(() => { + item = EntityPropertyImpl.createOptionalArray(typeCheckFactory,"test"); + }); + + it('can create a array property', () => { + expect( item ).toBeDefined(); + expect( item ).toBeInstanceOf(EntityPropertyImpl); + }); + + it('can create a array property which is an array', () => { + expect( item.isArray() ).toBe(true); + }); + + it('can create a array property which is optional', () => { + expect( item.isOptional() ).toBe(true); + }); + + it('can create a array property with default value as undefined', () => { + expect( item.getDefaultValue() ).toStrictEqual(undefined); + }); + + it('still has undefined default value after type has been set', () => { + item.setTypes(VariableType.STRING); + expect( item.getDefaultValue() ).toStrictEqual(undefined); + }); + + }); + + describe('#getDefaultValue', () => { + + it('can get a default value', () => { + const item = EntityPropertyImpl.create( + typeCheckFactory, + "test" + ).setTypes(VariableType.STRING).setDefaultValue('Hello'); + expect( item.getDefaultValue() ).toBe('Hello'); + }); + + it('can get a default value for an entity', () => { + const carFactory = ( + EntityFactoryImpl.create('Entity') + .add( EntityPropertyImpl.create(typeCheckFactory,"model").setTypes(VariableType.STRING).setDefaultValue("Ford") ) + ); + const CarType = carFactory.createEntityType(); + + EntityFactoryImpl.createProperty("test").setTypes(CarType) + + const item = EntityFactoryImpl.createProperty("test").setTypes(CarType); + + expect( (item.getDefaultValue() as any)?.getDTO() ).toStrictEqual({model: "Ford"}); + + }); + + }); + + describe('#setDefaultValue', () => { + + it('can set a default value', () => { + const item = EntityPropertyImpl.create(typeCheckFactory,"test").setTypes(VariableType.STRING).setDefaultValue('Hello'); + expect( item.getDefaultValue() ).toBe('Hello'); + }); + + }); + + describe('#defaultValue', () => { + + it('can set a default value', () => { + const item = EntityPropertyImpl.create(typeCheckFactory, + "test").setTypes(VariableType.STRING).defaultValue('Hello'); + expect( item.getDefaultValue() ).toBe('Hello'); + }); + + }); + + describe('#getEntityPropertyTypeFromVariable', () => { + + it('can detect string values', () => { + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable('hello') ).toBe(VariableType.STRING); + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable('') ).toBe(VariableType.STRING); + }); + + it('can detect integer number values', () => { + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable(123) ).toBe(VariableType.INTEGER); + }); + + it('can detect number values', () => { + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable(123.123) ).toBe(VariableType.NUMBER); + }); + + it('can detect boolean (false) value', () => { + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable(false) ).toBe(VariableType.BOOLEAN); + }); + + it('can detect boolean (true) value', () => { + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable(true) ).toBe(VariableType.BOOLEAN); + }); + + it('can detect null value', () => { + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable(null) ).toBe(VariableType.NULL); + }); + + it('can detect undefined value', () => { + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable(undefined) ).toBe(VariableType.UNDEFINED); + }); + + describe('with entities', () => { + + interface CarDTO extends DTO { + readonly model: string; + } + + interface Car extends Entity { + getModel() : string; + } + + let carFactory : EntityFactoryImpl; + let CarEntity : EntityType; + + beforeEach(() => { + carFactory = ( + EntityFactoryImpl.create('Car') + .add( EntityPropertyImpl.create(typeCheckFactory,"model").setDefaultValue("Ford") ) + ); + CarEntity = carFactory.createEntityType(); + }); + + it('can detect entity types', () => { + const car : Car = CarEntity.create(); + expect( EntityPropertyImpl.getEntityPropertyTypeFromVariable(car) ).toBe(CarEntity); + }); + + }); + + }); + +}); diff --git a/entities/types/EntityPropertyImpl.ts b/entities/types/EntityPropertyImpl.ts new file mode 100644 index 0000000..2587363 --- /dev/null +++ b/entities/types/EntityPropertyImpl.ts @@ -0,0 +1,386 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { map } from "../../functions/map"; +import { uniq } from "../../functions/uniq"; +import { upperFirst } from "../../functions/upperFirst"; +import { LogService } from "../../LogService"; +import { + isArray, + isArrayOrUndefined, +} from "../../types/Array"; +import { isBoolean } from "../../types/Boolean"; +import { isNull } from "../../types/Null"; +import { + isInteger, + isNumber, +} from "../../types/Number"; +import { isString } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; +import { + isEntity, +} from "./Entity"; +import { + EntityProperty, +} from "./EntityProperty"; +import { EntityTypeCheckFactory } from "./EntityTypeCheckFactory"; +import { + EntityVariableType, + EntityVariableValue, +} from "./EntityVariableType"; +import { + VariableType, +} from "./VariableType"; + +const LOG = LogService.createLogger('EntityPropertyImpl'); + +export class EntityPropertyImpl + implements EntityProperty { + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// #create /////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Create an entity property. + * + * @param entityTypeCheckFactory Type check function factory + * @param name The name of the property + * @param aliases The method aliases of the property + */ + public static create ( + entityTypeCheckFactory: EntityTypeCheckFactory, + name : string, + ...aliases : readonly string[] + ) : EntityPropertyImpl { + return new EntityPropertyImpl( + name, + aliases, + [], + undefined, + false, + true, + entityTypeCheckFactory, + ); + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// #createArray ///////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Create an array property. + * + * @param entityTypeCheckFactory + * @param name The name of the property + * @param aliases The method aliases of the property + */ + public static createArray ( + entityTypeCheckFactory: EntityTypeCheckFactory, + name : string, + ...aliases : readonly string[] + ) : EntityPropertyImpl { + return new EntityPropertyImpl( + name, + aliases, + [], + [], + true, + false, + entityTypeCheckFactory, + ); + } + + + /** + * Create an array property which may be undefined. + * + * @param entityTypeCheckFactory + * @param name The name of the property + * @param aliases The method aliases of the property + */ + public static createOptionalArray ( + entityTypeCheckFactory: EntityTypeCheckFactory, + name : string, + ...aliases : readonly string[] + ) : EntityPropertyImpl { + return new EntityPropertyImpl( + name, + aliases, + [], + undefined, + true, + true, + entityTypeCheckFactory, + ); + } + + + + + //////////////////////////////////////////////////////////////////////////// + //////////////////// #getEntityPropertyTypeFromVariable ////////////////// + //////////////////////////////////////////////////////////////////////////// + + + public static getEntityPropertyTypeFromVariable ( + value: EntityVariableValue + ) : EntityVariableType { + if (isString(value)) return VariableType.STRING; + if (isInteger(value)) return VariableType.INTEGER; + if (isNumber(value)) return VariableType.NUMBER; + if (isBoolean(value)) return VariableType.BOOLEAN; + if (isNull(value)) return VariableType.NULL; + if (isUndefined(value)) return VariableType.UNDEFINED; + if (isEntity(value)) return value.getEntityType(); + throw new TypeError(`The value was of unsupported type: "${value}"`) + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////// private properties ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * The name of the property. + * + * @private + */ + private readonly _name : string; + + /** + * Alias names of methods. + * + * These aliases are only used for methods. It will not create support for + * alias DTO properties. + * + * @private + */ + private readonly _methodAliases : readonly string[]; + + /** + * Type(s) of the property. + * + * @private + */ + private _types : readonly EntityVariableType[]; + + /** + * The default value of the property. + * + * @private + */ + private _defaultValue : EntityVariableValue; + + /** + * `true` if this property is an array type. + * + * @private + */ + private readonly _isArray : boolean; + + /** + * `true` if this property is may be undefined. + * + * @private + */ + private readonly _isOptional : boolean; + + /** + * Type check function factory + * + * @private + */ + private readonly _entityTypeCheckFactory : EntityTypeCheckFactory; + + //////////////////////////////////////////////////////////////////////////// + //////////////////////// protected constructor /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Construct the property entity. + * + * @param name The name of the property + * @param methodAliases Alias names for the property + * @param types Types of the property + * @param defaultValue Default value + * @param isArray True if this property is an array. + * @param isOptional True if this property may be undefined. + * @param entityTypeCheckFactory Type check factory + * @protected + */ + protected constructor ( + name : string, + methodAliases : readonly string[], + types : readonly EntityVariableType[], + defaultValue : EntityVariableValue, + isArray : boolean, + isOptional : boolean, + entityTypeCheckFactory : EntityTypeCheckFactory, + ) { + this._name = name; + this._methodAliases = methodAliases; + this._types = types; + this._defaultValue = defaultValue; + this._isArray = isArray; + this._isOptional = isOptional; + this._entityTypeCheckFactory = entityTypeCheckFactory; + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// public methods /////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + + /** + * @inheritDoc + */ + public getPropertyName () : string { + return this._name; + } + + /** + * @inheritDoc + */ + public getMethodAliases () : readonly string[] { + return this._methodAliases; + } + + /** + * @inheritDoc + */ + public isArray () : boolean { + return this._isArray; + } + + /** + * @inheritDoc + */ + public isOptional () : boolean { + return this._isOptional; + } + + /** + * @inheritDoc + */ + public getTypes () : readonly EntityVariableType[] { + return this._types; + } + + /** + * @inheritDoc + */ + public setTypes ( + ...types : readonly EntityVariableType[] + ): this { + this._types = types; + if (this._isArray) { + this._defaultValue = this._isOptional ? undefined : []; + } else { + this._defaultValue = this._entityTypeCheckFactory.createDefaultValueFromTypes(types); + } + return this; + } + + /** + * @inheritDoc + */ + public types ( + ...types : readonly EntityVariableType[] + ) : this { + return this.setTypes(...types); + } + + /** + * @inheritDoc + */ + public getDefaultValue () : EntityVariableValue { + return this._defaultValue; + } + + /** + * @inheritDoc + */ + public setDefaultValue (value: EntityVariableValue) : this { + + if (this._isArray) { + if (this._isOptional) { + if (!isArrayOrUndefined(value) ) { + LOG.warn(`Warning! The default value provided to .setDefaultValue() was not an array or undefined. This may be a bug. Value: `, value); + } + } else { + if (!isArray(value) ) { + LOG.warn(`Warning! The default value provided to .setDefaultValue() was not an array. This may be a bug. Value: `, value); + } + } + } + + this._defaultValue = value; + + if (!this._types.length) { + if (this._isArray) { + if (isArray(value)) { + this._types = uniq(map( + value, + (item) => EntityPropertyImpl.getEntityPropertyTypeFromVariable(item) + )); + } + } else { + this._types = uniq([ + EntityPropertyImpl.getEntityPropertyTypeFromVariable(value) + ]); + } + } + + return this; + } + + /** + * @inheritDoc + */ + public defaultValue (value: EntityVariableValue) : this { + return this.setDefaultValue(value); + } + + /** + * @inheritDoc + */ + public getGetterNames () : readonly string[] { + const propertyName : string = this.getPropertyName(); + const getterName : string = `get${ upperFirst(propertyName) }`; + return [ + getterName, + ...(map( + this.getMethodAliases(), + (alias) => `get${ upperFirst( alias ) }` + )), + ]; + } + + /** + * @inheritDoc + */ + public getSetterNames () : readonly string[] { + const propertyName : string = this.getPropertyName(); + return [ + `set${ upperFirst(propertyName) }`, + ...(map( + this.getMethodAliases(), + (alias) => `set${ upperFirst( alias ) }` + )), + `${ propertyName }`, + ...(map( + this.getMethodAliases(), + (alias) => `${ alias }` + )), + ]; + } + +} diff --git a/entities/types/EntityType.ts b/entities/types/EntityType.ts new file mode 100644 index 0000000..62ff3fc --- /dev/null +++ b/entities/types/EntityType.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { DTO } from "./DTO"; +import { Entity } from "./Entity"; +import { EntityMethod } from "./EntityMethod"; +import { EntityProperty } from "./EntityProperty"; + +/** + * Interface for entity types, e.g. the public API for static methods in an + * entity class. + */ +export interface EntityType< + D extends DTO, + T extends Entity, +> { + + /** + * The constructor + */ + new (dto ?: D | undefined) : T; + + /** + * The name of the entity. + */ + getEntityName() : string; + + /** + * Creates an entity with default values. + */ + create () : T; + + /** + * Creates an entity from a DTO object. + * + * @param dto + */ + createFromDTO ( + dto : D, + ) : T; + + /** + * Return DTO property configurations. + */ + getProperties () : readonly EntityProperty[]; + + /** + * Returns declared static methods for the type. + */ + getStaticMethods () : EntityMethod[]; + + /** + * Returns `true` if value is type of the entity. + * + * @param value + */ + isEntity (value: unknown): value is T; + + /** + * Returns a string explaining why the value is not a type of this entity, or otherwise that it is. + * + * @param value + */ + explainEntity (value: unknown): string; + + /** + * Returns `true` if value is type of the entity DTO object. + * + * @param value + */ + isDTO (value: unknown): value is D; + +} + +export function isEntityType (value : unknown) : value is EntityType { + return ( + isObject(value) + && isFunction(value?.getEntityName) + && isFunction(value?.create) + && isFunction(value?.createFromDTO) + && isFunction(value?.getProperties) + && isFunction(value?.isEntity) + && isFunction(value?.explainEntity) + && isFunction(value?.isDTO) + ); +} diff --git a/entities/types/EntityTypeCheckFactory.ts b/entities/types/EntityTypeCheckFactory.ts new file mode 100644 index 0000000..4e4b862 --- /dev/null +++ b/entities/types/EntityTypeCheckFactory.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ChainOperation } from "./ChainOperation"; +import { + TypeCheckFn, + TypeExplainFn, +} from "./EntityFactory"; +import { + EntityVariableType, + EntityVariableValue, +} from "./EntityVariableType"; + +/** + * + */ +export interface EntityTypeCheckFactory { + + /** + * + * @param types + */ + createDefaultValueFromTypes ( + types: readonly EntityVariableType[], + ) : EntityVariableValue; + + /** + * + * @param types + * @param op + * @param useDtoCheck + */ + createChainedTypeCheckFunction ( + op: ChainOperation, + types: readonly EntityVariableType[], + useDtoCheck : boolean | "both", + ) : TypeCheckFn; + + /** + * + * @param op + * @param types + * @param useDtoCheck + */ + createChainedTypeExplainFunction ( + op: ChainOperation, + types: readonly EntityVariableType[], + useDtoCheck : boolean | "both", + ) : TypeExplainFn; + + /** + * + * @param item + * @param useDtoCheck Indicates that isDTO should be used instead of isEntity. + * @protected + */ + createTypeCheckFunction ( + item: EntityVariableType, + useDtoCheck : boolean | "both", + ) : TypeCheckFn; + + /** + * + * @param types + */ + getTypeNameList ( + types: readonly EntityVariableType[] + ) : string[]; + + /** + * + * @param item + */ + getTypeName ( item: EntityVariableType ) : string; + +} diff --git a/entities/types/EntityTypeCheckFactoryImpl.test.ts b/entities/types/EntityTypeCheckFactoryImpl.test.ts new file mode 100644 index 0000000..4af5743 --- /dev/null +++ b/entities/types/EntityTypeCheckFactoryImpl.test.ts @@ -0,0 +1,568 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { describe } from "@jest/globals"; +import { ChainOperation } from "./ChainOperation"; +import { DTO } from "./DTO"; +import { Entity } from "./Entity"; +import { EntityFactoryImpl } from "./EntityFactoryImpl"; +import { EntityTypeCheckFactoryImpl } from "./EntityTypeCheckFactoryImpl"; +import { EntityTypeRegistry } from "./EntityTypeRegistry"; +import { EntityTypeRegistryImpl } from "./EntityTypeRegistryImpl"; +import { VariableType } from "./VariableType"; + +describe('EntityTypeCheckFactoryImpl', () => { + + let registry : EntityTypeRegistry; + let factory : EntityTypeCheckFactoryImpl; + + beforeEach( () => { + registry = EntityTypeRegistryImpl.create(); + factory = EntityTypeCheckFactoryImpl.create(registry); + }); + + afterEach(() => { + EntityFactoryImpl.destroy(); + }); + + describe('.createTypeCheckFn', () => { + + it('can create a test function for null values', () => { + const fn = factory.createTypeCheckFunction(VariableType.NULL, false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(null) ).toBe(true); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for undefined values', () => { + const fn = factory.createTypeCheckFunction(VariableType.UNDEFINED, false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(true); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for boolean values', () => { + const fn = factory.createTypeCheckFunction(VariableType.BOOLEAN, false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(true); + expect( fn(false) ).toBe(true); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for number values', () => { + const fn = factory.createTypeCheckFunction(VariableType.NUMBER, false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(true); + expect( fn(123.456) ).toBe(true); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for integer values', () => { + const fn = factory.createTypeCheckFunction(VariableType.INTEGER, false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(true); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for enum values', () => { + + enum FooOrBarType { + FOO = "foo", + BAR = "bar" + } + + const fn = factory.createTypeCheckFunction( FooOrBarType , false); + + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + + expect( fn("foo") ).toBe(true); + expect( fn("bar") ).toBe(true); + }); + + it('can create a test function for entity values', () => { + + enum GearType { + AUTOMATIC = "AUTOMATIC", + MANUAL = "MANUAL" + } + + interface CarDTO extends DTO { + readonly model: string; + readonly gear: GearType; + } + + interface Car extends Entity { + getModel() : string; + setModel(model: string) : this; + getGear() : GearType; + setGear(model: GearType) : this; + } + + const carFactory = ( + EntityFactoryImpl.create('Car') + .add( EntityFactoryImpl.createProperty("model").setDefaultValue("Ford") ) + .add( EntityFactoryImpl.createProperty("gear").setTypes(GearType).setDefaultValue(GearType.AUTOMATIC) ) + ); + + const CarEntity = carFactory.createEntityType('CarEntity'); + + const fn = factory.createTypeCheckFunction( CarEntity , false); + + expect( fn( CarEntity.create() ) ) .toBe(true); + + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + expect( fn("foo") ).toBe(false); + expect( fn("bar") ).toBe(false); + }); + + }); + + describe('.createChainedTypeCheckFunction', () => { + + it('can create a test function for null values', () => { + const fn = factory.createChainedTypeCheckFunction(ChainOperation.OR, [VariableType.NULL], false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(null) ).toBe(true); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for undefined values', () => { + const fn = factory.createChainedTypeCheckFunction(ChainOperation.OR, [VariableType.UNDEFINED], false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(true); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for boolean values', () => { + const fn = factory.createChainedTypeCheckFunction(ChainOperation.OR, [VariableType.BOOLEAN], false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(true); + expect( fn(false) ).toBe(true); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for number values', () => { + const fn = factory.createChainedTypeCheckFunction(ChainOperation.OR, [VariableType.NUMBER], false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(true); + expect( fn(123.456) ).toBe(true); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for integer values', () => { + const fn = factory.createChainedTypeCheckFunction(ChainOperation.OR, [VariableType.INTEGER], false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(true); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for integer or string values', () => { + const fn = factory.createChainedTypeCheckFunction(ChainOperation.OR, [VariableType.INTEGER, VariableType.STRING], false); + + expect( fn(123) ).toBe(true); + expect( fn("hello world") ).toBe(true); + + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + }); + + it('can create a test function for integer or undefined values', () => { + const fn = factory.createChainedTypeCheckFunction(ChainOperation.OR, [VariableType.INTEGER, VariableType.UNDEFINED], false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(true); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(true); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + }); + + it('can create a test function for enum values', () => { + + enum FooOrBarType { + FOO = "foo", + BAR = "bar" + } + + const fn = factory.createChainedTypeCheckFunction(ChainOperation.OR, [FooOrBarType], false ); + + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + expect( fn("hello world") ).toBe(false); + + expect( fn("foo") ).toBe(true); + expect( fn("bar") ).toBe(true); + }); + + }); + + describe('#createChainedTypeExplainFunction', () => { + + it('can create an explain function for null values', () => { + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [VariableType.NULL], false); + expect( fn({name : 'John', age: 20}) ).toBe('not null'); + expect( fn({name : 'John', age: null}) ).toBe('not null'); + expect( fn({name : 123, age: 30}) ).toBe('not null'); + expect( fn({age: 30}) ).toBe('not null'); + expect( fn({name : 123}) ).toBe('not null'); + expect( fn(123) ).toBe('not null'); + expect( fn(null) ).toBe('OK'); + expect( fn(undefined) ).toBe('not null'); + expect( fn({}) ).toBe('not null'); + expect( fn([]) ).toBe('not null'); + expect( fn(true) ).toBe('not null'); + expect( fn(false) ).toBe('not null'); + expect( fn("hello world") ).toBe('not null'); + }); + + it('can create an explain function for undefined values', () => { + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [VariableType.UNDEFINED], false); + expect( fn({name : 'John', age: 20}) ).toBe('not undefined'); + expect( fn({name : 'John', age: null}) ).toBe('not undefined'); + expect( fn({name : 123, age: 30}) ).toBe('not undefined'); + expect( fn({age: 30}) ).toBe('not undefined'); + expect( fn({name : 123}) ).toBe('not undefined'); + expect( fn(123) ).toBe('not undefined'); + expect( fn(123.456) ).toBe('not undefined'); + expect( fn(null) ).toBe('not undefined'); + expect( fn(undefined) ).toBe('OK'); + expect( fn({}) ).toBe('not undefined'); + expect( fn([]) ).toBe('not undefined'); + expect( fn(true) ).toBe('not undefined'); + expect( fn(false) ).toBe('not undefined'); + expect( fn("hello world") ).toBe('not undefined'); + }); + + it('can create an explain function for boolean values', () => { + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [VariableType.BOOLEAN], false); + expect( fn({name : 'John', age: 20}) ).toBe('not boolean'); + expect( fn({name : 'John', age: null}) ).toBe('not boolean'); + expect( fn({name : 123, age: 30}) ).toBe('not boolean'); + expect( fn({age: 30}) ).toBe('not boolean'); + expect( fn({name : 123}) ).toBe('not boolean'); + expect( fn(123) ).toBe('not boolean'); + expect( fn(123.456) ).toBe('not boolean'); + expect( fn(null) ).toBe('not boolean'); + expect( fn(undefined) ).toBe('not boolean'); + expect( fn({}) ).toBe('not boolean'); + expect( fn([]) ).toBe('not boolean'); + expect( fn(true) ).toBe('OK'); + expect( fn(false) ).toBe('OK'); + expect( fn("hello world") ).toBe('not boolean'); + }); + + it('can create an explain function for number values', () => { + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [VariableType.NUMBER], false); + expect( fn({name : 'John', age: 20}) ).toBe('not number'); + expect( fn({name : 'John', age: null}) ).toBe('not number'); + expect( fn({name : 123, age: 30}) ).toBe('not number'); + expect( fn({age: 30}) ).toBe('not number'); + expect( fn({name : 123}) ).toBe('not number'); + expect( fn(123) ).toBe('OK'); + expect( fn(123.456) ).toBe('OK'); + expect( fn(null) ).toBe('not number'); + expect( fn(undefined) ).toBe('not number'); + expect( fn({}) ).toBe('not number'); + expect( fn([]) ).toBe('not number'); + expect( fn(true) ).toBe('not number'); + expect( fn(false) ).toBe('not number'); + expect( fn("hello world") ).toBe('not number'); + }); + + it('can create an explain function for integer values', () => { + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [VariableType.INTEGER], false); + expect( fn({name : 'John', age: 20}) ).toBe('not integer'); + expect( fn({name : 'John', age: null}) ).toBe('not integer'); + expect( fn({name : 123, age: 30}) ).toBe('not integer'); + expect( fn({age: 30}) ).toBe('not integer'); + expect( fn({name : 123}) ).toBe('not integer'); + expect( fn(123) ).toBe('OK'); + expect( fn(123.456) ).toBe('not integer'); + expect( fn(null) ).toBe('not integer'); + expect( fn(undefined) ).toBe('not integer'); + expect( fn({}) ).toBe('not integer'); + expect( fn([]) ).toBe('not integer'); + expect( fn(true) ).toBe('not integer'); + expect( fn(false) ).toBe('not integer'); + expect( fn("hello world") ).toBe('not integer'); + }); + + it('can create an explain function for integer or string values', () => { + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [VariableType.INTEGER, VariableType.STRING], false); + expect( fn({name : 'John', age: 20}) ).toBe(`not one of:\n - integer\n - string`); + expect( fn({name : 'John', age: null}) ).toBe(`not one of:\n - integer\n - string`); + expect( fn({name : 123, age: 30}) ).toBe(`not one of:\n - integer\n - string`); + expect( fn({age: 30}) ).toBe(`not one of:\n - integer\n - string`); + expect( fn({name : 123}) ).toBe(`not one of:\n - integer\n - string`); + expect( fn(123) ).toBe('OK'); + expect( fn(123.456) ).toBe(`not one of:\n - integer\n - string`); + expect( fn(null) ).toBe(`not one of:\n - integer\n - string`); + expect( fn(undefined) ).toBe(`not one of:\n - integer\n - string`); + expect( fn({}) ).toBe(`not one of:\n - integer\n - string`); + expect( fn([]) ).toBe(`not one of:\n - integer\n - string`); + expect( fn(true) ).toBe(`not one of:\n - integer\n - string`); + expect( fn(false) ).toBe(`not one of:\n - integer\n - string`); + expect( fn("hello world") ).toBe('OK'); + }); + + it('can create an explain function for integer or undefined values', () => { + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [VariableType.INTEGER, VariableType.UNDEFINED], false); + expect( fn({name : 'John', age: 20}) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn({name : 'John', age: null}) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn({name : 123, age: 30}) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn({age: 30}) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn({name : 123}) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn(123) ).toBe('OK'); + expect( fn(123.456) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn(null) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn(undefined) ).toBe('OK'); + expect( fn({}) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn([]) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn(true) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn(false) ).toBe(`not one of:\n - integer\n - undefined`); + expect( fn("hello world") ).toBe(`not one of:\n - integer\n - undefined`); + }); + + it('can create an explain function for enum values', () => { + + enum FooOrBarType { + FOO = "foo", + BAR = "bar" + } + + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [FooOrBarType], false ); + + expect( fn({name : 'John', age: 20}) ).toBe('not enum (foo | bar)'); + expect( fn({name : 'John', age: null}) ).toBe('not enum (foo | bar)'); + expect( fn({name : 123, age: 30}) ).toBe('not enum (foo | bar)'); + expect( fn({age: 30}) ).toBe('not enum (foo | bar)'); + expect( fn({name : 123}) ).toBe('not enum (foo | bar)'); + expect( fn(123) ).toBe('not enum (foo | bar)'); + expect( fn(123.456) ).toBe('not enum (foo | bar)'); + expect( fn(null) ).toBe('not enum (foo | bar)'); + expect( fn(undefined) ).toBe('not enum (foo | bar)'); + expect( fn({}) ).toBe('not enum (foo | bar)'); + expect( fn([]) ).toBe('not enum (foo | bar)'); + expect( fn(true) ).toBe('not enum (foo | bar)'); + expect( fn(false) ).toBe('not enum (foo | bar)'); + expect( fn("hello world") ).toBe('not enum (foo | bar)'); + + expect( fn("foo") ).toBe('OK'); + expect( fn("bar") ).toBe('OK'); + }); + + it('can create an explain function for entity values', () => { + + enum GearType { + AUTOMATIC = "AUTOMATIC", + MANUAL = "MANUAL" + } + + interface CarDTO extends DTO { + readonly model: string; + readonly gear: GearType; + } + + interface Car extends Entity { + getModel() : string; + setModel(model: string) : this; + getGear() : GearType; + setGear(model: GearType) : this; + } + + const carFactory = ( + EntityFactoryImpl.create('Car') + .add( EntityFactoryImpl.createProperty("model").setDefaultValue("Ford") ) + .add( EntityFactoryImpl.createProperty("gear").setTypes(GearType).setDefaultValue(GearType.AUTOMATIC) ) + ); + + const CarEntity = carFactory.createEntityType('CarEntity'); + + const fn = factory.createChainedTypeExplainFunction(ChainOperation.OR, [CarEntity], false ); + + const car = CarEntity.create(); + const dto = car.getDTO(); + + expect( fn( car ) ) .toBe('OK'); + + expect( fn( dto ) ) .toBe('not CarEntity'); + expect( fn({name : 'John', age: 20}) ).toBe('not CarEntity'); + expect( fn({name : 'John', age: null}) ).toBe('not CarEntity'); + expect( fn({name : 123, age: 30}) ).toBe('not CarEntity'); + expect( fn({age: 30}) ).toBe('not CarEntity'); + expect( fn({name : 123}) ).toBe('not CarEntity'); + expect( fn(123) ).toBe('not CarEntity'); + expect( fn(123.456) ).toBe('not CarEntity'); + expect( fn(null) ).toBe('not CarEntity'); + expect( fn(undefined) ).toBe('not CarEntity'); + expect( fn({}) ).toBe('not CarEntity'); + expect( fn([]) ).toBe('not CarEntity'); + expect( fn(true) ).toBe('not CarEntity'); + expect( fn(false) ).toBe('not CarEntity'); + expect( fn("hello world") ).toBe('not CarEntity'); + expect( fn("foo") ).toBe('not CarEntity'); + expect( fn("bar") ).toBe('not CarEntity'); + }); + + + }); + + +}); diff --git a/entities/types/EntityTypeCheckFactoryImpl.ts b/entities/types/EntityTypeCheckFactoryImpl.ts new file mode 100644 index 0000000..c9ccc54 --- /dev/null +++ b/entities/types/EntityTypeCheckFactoryImpl.ts @@ -0,0 +1,275 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { EnumUtils } from "../../EnumUtils"; +import { find } from "../../functions/find"; +import { map } from "../../functions/map"; +import { uniq } from "../../functions/uniq"; +import { isReadonlyJsonAny } from "../../Json"; +import { LogService } from "../../LogService"; +import { isBoolean } from "../../types/Boolean"; +import { + isEnum, + isEnumType, +} from "../../types/Enum"; +import { + explainNot, + explainOk, + explainOneOf, +} from "../../types/explain"; +import { isNull } from "../../types/Null"; +import { + isInteger, + isNumber, +} from "../../types/Number"; +import { isString } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; +import { ChainOperation } from "./ChainOperation"; +import { DTO } from "./DTO"; +import { + TypeCheckFn, + TypeExplainFn, +} from "./EntityFactory"; +import { + EntityType, + isEntityType, +} from "./EntityType"; +import { EntityTypeCheckFactory } from "./EntityTypeCheckFactory"; +import { EntityTypeRegistry } from "./EntityTypeRegistry"; +import { + EntityVariableType, + EntityVariableValue, +} from "./EntityVariableType"; +import { TypeCheckFunctionUtils } from "./TypeCheckFunctionUtils"; +import { + isVariableType, + VariableType, +} from "./VariableType"; + +const LOG = LogService.createLogger( 'EntityTypeCheckFactoryImpl' ); + +/** + * + */ +export class EntityTypeCheckFactoryImpl + implements EntityTypeCheckFactory +{ + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////////// #create ////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + public static create ( + entities : EntityTypeRegistry + ) : EntityTypeCheckFactoryImpl { + return new EntityTypeCheckFactoryImpl(entities); + } + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////// private properties //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + private readonly _entities : EntityTypeRegistry; + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////////// #constructor /////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + protected constructor ( + entities: EntityTypeRegistry + ) { + this._entities = entities; + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////// #createDefaultValueFromTypes ///////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public createDefaultValueFromTypes ( + types: readonly EntityVariableType[], + ) : EntityVariableValue { + if ( types.length === 0 || types.includes(VariableType.UNDEFINED) ) return undefined; + if ( types.includes(VariableType.NULL) ) return null; + if ( types.includes(VariableType.STRING) ) return ""; + if ( types.includes(VariableType.NUMBER) ) return 0; + if ( types.includes(VariableType.INTEGER) ) return 0; + if ( types.includes(VariableType.BOOLEAN) ) return false; + + const Type : EntityVariableType | undefined = find( + types, + (item : EntityVariableType) => isEntityType(item) + ) as EntityType; + + if ( Type !== undefined ) { + return Type.create(); + } + + return undefined; + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////// #createChainedTypeCheckFunction //////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public createChainedTypeCheckFunction ( + op: ChainOperation, + types: readonly EntityVariableType[], + useDtoCheck : boolean | "both", + ) : TypeCheckFn { + const functions= map( + uniq(types), + (item: EntityVariableType) : TypeCheckFn => this.createTypeCheckFunction(item, useDtoCheck) + ); + return TypeCheckFunctionUtils.createChainedFunction(op, functions); + } + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////// #createTypeCheckFn //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public createTypeCheckFunction ( + item: EntityVariableType, + useDtoCheck : boolean | "both", + ) : TypeCheckFn { + + if ( isString(item) && !isVariableType(item) ) { + const Type = this._entities.findType(item); + if ( Type ) { + item = Type; + } else { + throw new TypeError(`EntityTypeCheckFactoryImpl.createTypeCheckFunction(): Could not initialize entity by name: ${item}`); + } + } + + if ( isEntityType(item) ) { + if (useDtoCheck === "both") { + const isDTO = item.isDTO.bind(item); + const isEntity = item.isEntity.bind(item); + return (value: unknown) : boolean => isDTO(value) || isEntity(value); + } + const isFn = useDtoCheck ? item.isDTO.bind( item ) : item.isEntity.bind( item ); + return (value: unknown) : boolean => isFn(value); + } else if (isEnumType(item)) { + let enumType = item; + return (value: unknown) : boolean => isEnum(enumType, value); + } + + switch (item) { + case VariableType.JSON: return (value: unknown) : boolean => isReadonlyJsonAny(value); + case VariableType.BOOLEAN: return (value: unknown) : boolean => isBoolean(value); + case VariableType.STRING: return (value: unknown) : boolean => isString(value); + case VariableType.INTEGER: return (value: unknown) : boolean => isInteger(value); + case VariableType.NUMBER: return (value: unknown) : boolean => isNumber(value); + case VariableType.NULL: return (value: unknown) : boolean => isNull(value); + case VariableType.UNDEFINED: return (value: unknown) : boolean => isUndefined(value); + default: throw new TypeError(`EntityTypeCheckFactoryImpl.createTypeCheckFunction: Unknown variable type: ${item}`); + } + + } + + + //////////////////////////////////////////////////////////////////////////// + //////////////////// #createChainedTypeExplainFunction /////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public createChainedTypeExplainFunction ( + op: ChainOperation, + types: readonly EntityVariableType[], + useDtoCheck : boolean | "both", + ) : TypeExplainFn { + if (!types.length) throw new TypeError(`createChainedTypeExplainFunction: There must be at least one type`); + try { + const isType = this.createChainedTypeCheckFunction(op, types, useDtoCheck); + const typeNames : string[] = this.getTypeNameList(types); + const explainNotOneOf : string = explainNot( explainOneOf(typeNames) ); + const ok = explainOk(); + return (value : unknown) : string => isType(value) ? ok : explainNotOneOf; + } catch (err) { + LOG.debug(`Error in createChainedTypeExplainFunction(): `, err); + throw new Error(`createChainedTypeExplainFunction: Error: ${err}`); + } + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// #getTypeNameList ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public getTypeNameList ( + types: readonly EntityVariableType[] + ) : string[] { + return map( + types, + (item: EntityVariableType) : string => this.getTypeName( item ), + ); + } + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// #getTypeName ////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public getTypeName ( + item: EntityVariableType + ) : string { + + if ( isString(item) && !isVariableType(item) ) { + const Type = this._entities.findType(item); + if ( Type ) { + item = Type; + } else { + throw new TypeError(`EntityFactoryImpl._getTypeName(): Could not initialize entity by name: ${item}`); + } + } + + if (isEntityType(item)) { + return item.getEntityName(); + } else if (isEnumType(item)) { + return `enum (${EnumUtils.getValues(item).join(' | ')})`; + } + + switch (item) { + case VariableType.JSON: return 'json'; + case VariableType.BOOLEAN: return 'boolean'; + case VariableType.STRING: return 'string'; + case VariableType.INTEGER: return 'integer'; + case VariableType.NUMBER: return 'number'; + case VariableType.NULL: return 'null'; + case VariableType.UNDEFINED: return 'undefined'; + default: throw new TypeError(`createTypeExplainFn: Unknown variable type: ${item}`); + } + } + + +} diff --git a/entities/types/EntityTypeRegistry.ts b/entities/types/EntityTypeRegistry.ts new file mode 100644 index 0000000..3cd1073 --- /dev/null +++ b/entities/types/EntityTypeRegistry.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { Entity } from "./Entity"; +import { EntityType } from "./EntityType"; + +/** + * Interface for entity type registry. + */ +export interface EntityTypeRegistry { + + /** + * Destroy global state. + */ + destroy () : this; + + /** + * Unregister previously registered entity type by name. + */ + deleteType ( name : string) : this; + + /** + * Check if entity exists by name. + * + * @param name + */ + hasType ( name : string) : boolean; + + /** + * Returns the entity by name, otherwise undefined. + * + * @param name + */ + findType ( name : string) : EntityType> | undefined; + + /** + * Register a type. + * + * @param name + * @param Type + */ + registerType ( + name : string, + Type: EntityType>, + ) : this; + +} diff --git a/entities/types/EntityTypeRegistryImpl.test.ts b/entities/types/EntityTypeRegistryImpl.test.ts new file mode 100644 index 0000000..66fdf2e --- /dev/null +++ b/entities/types/EntityTypeRegistryImpl.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { DTO } from "./DTO"; +import { Entity } from "./Entity"; +import { EntityFactoryImpl } from "./EntityFactoryImpl"; +import { EntityType } from "./EntityType"; +import { EntityTypeRegistryImpl } from "./EntityTypeRegistryImpl"; + +describe('EntityTypeRegistryImpl', () => { + + interface CarDTO extends DTO { + readonly model: string; + } + + interface Car extends Entity { + getModel() : string; + setModel(model: string) : this; + } + + let registry : EntityTypeRegistryImpl; + let carFactory : EntityFactoryImpl; + let CarEntity : EntityType; + + beforeEach(() => { + + registry = EntityTypeRegistryImpl.create() + + carFactory = ( + EntityFactoryImpl.create('Car') + .add( EntityFactoryImpl.createProperty("model").setDefaultValue("Ford") ) + ); + + CarEntity = carFactory.createEntityType('CarEntity'); + + }); + + afterEach(() => { + EntityFactoryImpl.destroy(); + }); + + describe('#create', () => { + it('can create registry', () => { + expect(registry).toBeDefined(); + }); + }); + + describe('.destroy', () => { + it('can destroy registry', () => { + expect( () => registry.destroy() ).not.toThrow(); + }); + }); + + describe('.registerType', () => { + it('can register type', () => { + expect( registry.registerType('type', CarEntity) ).toBe(registry); + }); + }); + + describe('.hasType', () => { + + it('can check if type is registered', () => { + registry.registerType('type', CarEntity); + expect( registry.hasType('type')).toBe(true); + }); + + it('can check if type is not registered', () => { + expect( registry.hasType('type')).toBe(false); + }); + + }); + + describe('.findType', () => { + + it('can find registered type', () => { + registry.registerType('type', CarEntity); + expect( registry.findType('type')).toBe(CarEntity); + }); + + it('cannot find unregistered type', () => { + expect( registry.findType('type')).toBe(undefined); + }); + + }); + + + describe('.deleteType', () => { + + it('can unregister type which is registered', () => { + registry.registerType('type', CarEntity); + expect( () => registry.deleteType('type')).not.toThrow(); + expect( registry.hasType('type')).toBe(false); + }); + + it('can unregister type which is not registered', () => { + expect( () => registry.deleteType('type')).not.toThrow(); + expect( registry.hasType('type')).toBe(false); + }); + + }); + +}); diff --git a/entities/types/EntityTypeRegistryImpl.ts b/entities/types/EntityTypeRegistryImpl.ts new file mode 100644 index 0000000..138e0da --- /dev/null +++ b/entities/types/EntityTypeRegistryImpl.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { has } from "../../functions/has"; +import { Entity } from "./Entity"; +import { + EntityType, + isEntityType, +} from "./EntityType"; +import { EntityTypeRegistry } from "./EntityTypeRegistry"; + +/** + * + */ +export class EntityTypeRegistryImpl + implements EntityTypeRegistry +{ + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////// private properties //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + private _entities : { + [key: string]: EntityType> + } = {}; + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////////// #create ////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Create an empty registry. + */ + public static create () : EntityTypeRegistryImpl { + return new EntityTypeRegistryImpl(); + } + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////////// #constructor /////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + /** + * Construct an empty registry. + * + * @protected + */ + protected constructor () { + this._entities = {}; + } + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////////// #destroy //////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public destroy () : this { + this._entities = {}; + return this; + } + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////// #deleteByName /////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public deleteType ( name : string) : this { + if ( has(this._entities, name) ) { + delete this._entities[name]; + } + return this; + } + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////// #hasEntityType /////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public hasType ( name : string) : boolean { + return has(this._entities, name); + } + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////// #findEntityType /////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public findType ( name : string) : EntityType> | undefined { + if ( !has(this._entities, name) ) { + return undefined; + } + return this._entities[name]; + } + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////// #registerType /////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + public registerType ( + name : string, + Type: EntityType>, + ) : this { + if (has(this._entities, name)) { + throw new TypeError(`EntityTypeRegistryImpl.registerType: Type exists already: ${name}`); + } + if (!isEntityType(Type)) { + throw new TypeError(`EntityTypeRegistryImpl.registerType: Type not EntityType: ${Type}`); + } + this._entities[name] = Type; + return this; + } + + +} diff --git a/entities/types/EntityVariableType.ts b/entities/types/EntityVariableType.ts new file mode 100644 index 0000000..a126a0e --- /dev/null +++ b/entities/types/EntityVariableType.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { Enum } from "../../types/Enum"; +import { Entity } from "./Entity"; +import { EntityType } from "./EntityType"; +import { VariableType } from "./VariableType"; + +/** + * + */ +export type EntityVariableType = EntityType> | Enum | VariableType | string; + +/** + * + */ +export type EntityVariableValue = Entity | string | number | boolean | null | undefined | Enum | EntityVariableValue[]; diff --git a/entities/types/Extendable.ts b/entities/types/Extendable.ts new file mode 100644 index 0000000..cc93963 --- /dev/null +++ b/entities/types/Extendable.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +/** + * Interface for extendable objects. + */ +export interface Extendable { + + /** + * Get component name. + */ + getName () : string; + + /** + * Set the name of object which to extend from. This can also be an URL. + * + * @param name + */ + extend (name : string) : this; + + /** + * Get the name of object which to extend from. This can also be an URL. + */ + getExtend () : string | undefined; + +} diff --git a/entities/types/ExtendableDTO.ts b/entities/types/ExtendableDTO.ts new file mode 100644 index 0000000..0083d38 --- /dev/null +++ b/entities/types/ExtendableDTO.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { DTO } from "./DTO"; +import { DTOWithName } from "./DTOWithName"; +import { DTOWithOptionalExtend } from "./DTOWithOptionalExtend"; + +/** + * Interface for extendable DTOs. + */ +export interface ExtendableDTO + extends + DTO, + DTOWithName, + DTOWithOptionalExtend +{ + + /** + * @inheritDoc + */ + readonly name : string; + + /** + * @inheritDoc + */ + readonly extend ?: string | undefined; + +} diff --git a/entities/types/ExtendableEntity.ts b/entities/types/ExtendableEntity.ts new file mode 100644 index 0000000..afba0f7 --- /dev/null +++ b/entities/types/ExtendableEntity.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { DTO } from "./DTO"; +import { Entity } from "./Entity"; +import { Extendable } from "./Extendable"; + +export interface ExtendableEntity + extends + Extendable, + Entity +{ + + +} diff --git a/entities/types/HyperComponent.ts b/entities/types/HyperComponent.ts new file mode 100644 index 0000000..358b0a8 --- /dev/null +++ b/entities/types/HyperComponent.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum HyperComponent { + Form = "fi.nor.form", + Table = "fi.nor.table", + TableRow = "fi.nor.table.row", + TableColumn = "fi.nor.table.column", + Button = "fi.nor.button", + ActionButton = "fi.nor.actionButton", + LinkButton = "fi.nor.linkButton", + Link = "fi.nor.link", + Article = "fi.nor.article", + Div = "fi.nor.div", + Span = "fi.nor.span", + H1 = "fi.nor.h1", + H2 = "fi.nor.h2", + H3 = "fi.nor.h3", + H4 = "fi.nor.h4", + H5 = "fi.nor.h5", + H6 = "fi.nor.h6", + Paragraph = "fi.nor.paragraph", + List = "fi.nor.list", + Image = "fi.nor.image", + Card = "fi.nor.card", + Accordion = "fi.nor.accordion", +} + +export function isHyperComponent (value: unknown) : value is HyperComponent { + return isEnum(HyperComponent, value); +} + +export function explainHyperComponent (value : unknown) : string { + return explainEnum("HyperComponent", HyperComponent, isHyperComponent, value); +} + +export function stringifyHyperComponent (value : HyperComponent) : string { + return stringifyEnum(HyperComponent, value); +} + +export function parseHyperComponent (value: any) : HyperComponent | undefined { + return parseEnum(HyperComponent, value) as HyperComponent | undefined; +} + +export function isHyperComponentOrUndefined (value: unknown): value is HyperComponent | undefined { + return isUndefined(value) || isHyperComponent(value); +} + +export function explainHyperComponentOrUndefined (value: unknown): string { + return isHyperComponentOrUndefined(value) ? explainOk() : explainNot(explainOr(['HyperComponent', 'undefined'])); +} diff --git a/entities/types/IsDTOTestFunction.ts b/entities/types/IsDTOTestFunction.ts new file mode 100644 index 0000000..1c9b15e --- /dev/null +++ b/entities/types/IsDTOTestFunction.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { DTO } from "./DTO"; +import { Entity } from "./Entity"; + +/** + * Type for DTO test function. + * @returns `true` if value is type of T. + */ +export interface IsDTOTestFunction { + (value : unknown) : value is T; +} + +/** + * Type for interface test function. + * @returns `true` if value is type of T. + */ +export interface IsInterfaceTestFunction< + D extends DTO, + T extends Entity, +> { + (value : unknown) : value is T; +} + +export interface IsInterfaceOrTestFunction< + D extends DTO, + T extends Entity, + X +> { + (value : unknown) : value is T | X; +} + +/** + * Type for DTO test function which supports other types. + * + * @returns `true` if value is type of D or T. + */ +export interface IsDTOOrTestFunction { + (value : unknown) : value is D | T; +} + +/** + * A function which explains results of DTO test functions. + * + * @returns Human readable explanation why a type check wasn't accepted or if it was. + */ +export interface IsDTOExplainFunction { + (value : unknown) : string; +} diff --git a/entities/types/JsonSerializable.ts b/entities/types/JsonSerializable.ts new file mode 100644 index 0000000..b14cc65 --- /dev/null +++ b/entities/types/JsonSerializable.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; + +export interface JsonSerializable { + + /** + * Returns internal value (e.g. JSON presentation of the entity). + */ + valueOf() : ReadonlyJsonObject; + + /** + * Returns JSON presentation of the entity. + */ + toJSON () : ReadonlyJsonObject; + +} diff --git a/entities/types/TextAlign.ts b/entities/types/TextAlign.ts new file mode 100644 index 0000000..a876b41 --- /dev/null +++ b/entities/types/TextAlign.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum TextAlign { + START = "start", + END = "end", + LEFT = "left", + RIGHT = "right", + CENTER = "center", + JUSTIFY = "justify", + JUSTIFY_ALL = "justify-all", + MATCH_PARENT = "match-parent", + INHERIT = "inherit", + INITIAL = "initial", + REVERT = "revert", + REVERT_LAYER = "revert-layer", + UNSET = "unset", +} + +export function isTextAlign (value: unknown) : value is TextAlign { + return isEnum(TextAlign, value); +} + +export function explainTextAlign (value : unknown) : string { + return explainEnum("TextAlign", TextAlign, isTextAlign, value); +} + +export function stringifyTextAlign (value : TextAlign) : string { + return stringifyEnum(TextAlign, value); +} + +export function parseTextAlign (value: any) : TextAlign | undefined { + return parseEnum(TextAlign, value) as TextAlign | undefined; +} + +export function isTextAlignOrUndefined (value: unknown): value is TextAlign | undefined { + return isUndefined(value) || isTextAlign(value); +} + +export function explainTextAlignOrUndefined (value: unknown): string { + return isTextAlignOrUndefined(value) ? explainOk() : explainNot(explainOr(['TextAlign', 'undefined'])); +} diff --git a/entities/types/TextDecorationLineType.ts b/entities/types/TextDecorationLineType.ts new file mode 100644 index 0000000..46acc52 --- /dev/null +++ b/entities/types/TextDecorationLineType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + explainEnum, + isEnum, + parseEnum, + stringifyEnum, +} from "../../types/Enum"; +import { + explainNot, + explainOk, + explainOr, +} from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum TextDecorationLineType { + NONE = "none", + UNDERLINE = "underline", + OVERLINE = "overline", + INITIAL = "initial", + INHERIT = "inherit", + LINE_THROUGH = "line-through", +} + +export function isTextDecorationLineType ( value: unknown) : value is TextDecorationLineType { + return isEnum(TextDecorationLineType, value); +} + +export function explainTextDecorationLineType ( value : unknown) : string { + return explainEnum("TextDecorationLine", TextDecorationLineType, isTextDecorationLineType, value); +} + +export function stringifyTextDecorationLineType ( value : TextDecorationLineType) : string { + return stringifyEnum(TextDecorationLineType, value); +} + +export function parseTextDecorationLineType ( value: any) : TextDecorationLineType | undefined { + return parseEnum(TextDecorationLineType, value) as TextDecorationLineType | undefined; +} + +export function isTextDecorationLineTypeOrUndefined ( value: unknown): value is TextDecorationLineType | undefined { + return isUndefined(value) || isTextDecorationLineType(value); +} + +export function explainTextDecorationLineTypeOrUndefined ( value: unknown): string { + return isTextDecorationLineTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['TextDecorationLine', 'undefined'])); +} diff --git a/entities/types/TextDecorationStyle.ts b/entities/types/TextDecorationStyle.ts new file mode 100644 index 0000000..238bfb0 --- /dev/null +++ b/entities/types/TextDecorationStyle.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + explainEnum, + isEnum, + parseEnum, + stringifyEnum, +} from "../../types/Enum"; +import { + explainNot, + explainOk, + explainOr, +} from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum TextDecorationStyle { + SOLID = "solid", + DOUBLE = "double", + DOTTED = "dotted", + DASHED = "dashed", + WAVY = "wavy", + INITIAL = "initial", + INHERIT = "inherit", +} + +export function isTextDecorationStyle (value: unknown) : value is TextDecorationStyle { + return isEnum(TextDecorationStyle, value); +} + +export function explainTextDecorationStyle (value : unknown) : string { + return explainEnum("TextDecorationStyle", TextDecorationStyle, isTextDecorationStyle, value); +} + +export function stringifyTextDecorationStyle (value : TextDecorationStyle) : string { + return stringifyEnum(TextDecorationStyle, value); +} + +export function parseTextDecorationStyle (value: any) : TextDecorationStyle | undefined { + return parseEnum(TextDecorationStyle, value) as TextDecorationStyle | undefined; +} + +export function isTextDecorationStyleOrUndefined (value: unknown): value is TextDecorationStyle | undefined { + return isUndefined(value) || isTextDecorationStyle(value); +} + +export function explainTextDecorationStyleOrUndefined (value: unknown): string { + return isTextDecorationStyleOrUndefined(value) ? explainOk() : explainNot(explainOr(['TextDecorationStyle', 'undefined'])); +} diff --git a/entities/types/TypeCheckFunctionUtils.test.ts b/entities/types/TypeCheckFunctionUtils.test.ts new file mode 100644 index 0000000..4e166ba --- /dev/null +++ b/entities/types/TypeCheckFunctionUtils.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isNumber } from "../../types/Number"; +import { isString } from "../../types/String"; +import { ChainOperation } from "./ChainOperation"; +import { TypeCheckFunctionUtils } from "./TypeCheckFunctionUtils"; + +describe('TypeCheckFunctionUtils', () => { + + describe('#createChainedFunction', () => { + + it('can create single operation function using OR', () => { + + const fn = TypeCheckFunctionUtils.createChainedFunction(ChainOperation.OR, [isString] ); + + expect( fn("hello world") ).toBe(true); + + expect( fn(123) ).toBe(false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(0) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + + }); + + it('can create single operation function using AND', () => { + + const fn = TypeCheckFunctionUtils.createChainedFunction(ChainOperation.AND, [isString] ); + + expect( fn("hello world") ).toBe(true); + + expect( fn(123) ).toBe(false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(123.456) ).toBe(false); + expect( fn(0) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + + }); + + it('can create multi operation function using OR', () => { + + const fn = TypeCheckFunctionUtils.createChainedFunction(ChainOperation.OR, [isString, isNumber] ); + + expect( fn(123) ).toBe(true); + expect( fn(123.456) ).toBe(true); + expect( fn(0) ).toBe(true); + expect( fn("hello world") ).toBe(true); + + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + + }); + + it('can create multi operation function using AND', () => { + + const isLower = (value: any) : boolean => value <= 200; + const isHigher = (value: any) : boolean => value >= 10; + + const fn = TypeCheckFunctionUtils.createChainedFunction(ChainOperation.AND, [isNumber, isLower, isHigher] ); + + expect( fn(123) ).toBe(true); + expect( fn(123.456) ).toBe(true); + + expect( fn(0) ).toBe(false); + expect( fn(1000) ).toBe(false); + expect( fn("hello world") ).toBe(false); + expect( fn({name : 'John', age: 20}) ).toBe(false); + expect( fn({name : 'John', age: null}) ).toBe(false); + expect( fn({name : 123, age: 30}) ).toBe(false); + expect( fn({age: 30}) ).toBe(false); + expect( fn({name : 123}) ).toBe(false); + expect( fn(null) ).toBe(false); + expect( fn(undefined) ).toBe(false); + expect( fn({}) ).toBe(false); + expect( fn([]) ).toBe(false); + expect( fn(true) ).toBe(false); + expect( fn(false) ).toBe(false); + + }); + + }); + +}); diff --git a/entities/types/TypeCheckFunctionUtils.ts b/entities/types/TypeCheckFunctionUtils.ts new file mode 100644 index 0000000..f4a3515 --- /dev/null +++ b/entities/types/TypeCheckFunctionUtils.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { reduce } from "../../functions/reduce"; +import { + ChainOperation, + getChainOperationFunction, +} from "./ChainOperation"; +import { TypeCheckFn } from "./EntityFactory"; + +/** + * + */ +export class TypeCheckFunctionUtils { + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////// #createChainedFunction /////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * + * @param op + * @param list + */ + public static createChainedFunction ( + op : ChainOperation, + list : readonly TypeCheckFn[], + ) : TypeCheckFn { + const operation = getChainOperationFunction(op); + const func = list.length ? reduce( + list, + (prev: TypeCheckFn | undefined, item: TypeCheckFn) : TypeCheckFn => { + if (prev === undefined) { + return (value: unknown) : boolean => item(value); + } else { + return (value: unknown) : boolean => operation(prev(value), item(value)); + } + }, + undefined, + ) : undefined; + if (func === undefined) { + throw new TypeError(`TypeCheckFunctionUtils.createChainedFunction: At least one test function must be defined`); + } + return func; + } + + +} diff --git a/entities/types/UnitType.ts b/entities/types/UnitType.ts new file mode 100644 index 0000000..67162f8 --- /dev/null +++ b/entities/types/UnitType.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +/** + * Units + */ +export enum UnitType { + + /** + * `cm` - Centimeters + */ + CM = "cm", + + /** + * `mm` - Millimeters + */ + MM = "mm", + + /** + * `in` - Inches + */ + IN = "in", + + /** + * `px` - Pixels + */ + PX = "px", + + /** + * `pt` - Points + */ + PT = "pt", + + /** + * `pc` - Picas + */ + PC = "pc", + + /** + * `em` - Relative to the font-size of the element + */ + EM = "em", + + /** + * `ex` - Relative to x-height of the current font + */ + EX = "ex", + + /** + * `ch` - Relative to width of the "0" (zero) + */ + CH = "ch", + + /** + * `rem` - Relative to font-size of the root element + */ + REM = "rem", + + /** + * `vw` - Relative to 1% of the width of the viewport + */ + VW = "vw", + + /** + * `vh` - Relative to 1% of the height of the viewport + */ + VH = "vh", + + /** + * `vmin` - Relative to 1% of viewport's smaller dimension + */ + VMIN = "vmin", + + /** + * `vmax` - Relative to 1% of viewport's larger dimension + */ + VMAX = "vmax", + + /** + * `%` - Relative to the parent element + */ + PERCENT = "%", + +} + +export function isUnitType (value: unknown) : value is UnitType { + return isEnum(UnitType, value); +} + +export function explainUnitType (value : unknown) : string { + return explainEnum("UnitType", UnitType, isUnitType, value); +} + +export function stringifyUnitType (value : UnitType) : string { + return stringifyEnum(UnitType, value); +} + +export function parseUnitType (value: any) : UnitType | undefined { + return parseEnum(UnitType, value) as UnitType | undefined; +} + +export function isUnitTypeOrUndefined (value: unknown): value is UnitType | undefined { + return isUndefined(value) || isUnitType(value); +} + +export function explainUnitTypeOrUndefined (value: unknown): string { + return isUnitTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['UnitType', 'undefined'])); +} diff --git a/entities/types/VariableType.ts b/entities/types/VariableType.ts new file mode 100644 index 0000000..35386a3 --- /dev/null +++ b/entities/types/VariableType.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { + explainEnum, + isEnum, + parseEnum, + stringifyEnum, +} from "../../types/Enum"; +import { + explainNot, + explainOk, + explainOr, +} from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum VariableType { + JSON = "json", + STRING = "string", + NUMBER = "number", + INTEGER = "integer", + BOOLEAN = "boolean", + NULL = "null", + UNDEFINED = "undefined", +} + +export function isVariableType (value: unknown) : value is VariableType { + return isEnum(VariableType, value); +} + +export function explainVariableType (value : unknown) : string { + return explainEnum("VariableType", VariableType, isVariableType, value); +} + +export function stringifyVariableType (value : VariableType) : string { + return stringifyEnum(VariableType, value); +} + +export function parseVariableType (value: any) : VariableType | undefined { + return parseEnum(VariableType, value) as VariableType | undefined; +} + +export function isVariableTypeOrUndefined (value: unknown): value is VariableType | undefined { + return isUndefined(value) || isVariableType(value); +} + +export function explainVariableTypeOrUndefined (value: unknown): string { + return isVariableTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['VariableType', 'undefined'])); +} diff --git a/entities/view/View.ts b/entities/view/View.ts new file mode 100644 index 0000000..f036e62 --- /dev/null +++ b/entities/view/View.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { + ReadonlyJsonAny, + ReadonlyJsonArray, + ReadonlyJsonArrayOf, + ReadonlyJsonObject, +} from "../../Json"; +import { TestCallbackNonStandard } from "../../types/TestCallback"; +import { Component } from "../component/Component"; +import { UnreparedComponentContent } from "../component/ComponentContent"; +import { ComponentDTO } from "../component/ComponentDTO"; +import { Seo } from "../seo/Seo"; +import { SeoDTO } from "../seo/SeoDTO"; +import { SeoEntity } from "../seo/SeoEntity"; +import { StyleDTO } from "../style/StyleDTO"; +import { ViewDTO } from "./ViewDTO"; +import { ComponentEntity } from "../component/ComponentEntity"; +import { StyleEntity } from "../style/StyleEntity"; +import { ExtendableEntity } from "../types/ExtendableEntity"; +import { Style } from "../style/Style"; + +/** + * Interface for Hyper views. + */ +export interface View + extends ExtendableEntity +{ + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// standard methods ////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + valueOf() : ReadonlyJsonObject; + + /** + * @inheritDoc + */ + toJSON () : ReadonlyJsonObject; + + /** + * + */ + getDTO () : ViewDTO; + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// name property ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + getName () : string; + setName (name : string) : this; + name (name : string) : this; + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////// extend property ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * @inheritDoc + */ + getExtend () : string | undefined; + + /** + * @inheritDoc + */ + setExtend (name : string | undefined) : this; + + /** + * @inheritDoc + */ + extend (name : string | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////// publicUrl property ////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * + */ + getPublicUrl () : string | undefined; + + /** + * + * @param value + */ + setPublicUrl (value : string | undefined) : this; + + /** + * + * @param value + */ + publicUrl (value : string | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + ///////////////////////////// language property ////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * + */ + getLanguage () : string | undefined; + + /** + * + * @param value + */ + setLanguage (value : string | undefined) : this; + + /** + * + * @param value + */ + language (value : string | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + /////////////////////////////// seo property ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + getSeo () : SeoEntity | undefined; + getSeoDTO () : SeoDTO | undefined; + setSeo (value: SeoEntity | Seo | SeoDTO | undefined) : this; + seo (value: SeoEntity | Seo | SeoDTO | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// style property //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + /** + * Returns the style entity. + */ + getStyle () : Style | undefined; + + /** + * Returns the style DTO. + */ + getStyleDTO () : StyleDTO | undefined; + + /** + * Sets the style. + * + * @param value + */ + setStyle (value : StyleEntity | Style | StyleDTO | undefined) : this; + + /** + * Sets the style. + * + * @param value + */ + style (value : StyleEntity | Style | StyleDTO | undefined) : this; + + + //////////////////////////////////////////////////////////////////////////// + //////////////////////////// content property //////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + + getContent () : readonly (string|Component|ComponentEntity|ComponentDTO)[]; + + getContentDTO () : readonly (string|Component|ComponentEntity|ComponentDTO)[]; + + /** + * Set inner content. + * + * @param value + */ + setContent (value : readonly (string|Component|ComponentEntity|ComponentDTO)[] ) : this; + + /** + * Add inner content. + * + * @param value + */ + add (value : UnreparedComponentContent ) : this; + + /** + * Add inner content. + * + * @param value + */ + addContent (value : UnreparedComponentContent ) : this; + + /** + * Add inner text content. + * + * @param value + */ + addText (value : string) : this; + + + //////////////////////////////////////////////////////////////////////////// + ////////////////////////////// meta property ///////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + getMeta () : ReadonlyJsonObject | undefined; + + /** + * + * @param value + */ + setMeta (value: ReadonlyJsonObject | undefined) : this; + + /** + * + * @param value + */ + meta (value: ReadonlyJsonObject | undefined) : this; + + /** + * Set automatic refresh of the view after a timeout. + * + * See also `.setTimestamp()`. + * + * @param value + */ + setRefresh (value: number) : this; + + /** + * Set automatic refresh of the view after a timeout. + * + * @param value + */ + setIntervalRefresh (value: number) : this; + + /** + * Set timestamp of the view. + * + * This should be in ISO format like `'2023-11-29T21:38:38.483Z'`. + * + * Together with `.setRefresh()` this enables the view to update by intervals. + * + * @param value + */ + setTimestamp (value: string) : this; + + /** + * Returns true if the meta property exists. + * + * @param name + */ + hasMetaProperty (name : string) : boolean; + + /** + * Returns the value of a meta property. + * @param name + */ + getMetaProperty (name : string) : any | undefined; + + /** + * Get a value of internal string meta property. + * + * @param name + */ + getMetaString (name : string) : string | undefined; + + /** + * Set a value of internal string meta property. + * + * @param name + * @param value + */ + setMetaString (name : string, value: string | undefined) : this; + + /** + * Get a value of internal number meta property. + * + * @param name + */ + getMetaNumber (name : string) : number | null | undefined; + + /** + * Set a value of internal number meta property. + * + * @param name + * @param value + */ + setMetaNumber (name : string, value: number | undefined) : this; + + /** + * Get a value of internal boolean meta property. + * + * @param name + */ + getMetaBoolean (name : string) : boolean | null | undefined; + + /** + * Set a value of internal boolean meta property. + * + * @param name + * @param value + */ + setMetaBoolean (name : string, value: boolean | undefined) : this; + + /** + * Get a value of internal object meta property. + * + * @param name + */ + getMetaObject (name : string) : ReadonlyJsonObject | null | undefined; + + /** + * Set a value of internal object meta property. + * + * @param name + * @param value + */ + setMetaObject (name : string, value: ReadonlyJsonObject | null | undefined) : this; + + /** + * Get a value of internal array meta property. + * + * @param name + */ + getMetaArray (name : string) : ReadonlyJsonArray | undefined; + + /** + * Get a value of internal array meta property. + * + * @param name + * @param isItemOf + */ + getMetaArrayOf ( + name : string, + isItemOf : TestCallbackNonStandard, + ) : ReadonlyJsonArrayOf | undefined; + + /** + * Set a value of internal array meta property. + * + * @param name + * @param value + */ + setMetaArray (name : string, value: ReadonlyJsonArray | undefined) : this; + +} diff --git a/entities/view/ViewDTO.ts b/entities/view/ViewDTO.ts new file mode 100644 index 0000000..d1eabdb --- /dev/null +++ b/entities/view/ViewDTO.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { + ComponentDTOContent, +} from "../component/ComponentContent"; +import { DTO } from "../types/DTO"; +import { ExtendableDTO } from "../types/ExtendableDTO"; +import { SeoDTO } from "../seo/SeoDTO"; +import { StyleDTO } from "../style/StyleDTO"; +import { DTOWithOptionalExtend } from "../types/DTOWithOptionalExtend"; +import { DTOWithName } from "../types/DTOWithName"; + +export interface ViewDTO + extends DTO, + DTOWithOptionalExtend, + DTOWithName, + ExtendableDTO +{ + readonly name : string; + readonly extend ?: string; + readonly publicUrl ?: string; + readonly language ?: string; + readonly seo ?: SeoDTO; + readonly style ?: StyleDTO; + readonly content ?: ComponentDTOContent; + readonly meta ?: ReadonlyJsonObject; +} + +/** + * + * @deprecated + */ +export function createViewDTO ( + name : string, + extend : string | undefined, + publicUrl : string | undefined, + language : string | undefined, + seo : SeoDTO | undefined, + content : ComponentDTOContent | undefined, + style : StyleDTO | undefined, + meta : ReadonlyJsonObject | undefined, +) : ViewDTO { + return { + name, + ...(extend !== undefined ? {extend} : {}), + ...(publicUrl !== undefined ? {publicUrl} : {}), + ...(seo !== undefined ? {seo} : {}), + ...(language !== undefined ? {language} : {}), + ...(content !== undefined ? {content} : {}), + ...(style !== undefined ? {style} : {}), + ...(meta !== undefined ? {meta} : {}), + }; +} diff --git a/entities/view/ViewEntity.ts b/entities/view/ViewEntity.ts new file mode 100644 index 0000000..774086d --- /dev/null +++ b/entities/view/ViewEntity.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { map } from "../../functions/map"; +import { LogUtils } from "../../LogUtils"; +import { isArray } from "../../types/Array"; +import { isString } from "../../types/String"; +import { + UnreparedComponentContentItem, + UnreparedComponentContent, + ComponentDTOContentItem, +} from "../component/ComponentContent"; +import { + ComponentEntity, + isComponent, + isComponentDTO, + isComponentEntity, +} from "../component/ComponentEntity"; +import { SeoEntity } from "../seo/SeoEntity"; +import { StyleEntity } from "../style/StyleEntity"; +import { VariableType } from "../types/VariableType"; +import { ViewDTO } from "./ViewDTO"; +import { EntityFactoryImpl } from "../types/EntityFactoryImpl"; +import { View } from "./View"; + +export const ViewEntityFactory = ( + EntityFactoryImpl.create('View') + .add( EntityFactoryImpl.createProperty("name").setTypes(VariableType.STRING) ) + .add( EntityFactoryImpl.createProperty("extend").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("publicUrl").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("language").setTypes(VariableType.STRING, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("seo").setTypes(SeoEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createProperty("style").setTypes(StyleEntity, VariableType.UNDEFINED) ) + .add( EntityFactoryImpl.createOptionalArrayProperty("content").setTypes(VariableType.STRING, ComponentEntity) ) + .add( EntityFactoryImpl.createProperty("meta").setTypes(VariableType.JSON, VariableType.UNDEFINED) ) +); + +export const BaseViewEntity = ViewEntityFactory.createEntityType(); + +export const isViewDTO = ViewEntityFactory.createTestFunctionOfDTO(); + +export const isView = ViewEntityFactory.createTestFunctionOfInterface(); + +export const explainViewDTO = ViewEntityFactory.createExplainFunctionOfDTO(); + +export const isViewDTOOrUndefined = ViewEntityFactory.createTestFunctionOfDTOorOneOf(VariableType.UNDEFINED); + +export const explainViewDTOOrUndefined = ViewEntityFactory.createExplainFunctionOfDTOorOneOf(VariableType.UNDEFINED); + + +/** + * Entity for Hyper views. + */ +export class ViewEntity + extends BaseViewEntity + implements + View +{ + + public static create ( + name ?: string | ViewDTO | undefined, + ) : ViewEntity { + return new ViewEntity( + name, + ); + } + + public constructor ( + name ?: string | ViewDTO | undefined, + ) { + if (isString(name)) { + super({name}); + } else { + super(name); + } + } + + public addContent ( value : UnreparedComponentContent ) : this { + + if ( isArray(value) ) { + const prevContent = this.getContentDTO(); + return this.setContent( [ + ...(prevContent ? prevContent : []), + ...map( + value, + (item: UnreparedComponentContentItem) : ComponentDTOContentItem => { + if (isComponentEntity(item)) { + return item.getDTO(); + } + if (isComponent(item)) { + return item.getDTO(); + } + return item; + } + ), + ]); + } + + if ( isString(value) || isComponentDTO(value) ) { + const prevContent = this.getContentDTO(); + return this.setContent( [ + ...(prevContent ? prevContent : []), + value, + ]); + } + + if ( isComponentEntity(value) || isComponent(value) ) { + const prevContent = this.getContentDTO(); + return this.setContent( [ + ...(prevContent ? prevContent : []), + value.getDTO(), + ]); + } + + console.log(`WOOT: value = `, value); + throw new TypeError(`${this.getEntityType().getEntityName()}.addContent: Invalid argument: ${LogUtils.stringifyValue(value)}`); + + } + + public add ( value : UnreparedComponentContent ) : this { + return this.addContent(value); + } + + public addText ( value : string ) : this { + return this.addContent(value); + } + + public getRefresh () : number | null | undefined { + return this.getMetaNumber("refresh"); + } + + public getIntervalRefresh () : number | null | undefined { + return this.getMetaNumber("refresh"); + } + + public setRefresh (value: number | undefined) : this { + return this.setMetaNumber("refresh", value); + } + + public setIntervalRefresh (value: number | undefined) : this { + return this.setRefresh(value); + } + + public getTimestamp () : string | undefined { + return this.getMetaString("timestamp"); + } + + public setTimestamp (value: string | undefined) : this { + return this.setMetaString("timestamp", value); + } + +} + +export function isViewEntity (value: unknown): value is ViewEntity { + return value instanceof ViewEntity; +} diff --git a/finId/hetu.test.ts b/finId/hetu.test.ts new file mode 100644 index 0000000..8c8d0dc --- /dev/null +++ b/finId/hetu.test.ts @@ -0,0 +1,78 @@ +import { + checkHetuString, + Hetu, + hetuChecksum, + HetuObject, + HetuSex, + parseHetuDate, + parseHetuObject, + parseHetuString, + parseSex +} from "./hetu"; + +const VALID_HETU_1 = '010171-1000'; +const VALID_HETU_1_YEAR = 1971; +const VALID_HETU_1_MONTH = 1; +const VALID_HETU_1_DAY = 1; + +const VALID_HETU_2 = '010171-1985'; +const INVALID_HETU_1 = '010171-1234'; + +describe.skip('HetuObject', () => { + + test('HetuObject is class', () => { + expect(new HetuObject(VALID_HETU_1)).toBeInstanceOf(HetuObject); + expect(new HetuObject(VALID_HETU_2)).toBeInstanceOf(HetuObject); + }); + + describe('#parseHetuObject', () => { + test('parseHetuObject can return HetuObject', () => { + expect(parseHetuObject(VALID_HETU_1)).toBeInstanceOf(HetuObject); + expect(parseHetuObject(VALID_HETU_2)).toBeInstanceOf(HetuObject); + }); + }); + + describe('#checkHetuString', () => { + test('can check valid hetu string', () => { + expect(checkHetuString(VALID_HETU_1)).toStrictEqual(true); + expect(checkHetuString(VALID_HETU_2)).toStrictEqual(true); + }); + test('can check invalid hetu string', () => { + expect(checkHetuString(INVALID_HETU_1)).toStrictEqual(false); + }); + }); + +}); + +describe.skip('parseHetuString / hetuChecksum / parseSex', () => { + + test('can parse valid hetu string', () => { + expect(parseHetuString(INVALID_HETU_1)).toBeDefined(); + expect(parseHetuString('010171-1985')).toBeDefined(); + expect(parseHetuString('010171-198-')).toBeUndefined(); + expect(parseHetuString('010171-19855')).toBeUndefined(); + expect(parseHetuString('010171-198')).toBeUndefined(); + expect(parseHetuString('010071-1985')).toBeUndefined(); + expect(parseHetuString('000171-1985')).toBeUndefined(); + expect(parseHetuString('0101A1-1985')).toBeUndefined(); + expect(parseHetuString('010171#1985')).toBeUndefined(); + }); + + test('can parse hetu checksum', () => { + const hetu : Hetu | undefined = parseHetuString(VALID_HETU_1); + expect( hetu && hetuChecksum(hetu)).toEqual ('5'); + }); + + test('can parse hetu date', () => { + const hetu : Hetu | undefined = parseHetuString(VALID_HETU_1); + expect(hetu && parseHetuDate(hetu) ).toEqual(new Date(VALID_HETU_1_YEAR, VALID_HETU_1_MONTH-1, VALID_HETU_1_DAY)); + }); + + test('can parse hetu sex', () => { + const hetu : Hetu | undefined = parseHetuString(VALID_HETU_1); + expect(hetu).not.toBe(undefined); + expect( hetu && parseSex(hetu) ).toEqual(HetuSex.FEMALE); + }); + +}); + diff --git a/finId/hetu.ts b/finId/hetu.ts new file mode 100644 index 0000000..dae05c6 --- /dev/null +++ b/finId/hetu.ts @@ -0,0 +1,176 @@ +/* + * Finnish Identity Number Library + * + * Copyright (C) 2022 by Heusala Group (http://www.hg.fi), + * Copyright (C) 2014 by Sendanor (http://www.sendanor.fi), + * Copyright (C) 2011-2014 by Jaakko-Heikki Heusala (http://www.jhh.me), + * Copyright (C) 2009 by Mux F-Production (http://mux.fi/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/* + * *** Tarkisteiden laskemisia *** + * luonut: Mux F-Production | http://mux.fi/ | 2009 + * käyttöoikeus: Sendanor - http://sendanor.fi/ + * Käyttöoikeus sisältää vapaan muokkauksen ja soveltamisen + * sekä käyttötarkoituksen (kaupallisuus, avoin lähdekoodi,...). + * + * created: Su 2009-05-17 + * updated: Su 2009-05-17 + * $Id: hetu.js 8373 2009-06-30 08:38:04Z jheusala $ + * ----- + * FUNKTIOT: + * - TarkistaHETU Tarkista henkilötunnuksen oikeellisuus. + * + * SUUNNITELMISSA: + * - pankkitilin oikeellisuuden laskenta (kotimaisen ja IBAN) + * + */ + +/** Calculate check sum for finnish hetu ID object */ +export function hetuChecksum (id: Hetu) : string { + // luodaan iso luku äsken luetuista numeroista ja samalla lasketaan tarkiste + let n = (id.n + id.yy*1000 + id.mm*100000 + id.dd*10000000)%31; + let s = '0123456789ABCDEFHJKLMNPRSTUVWXY'; + return s[n]; +} + +export function hetuParseCentury (x: string) : number | undefined { + switch(x) { + case '+': return 1800; + case '-': return 1900; + case 'A': return 2000; + } + return undefined; +} + +export enum HetuSex { + MALE = "male", + FEMALE = "female" +} + +export interface Hetu { + readonly x: string; + readonly dd : number; + readonly mm : number; + readonly yy : number; + readonly n : number; + readonly t : string; +} + +/** Parse hetu string to an object */ +export function parseHetuString (hetu: string) : Hetu | undefined { + // Tarkista henkilötunnus hetu (merkkijono muotoa PPKKVVXNNNT). + // dd = 01..31 (päivä) + // mm = 01..12 (kuukausi) + // yy = 00..99 (vuosi) + // x = "+" tarkoittaa 1800-lukua, "-" 1900-lukua ja "A" 2000-lukua + // n = 3-numeroinen yksilönumero (miehillä pariton, naisilla parillinen) + // t = tarkistemerkki (jokin seuraavista: 0123456789ABCDEFHJKLMNPRSTUVWXY) + + // vaaditaan 11 merkin pituus + if (hetu.length !== 11) { return; } + let dd = parseInt(hetu.substring(0, 2).replace('/^0+/', '').replace('/^$/', ''), 10); + let mm = parseInt(hetu.substring(2, 2).replace('/^0+/', '').replace('/^$/', ''), 10); + let yy = parseInt(hetu.substring(4, 2).replace('/^0+/', '').replace('/^$/', ''), 10); + let century = hetu[6].toUpperCase(); + let id = parseInt(hetu.substring(7, 3).replace('/^0+/', '').replace('/^$/', ''), 10); + let checksum = hetu[10].toUpperCase(); + if ((dd<1) || (dd>31)) { return; } + if ((mm<1) || (mm>12)) { return; } + if (isNaN(yy) || (yy<0)) { return; } + if ((century!=='+') && (century!=='-') && (century!=='A')) { return; } + if (isNaN(id) || (id<0)) { return; } + return { + x : century, + dd : dd, + mm : mm, + yy : yy, + n : id, + t : checksum + }; +} + +/** Check hetu from a string */ +export function checkParsedHetu (id: Hetu) : boolean { + if( (!id) || (id && (!id.t)) ) { return false; } + return hetuChecksum(id) === id.t; +} + +/** Parse date from hetu object */ +export function parseHetuDate (id : Hetu) : Date | undefined { + let century = hetuParseCentury(id.x); + if(century && id.mm && id.dd) { + return new Date(century+id.yy, id.mm-1, id.dd, 12); + } + return undefined; +} + +/** Parse sex */ +export function parseSex (parsed_hetu : Hetu) : HetuSex | undefined { + let n = parsed_hetu.n; + if((n === undefined) || (typeof n !== "number")) { return; } + /* jslint bitwise: false */ + /* jshint bitwise: false */ + n = n & 1; + /* jshint bitwise: true */ + /* jslint bitwise: true */ + switch(n) { + case 0: return HetuSex.FEMALE; + case 1: return HetuSex.MALE; + } +} + +export class HetuObject { + + private _value : Hetu | undefined; + + public constructor (hetu : string) { + this._value = parseHetuString(hetu); + } + + public change (h: string) : HetuObject { + this._value = parseHetuString(h); + return this; + } + + public check () : boolean { + return this._value ? checkParsedHetu(this._value) : false; + } + + public getDate () : Date | undefined { + return this._value ? parseHetuDate(this._value) : undefined; + } + + public getSex () : HetuSex | undefined { + return this._value ? parseSex(this._value) : undefined; + } + +} + +/** Parse hetu string and return it as an object */ +export function parseHetuObject (hetu : string) : HetuObject { + return new HetuObject(hetu); +} + +/** Check hetu from a string */ +export function checkHetuString (hetu: string) : boolean { + return parseHetuObject(hetu).check(); +} diff --git a/finId/pankkiviivakoodi.test.ts b/finId/pankkiviivakoodi.test.ts new file mode 100644 index 0000000..217a29d --- /dev/null +++ b/finId/pankkiviivakoodi.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + viivakoodiCreate, + viivakoodiCheck, + viivakoodiParse +} from './pankkiviivakoodi'; + +describe.skip('pankkiviivakoodi', () => { + + test('can create viivakoodi', () => { + expect(viivakoodiCreate( + 'FI9814283500171141', // iban + '100', // euros + '10', // cents + '13', // refnum + '2016-05-01' // duedate + )).toEqual('498142835001711410001001000000000000000000000013160501'); + }); + + test('can check valid viivakoodi', () => { + expect(viivakoodiCheck('498142835001711410001001000000000000000000000013160501')).toBe(true); + expect(viivakoodiCheck('010171-1234')).toBe(false); + expect(viivakoodiCheck('198142835001711410001001000000000000000000000013160501')).toBe(false); + + }); + + test('can parse viivakoodi version 4', () => { + const parsed = viivakoodiParse('498142835001711410001001000000000000000000000013160501'); + expect(parsed.iban).toEqual('FI9814283500171141'); + expect(parsed.refNum).toEqual('13'); + const duedate = new Date(parsed.dueDate); + expect(duedate.getDay).toBe(1); + expect(duedate.getMonth).toBe(5); + expect(duedate.getFullYear).toBe(2016); + expect(parsed.euros).toBe(100); + expect(parsed.cents).toBe(10); + }); + + test('can parse viivakoodi version 5', () => { + const parsed = viivakoodiParse('558101710000001220004829906000000559582243294671100131'); + expect(parsed.iban).toEqual('FI5810171000000122'); + expect(parsed.refNum).toEqual('559582243294671'); + const duedate = new Date(parsed.dueDate); + expect(duedate.getDay).toBe(31); + expect(duedate.getMonth).toBe(1); + expect(duedate.getFullYear).toBe(2010); + expect(parsed.euros).toBe(482); + expect(parsed.cents).toBe(99); + }); + +}); diff --git a/finId/pankkiviivakoodi.ts b/finId/pankkiviivakoodi.ts new file mode 100644 index 0000000..b605e30 --- /dev/null +++ b/finId/pankkiviivakoodi.ts @@ -0,0 +1,215 @@ +/* + * Finnish Identity Number Library + * + * Copyright (C) 2022 by Heusala Group (https://www.hg.fi), + * Copyright (C) 2014 by Sendanor (https://www.sendanor.fi), + * Copyright (C) 2011-2014 by Jaakko-Heikki Heusala (https://www.jhh.me), + * Copyright (C) 2009 by Mux F-Production (http://mux.fi/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { refNumParse } from './refNum'; +import { moment } from "../modules/moment"; +import { isString } from "../types/String"; + +export interface PankkiViivakoodi { + readonly iban: string; + readonly euros: number; + readonly cents: number; + readonly refNum: string | undefined; + readonly dueDate: string; +} + +/** Parse finnish IBAN as 16 numbers + * Example: 'FI21 1234 5600 0007 85', more examples: http://www.rbs.co.uk/corporate/international/g0/guide-to-international-business/regulatory-information/iban/iban-example.ashx + * @returns {string} The result as 16 numeric characters. + */ +export function parseFiIBAN (iban : string) : string | undefined { + iban = ('' + iban).trim().toLowerCase(); + const prefixString = iban.substring(0, 2); + if (prefixString !== 'fi') return undefined; + iban = iban.replace(/^[^0-9]+/, "").replace(/ +/g, ""); + if (iban?.length !== 16) return undefined; + return iban; +} + +/** */ +export function padZeros (num : string | number, l: number) : string { + num = '' + num; + let ll = num.length; + if (ll >= l) { + return num; + } + return new Array((l-ll)+1).join('0') + num; +} + +/** Parse finnish reference numbers and pad it + * @returns {string} The reference number as 20 long string padded with zeros. + */ +export function parseRefNum(num : string) : string { + num = (''+num).trim().replace(/[^0-9]/g, ""); + // debug.assert(num).is('string').maxLength(20); + if(num.length !== 20) { + return padZeros(num, 20); + } + // debug.assert(num).is('string').length(20).is('integer'); + return num; +} + +/** Parse cents and pad it. If the amount is wrong, use all zeros (8 characters). + * @returns {string} The reference number as 20 long string padded with zeros. + */ +export function parseCents( + euros : string | number | undefined, + cents ?: string | number | undefined +) : string { + + euros = padZeros(euros||'', 6); + cents = padZeros(cents||'', 2); + + if(euros.length !== 6) { + return '00000000'; + } + + let res; + if( (!euros) && (cents.length > 2) ) { + euros = ''; + res = padZeros('' + euros + cents, 8); + } else { + if(cents.length !== 2) { + return '00000000'; + } + res = padZeros('' + euros + cents, 8); + } + if(!(res && (res.length === 8))) { + return '00000000'; + } + // debug.assert(res).is('string').length(8).is('integer'); + return res; +} + +/** Parse dates as a string in format "YYMMDD" + * @returns {string} The date as a string + */ +export function parseDueDate(date : string) : string { + if(!date) { + return '000000'; + } + // FIXME: Use TimeService here + let str = moment(date).format("YYMMDD"); + // debug.assert(str).is('string').length(6).is('integer'); + return str; +} + +/** */ +export function viivakoodiCreate ( + iban: string, + euros: string, + cents: string, + refNum: string, + dueDate: string +) : string | undefined { + // debug.assert(opts).is('object'); + const ibanOrUndefined = parseFiIBAN(iban); + if (!ibanOrUndefined) return undefined; + cents = parseCents(euros, cents); + refNum = parseRefNum(refNum); + dueDate = parseDueDate(dueDate); + let viite = '4' + ibanOrUndefined + cents + "000" + refNum + dueDate; + // debug.assert(viite).is('string').length(54).is('integer'); + return viite; +} + +/** */ +export function viivakoodiCheck(code : string) : boolean { + if(!isString(code)) { return false; } + let version = code[0]; + if(! ( (version === '4') || (version === '5') ) ) { return false; } + if(code.length !== 54) { return false; } + //if(!is.integer(code)) { return false; } + return true; +} + +/** */ +export function viivakoodiParse4(code : string) : PankkiViivakoodi { + // debug.assert(code).is('string'); + + if(!viivakoodiCheck(code)) { + throw new TypeError("code is invalid: "+ code); + } + + // let version = code[0]; + // // debug.assert(version).is('string').equals('4'); + // + // let duedate = code.substring(1+16+6+2+3+20, 6); + // //debug.log('duedate = ', duedate); + + const refNum = refNumParse( code.substring(1+16+6+2+3, 20) ); + + let parsed : PankkiViivakoodi = { + iban : 'FI' + code.substring(1, 16), + euros : parseInt( code.substring(1+16, 6).replace(/^0+([0-9])/, "$1") , 10), + cents : parseInt( code.substring(1+16+6, 2).replace(/^0+([0-9])/, "$1") , 10), + refNum : refNum, + dueDate : moment( '20' + code.substring(1+16+6+2+3+20, 6), "YYYYMMDD" ).toISOString() + }; + + return parsed; +} + +/** + * @returns normal reference number + */ +export function parseRfRefNum(code: string) : string | undefined { + // debug.assert(code).is('string'); + if(code.substring(0, 2) === 'RF') { + return refNumParse( code.substring(4) ); + } +} + +/** */ +export function viivakoodiParse5(code: string) : PankkiViivakoodi { + // debug.assert(code).is('string'); + if(!viivakoodiCheck(code)) { + throw new TypeError("code is invalid: "+ code); + } + // let version = code[0]; + // // debug.assert(version).is('string').equals('5'); + let duedate = code.substring(1+16+6+2+23, 6); + //debug.log('duedate = ', duedate); + let parsed = { + iban: 'FI' + code.substring(1, 16), + euros: parseInt( code.substring(1+16, 6).replace(/^0+([0-9])/, "$1") , 10), + cents: parseInt( code.substring(1+16+6, 2).replace(/^0+([0-9])/, "$1") , 10), + refNum: parseRfRefNum('RF' + code.substring(1+16+6+2, 23)), + dueDate: moment( '20' + duedate, "YYYYMMDD" ).toISOString() + }; + return parsed; +} + +/** */ +export function viivakoodiParse(code: string) : PankkiViivakoodi { + // debug.assert(code).is('string'); + let version = code[0]; + if(version === '5') { + return viivakoodiParse5(code); + } + return viivakoodiParse4(code); +} diff --git a/finId/refNum.test.ts b/finId/refNum.test.ts new file mode 100644 index 0000000..8a7bab1 --- /dev/null +++ b/finId/refNum.test.ts @@ -0,0 +1,71 @@ +import { + refNumLeaveOnlyDigits, + refNumCalculateDigit, + refNumCreate, + refNumStrip, + refNumCheck, + refNumParse, + refNumCmp +} from './refNum'; + +describe('Referer number functions', () => { + + test('can remove chars and leave only digits from string', () => { + expect(refNumLeaveOnlyDigits('abc134')).toStrictEqual('134'); + }); + + test('can calculate reference number check digit', () => { + expect(refNumCalculateDigit('1')).toStrictEqual('3'); + expect(refNumCalculateDigit('41234555131')).toStrictEqual('0'); + expect(refNumCalculateDigit('4123455513')).toStrictEqual('1'); + expect(refNumCalculateDigit('412345551')).toStrictEqual('2'); + }); + + test('can create reference number by adding check-digit to num', () => { + expect(refNumCreate('1')).toStrictEqual('13'); + expect(refNumCreate('41234555131')).toStrictEqual('412345551310'); + expect(refNumCreate('4123455513')).toStrictEqual('41234555131'); + expect(refNumCreate('412345551')).toStrictEqual('4123455512'); + }); + + test('can remove check num from reference number', () => { + expect(refNumStrip('412345551310')).toStrictEqual('41234555131'); + expect(refNumStrip('13')).toStrictEqual('1'); + expect(refNumStrip('4123455512')).toStrictEqual('412345551'); + }); + + test('can check reference number', () => { + expect(refNumCheck('13')).toStrictEqual(true); + expect(refNumCheck('412345551310')).toStrictEqual(true); + expect(refNumCheck('41234555131')).toStrictEqual(true); + expect(refNumCheck('')).toStrictEqual(false); + expect(refNumCheck('1')).toStrictEqual(false); + expect(refNumCheck('1')).toStrictEqual(false); + expect(refNumCheck('12')).toStrictEqual(false); + expect(refNumCheck('412345551311')).toStrictEqual(false); + expect(refNumCheck('41234555132')).toStrictEqual(false); + }); + + test('can parse valid reference number', () => { + expect(refNumParse('13')).toStrictEqual('13'); + expect(refNumParse('412345551310')).toStrictEqual('412345551310'); + expect(refNumParse('41234555131')).toStrictEqual('41234555131'); + }); + + test('can parse invalid reference number', () => { + expect(refNumParse('1')).toBeUndefined(); + expect(refNumParse('12')).toBeUndefined(); + expect(refNumParse('41234555132')).toBeUndefined(); + }); + + test('can compare reference numbers and return true if equal', () => { + expect(refNumCmp('13', '13')).toStrictEqual(true); + expect(refNumCmp('412345551310', '412345551310')).toStrictEqual(true); + expect(refNumCmp('412345551310', '41234555131')).toStrictEqual(false); + }); + + test('cannot compare invalid reference numbers', () => { + expect(refNumCmp('412345551311', '412345551311')).toStrictEqual(false); + }); + +}); diff --git a/finId/refNum.ts b/finId/refNum.ts new file mode 100644 index 0000000..811bb3c --- /dev/null +++ b/finId/refNum.ts @@ -0,0 +1,160 @@ +/* + * Finnish Identity Number Library + * + * Copyright (C) 2022 by Heusala Group (http://www.hg.fi), + * Copyright (C) 2014 by Sendanor (http://www.sendanor.fi), + * Copyright (C) 2011-2014 by Jaakko-Heikki Heusala (http://www.jhh.me), + * Copyright (C) 2009 by Mux F-Production (http://mux.fi/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * Finnish Referer Number Library (for JavaScript) + * by: eXtranium 2009 http://extranium.net/ + * (c) Sendanor 2009 http://www.sendanor.fi/ + * + * created: Mo 2009-01-05 + * updated: We 2009-01-07 + * ----- + * + * Functions: + ** refNumLeave_only_digits - remove non-digits from string + ** refNumCalculate_digit - calculate check-digit needed in referer number + ** refNumCreate - generate referer number + ** refNumCheck - check referer number + * + */ + +/** Remove non-digits from string + * + * Removes any non-digit (0..9) chars from string s + * + */ +export function refNumLeaveOnlyDigits (s: string | number): string { + let leading = true; + s = '' + s; + let t = ''; // new string + let l = s.length; // length of old string + for ( let i = 0 ; i < l ; i++ ) { + if ( leading ) { + if ( s[i] === '0' ) { + continue; + } else if ( (s[i] >= '0') && (s[i] <= '9') ) { + leading = false; + } + } + if ( (s[i] >= '0') && (s[i] <= '9') ) t += s[i]; // get only the digits + } + return t; +} + +/** Calculate check-digit needed in referer number + * + * function calculates check-digit for complete referer number + * + * `num = string of digits ('0'..'9')` + * + */ +export function refNumCalculateDigit (num: string | number): string | undefined { + + num = refNumLeaveOnlyDigits(num); // we need only digits 0..9 + + let index = num.length; // set char pointer to end-of-string + + // real referer number is 1..19 + 1 chars long + if ( !index || (index > 19) ) { + return undefined; + } + + let calc = 9; // sum calculator + let mult = 0; // multiplier aka weight (7, 3, 1, 7, 3, 1,...) + + // loop with all digits in num backwards + while ( index-- ) { + if ( !(mult >>= 1) ) mult = 7; // update weight (7, 3, 1, 7, 3, 1,...) + calc += mult * (num[index].charCodeAt(0) - 48); // add weight*digit to sum + } // while + + return String.fromCharCode(57 - (calc % 10)); +} + +/** Generate referer number + * + * create referer number by adding check-digit to num + * `num = string of digits ('0'..'9')` + */ +export function refNumCreate (num: string | number): string | undefined { + const numString = refNumLeaveOnlyDigits(num); + const digit = refNumCalculateDigit(numString); + if ( digit === undefined ) { + return undefined; + } + return numString + digit; +} + +/** Remove check num from reference number + * check is referer number legal + * `refnum = referer number, string of digits '0'..'9'` + */ +export function refNumStrip (refnum: string | number): string | undefined { + + refnum = '' + refnum; + + // this becomes to fixed string + let t = ''; + + for ( let i = 0 ; i < refnum.length ; i++ ) { + let c = refnum[i]; + if ( c == ' ' ) continue; // space-bar allowed (but not used in calculations) + + // non-digits not allowed + if ( (c < '0') || (c > '9') ) { + return undefined; + } + + t += c; + } // for + + return t.substring(0, t.length - 1); +} + +/** Check referer number */ +export function refNumCheck (refNum: string | number): boolean { + const refNumStripped = refNumStrip(refNum); + const refNumCreated = refNumStripped !== undefined ? refNumCreate(refNumStripped) : undefined; + return refNumCreated !== undefined && refNumCreated === refNum; +} + +/** Parse reference number + * @returns {string|undefined} The parsed reference number if valid, otherwise undefined. + */ +export function refNumParse (refnum: string | number): string | undefined { + refnum = refNumLeaveOnlyDigits(refnum); + if ( refNumCheck(refnum) ) { + return refnum; + } +} + +/** Compare reference numbers + * @returns {boolean} True if a equals to b + */ +export function refNumCmp (a: string | number, b: string | number): boolean { + const a2 : string | undefined = refNumParse(a); + const b2 : string | undefined = refNumParse(b); + return !!a2 && !!b2 && (a2 === b2); +} diff --git a/finId/yTunnus.test.ts b/finId/yTunnus.test.ts new file mode 100644 index 0000000..fcf0719 --- /dev/null +++ b/finId/yTunnus.test.ts @@ -0,0 +1,44 @@ +import { yTunnusWithSum, yTunnusParse, yTunnusCheck, yTunnusCompare, yTunnusCheckNoThrow } from './yTunnus'; + +describe('Y-tunnus (Finnish business ID functions', () => { + + test('can generate Finnish business ID', () => { + expect(yTunnusWithSum('2092540')).toBe('2092540-6'); + expect(yTunnusWithSum('2256931')).toBe('2256931-0'); + expect(yTunnusWithSum('0709019')).toBe('0709019-2'); + expect(yTunnusWithSum( '709019')).toBe('0709019-2'); + }); + + test('can parse and return Finnish business ID', () => { + expect(yTunnusParse('0709019-2')).toBe('0709019-2'); + expect(yTunnusParse('0709019')).toBe('0709019-2'); + expect(yTunnusParse( '709019')).toBe('0709019-2'); + }); + + test('can compare two Finnish business IDs', () => { + expect(yTunnusCompare('0709019-2', '0709019-2')).toBe(true); + expect(yTunnusCompare('2092540-6', '0709019-2')).toBe(false); + }); + + test('can check Finnish business ID validity', () => { + expect(yTunnusCheck('2092540-6')).toBe(true); + expect(yTunnusCheck('2256931-0')).toBe(true); + expect(yTunnusCheck('0709019-2')).toBe(true); + expect(yTunnusCheck( '709019-2')).toBe(true); + expect(() => yTunnusCheck('2092540-4')).toThrowError(TypeError); + expect(() => yTunnusCheck('2256931-7')).toThrowError(TypeError); + expect(() => yTunnusCheck('0709019-3')).toThrowError(TypeError); + expect(() => yTunnusCheck( '709019-5')).toThrowError(TypeError); + }); + + test('can check Finnish business ID validity without throwing', () => { + expect(yTunnusCheckNoThrow('2092540-6')).toBe(true); + expect(yTunnusCheckNoThrow('2256931-0')).toBe(true); + expect(yTunnusCheckNoThrow('0709019-2')).toBe(true); + expect(yTunnusCheckNoThrow( '709019-2')).toBe(true); + expect(yTunnusCheckNoThrow('2092540-4')).toBe(false); + expect(yTunnusCheckNoThrow('2256931-7')).toBe(false); + expect(yTunnusCheckNoThrow('0709019-3')).toBe(false); + expect(yTunnusCheckNoThrow( '709019-5')).toBe(false); + }); +}); diff --git a/finId/yTunnus.ts b/finId/yTunnus.ts new file mode 100644 index 0000000..19b771b --- /dev/null +++ b/finId/yTunnus.ts @@ -0,0 +1,140 @@ +/* + * Finnish Identity Number Library + * + * Copyright (C) 2022-2023 by Heusala Group (http://www.hg.fi), + * Copyright (C) 2014 by Sendanor (http://www.sendanor.fi), + * Copyright (c) 2014 by Jaakko-Heikki Heusala + * Copyright (c) 2014 by Juho Vähäkangas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { LogService } from "../LogService"; +import { isString } from "../types/String"; +import { parseInteger } from "../types/Number"; +import { isNumberArray } from "../types/NumberArray"; + +const LOG = LogService.createLogger('yTunnus'); + +/** Generate Finnish business IDs + * @param id_ {string} The business ID with or without checksum + * @returns {string} The business ID with checksum + * @throws TypeError + */ +export function yTunnusWithSum (id_: string): string { + let numbers : number[] = [ 7, 9, 10, 5, 8, 4, 2 ]; + let parts : string[] = ('' + id_).split("-"); + let id : string | undefined = parts.shift(); + if ( !id ) { + throw new TypeError("id not valid: " + id_); + } + if ( id.length === 6 ) { + id = '0' + id; + } + if ( id.length !== 7 ) { + throw new TypeError("id length not valid: " + id_); + } + + const id_array : (number|undefined)[] = id.split('').map(parseInteger); + if (!isNumberArray(id_array)) { + throw new TypeError("id is not valid: "+ id_); + } + if ( id_array.length > 7 ) { + throw new TypeError("id array too long (" + id_array.length + ") for " + id_); + } + + let sum : number = id_array.reduce( (sum : number, n : number, i : number) => { + const x : number = numbers[i]; + return sum + n * x; + }, 0); + + sum = (sum % 11); + if ( !((sum === 0) || ((sum >= 2) && (sum <= 10))) ) { + throw new TypeError("Illegal checksum for " + id_); + } + sum = (sum === 0) ? 0 : 11 - sum; + + let sum_ : string = parts.join('-'); + if ( sum_ && ('' + sum !== sum_) ) { + throw new TypeError("Illegal checksum in " + id_); + } + + return id + "-" + sum; +} + +/** Check existance of checksum + * @param id {string} Finnish business ID + * @returns {boolean} `true` if `id` has a checksum + */ +export function yTunnusHasSum (id: string): boolean { + return ('' + id).match("-") !== null; +} + +/** Parse Finnish business ID + * @param id {string} ID with or without checksum + * @returns {string} ID with checksum + * @throws TypeError + */ +export function yTunnusParse (id: string): string { + return yTunnusWithSum(id); +} + +/** Non-throwing version of _parse() + * @param id {string} The ID to parse + * @returns {string} ID with checksum otherwise undefined + */ +export function yTunnusParseNoThrow (id: string): string | undefined { + try { + return yTunnusParse(id); + } catch (err) { + LOG.error(`Failed to parse "${id}": `, err); + return undefined; + } +} + +/** Compare two business IDs + * @param a {string} First business ID + * @param b {string} Second business ID + * @returns {boolean} `true` if both ids are identical + */ +export function yTunnusCompare (a: string, b: string): boolean { + return yTunnusParseNoThrow(a) === yTunnusParseNoThrow(b); +} + +/** Check Finnish business ID validity. This function might throw an exception! See _check_nothrow(). + * @param id {string} The ID to check + * @returns {boolean} `true` if ID was valid + * @throws TypeError + */ +export function yTunnusCheck (id: unknown): id is string { + if (!isString(id)) return false; + return yTunnusHasSum(id) ? yTunnusCompare(id, yTunnusWithSum(id)) : false; +} + +/** Non-throwing version of _check() + * @param id {string} The ID to check + * @returns {boolean} `true` if ID was valid + */ +export function yTunnusCheckNoThrow (id: string): boolean { + try { + return yTunnusCheck(id); + } catch (err) { + return false; + } +} diff --git a/frontend/button/ButtonStyle.ts b/frontend/button/ButtonStyle.ts new file mode 100644 index 0000000..3c8756e --- /dev/null +++ b/frontend/button/ButtonStyle.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { trim } from "../../functions/trim"; + +export enum ButtonStyle { + PRIMARY = "primary", + SECONDARY = "secondary", + SUCCESS = "success", + DANGER = "danger", + WARNING = "warning", + INFO = "info", + LINK = "link" +} + +export function isButtonStyle (value: any): value is ButtonStyle { + + switch (value) { + case ButtonStyle.PRIMARY: + case ButtonStyle.SECONDARY: + case ButtonStyle.SUCCESS: + case ButtonStyle.DANGER: + case ButtonStyle.WARNING: + case ButtonStyle.INFO: + case ButtonStyle.LINK: + return true; + + default: + return false; + + } +} + +export function isButtonStyleOrUndefined (value: any): value is ButtonStyle | undefined { + if (value === undefined) return true; + return isButtonStyle(value); +} + +export function stringifyButtonStyle (value: ButtonStyle): string { + switch (value) { + case ButtonStyle.PRIMARY : return 'primary'; + case ButtonStyle.SECONDARY : return 'secondary'; + case ButtonStyle.SUCCESS : return 'success'; + case ButtonStyle.DANGER : return 'danger'; + case ButtonStyle.WARNING : return 'warning'; + case ButtonStyle.INFO : return 'info'; + case ButtonStyle.LINK : return 'link'; + } + throw new TypeError(`Unsupported ButtonStyle value: ${value}`); +} + +export function parseButtonStyle (value: any): ButtonStyle | undefined { + + if (value === undefined) return undefined; + + switch (trim(`${value}`).toUpperCase()) { + case 'PRIMARY' : return ButtonStyle.PRIMARY; + case 'SECONDARY' : return ButtonStyle.SECONDARY; + case 'SUCCESS' : return ButtonStyle.SUCCESS; + case 'DANGER' : return ButtonStyle.DANGER; + case 'WARNING' : return ButtonStyle.WARNING; + case 'INFO' : return ButtonStyle.INFO; + case 'LINK' : return ButtonStyle.LINK; + default : return undefined; + } + +} + + + diff --git a/frontend/button/ButtonType.ts b/frontend/button/ButtonType.ts new file mode 100644 index 0000000..01d8cc6 --- /dev/null +++ b/frontend/button/ButtonType.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum ButtonType { + DEFAULT = "button", + RESET = "reset", + SUBMIT = "submit" +} + + diff --git a/functions/camelCase.ts b/functions/camelCase.ts new file mode 100644 index 0000000..31c9fad --- /dev/null +++ b/functions/camelCase.ts @@ -0,0 +1 @@ +export {default as camelCase} from 'lodash/camelCase.js'; diff --git a/functions/concat.ts b/functions/concat.ts new file mode 100644 index 0000000..c207d72 --- /dev/null +++ b/functions/concat.ts @@ -0,0 +1 @@ +export {default as concat} from 'lodash/concat.js'; diff --git a/functions/diffReader.test.ts b/functions/diffReader.test.ts new file mode 100644 index 0000000..07021e9 --- /dev/null +++ b/functions/diffReader.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { diffReader, parseDiffHunk } from "./diffReader"; + +const DIFF_CHUNK_1 = `diff --git a/AuthorizationClientService.ts b/AuthorizationClientService.ts +index 6cacefd..2c1b136 100644 +--- a/AuthorizationClientService.ts ++++ b/AuthorizationClientService.ts +@@ -2,10 +2,10 @@ + + import { RequestClient } from "./RequestClient"; + import { LogService } from "./LogService"; +-import { isString } from "./modules/lodash"; + import { RequestError } from "./request/types/RequestError"; + import { RequestStatus } from "./request/types/RequestStatus"; + import { AuthorizationUtils } from "./AuthorizationUtils"; ++import { isString } from "./types/String"; + + const LOG = LogService.createLogger('AuthorizationClientService'); + +`; + +const DIFF_CHUNK_2 = `diff --git a/AuthorizationUtils.ts b/AuthorizationUtils.ts +index a32ddcf..0cea9ec 100644 +--- a/AuthorizationUtils.ts ++++ b/AuthorizationUtils.ts +@@ -1,7 +1,8 @@ + // Copyright (c) 2022. Heusala Group. All rights reserved. + // Copyright (c) 2020-2021. Sendanor. All rights reserved. + +-import { startsWith, trim } from "./modules/lodash"; ++import { startsWith } from "./functions/startsWith"; ++import { trim } from "./functions/trim"; + + export class AuthorizationUtils { + +`; + +const DIFF_CHUNK_3 = `diff --git a/CacheService.ts b/CacheService.ts +index 772a587..9b927e0 100644 +--- a/CacheService.ts ++++ b/CacheService.ts +@@ -1,6 +1,6 @@ + // Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +-import { reduce } from "./modules/lodash"; ++import { reduce } from "./functions/reduce"; + + export interface CacheClearCallback { + () : Promise | void; +`; + +describe('diffReader', () => { + + it('can split chunks', () => { + const result = diffReader(DIFF_CHUNK_1 + DIFF_CHUNK_2 + DIFF_CHUNK_3); + expect(result.length).toBe(3); + expect(result[0]).toBe(DIFF_CHUNK_1); + expect(result[1]).toBe(DIFF_CHUNK_2); + expect(result[2]).toBe(DIFF_CHUNK_3); + }); + +}); + +describe ('parseDiffHunk', () => { + + it( 'can parse hunks', () => { + + expect( parseDiffHunk('@@ -1,4 +1,8 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -95,6 +93,8 @@ export class RequestInterfaceUtils {') ).toStrictEqual({newLines: 0, newStart: 93, oldLines: 0, oldStart: 95}); + + expect( parseDiffHunk('@@ -0,0 +0 @@') ).toStrictEqual({newLines: 0, newStart: 0, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0 +1,2 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0 +1 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,10 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,100 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,101 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,103 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,11 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,115 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,116 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,119 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,120 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,124 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,127 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,13 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,134 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,14 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,148 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,150 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,154 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,167 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,170 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,173 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,18 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,184 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,19 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,193 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,20 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,201 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,21 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,214 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,214 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,23 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,24 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,263 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,268 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,27 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,281 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,30 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,31 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,317 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,33 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,35 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,36 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,376 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,38 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,38 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,39 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,40 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,423 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,43 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,44 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,45 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,47 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,49 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,5 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,509 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,52 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,58 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,6 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,61 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,63 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,68 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,72 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,74 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,752 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,77 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,85 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + expect( parseDiffHunk('@@ -0,0 +1,94 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 0}); + + expect( parseDiffHunk('@@ -1,10 +1,8 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,10 +1,9 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,11 +1,8 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,11 +1,9 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,12 +1,10 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,13 +1,10 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,13 +1,12 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,13 +1,8 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,13 +1,9 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,14 +1,10 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,14 +1,8 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,15 +1,12 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,15 +1,13 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,15 +1,9 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,16 +1,48 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,1623 +0,0 @@') ).toStrictEqual({newLines: 0, newStart: 0, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,14 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,15 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,16 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,17 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,18 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,19 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,20 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,21 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,22 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,23 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,24 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,25 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,26 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,27 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,28 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,29 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,3 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,30 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,31 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,32 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,33 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,34 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,35 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,36 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,37 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,38 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,39 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,4 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,40 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,41 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,42 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,43 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,44 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,45 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,46 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,47 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,48 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,49 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,5 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,50 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,51 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,52 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,53 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,54 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,55 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,56 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,57 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,58 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,58 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,59 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,6 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,60 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,61 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,62 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,63 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + expect( parseDiffHunk('@@ -1,17 +1,64 @@') ).toStrictEqual({newLines: 0, newStart: 1, oldLines: 0, oldStart: 1}); + + expect( parseDiffHunk('@@ -10,17 +10,15 @@ import {') ).toStrictEqual({newLines: 0, newStart: 10, oldLines: 0, oldStart: 10}); + expect( parseDiffHunk("@@ -10,4 +11,8 @@ const LOG = LogService.createLogger('ProcessUtils');") ).toStrictEqual({newLines: 0, newStart: 11, oldLines: 0, oldStart: 10}); + expect( parseDiffHunk('@@ -10,5 +10,5 @@ import {') ).toStrictEqual({newLines: 0, newStart: 10, oldLines: 0, oldStart: 10}); + expect( parseDiffHunk('@@ -101,5 +186,6 @@ export class CommandArgumentUtils {') ).toStrictEqual({newLines: 0, newStart: 186, oldLines: 0, oldStart: 101}); + expect( parseDiffHunk('@@ -102,6 +102,7 @@ export class RequestInterfaceUtils {') ).toStrictEqual({newLines: 0, newStart: 102, oldLines: 0, oldStart: 102}); + expect( parseDiffHunk('@@ -104,12 +109,14 @@ export class ProcessUtils {') ).toStrictEqual({newLines: 0, newStart: 109, oldLines: 0, oldStart: 104}); + expect( parseDiffHunk('@@ -11,17 +11,16 @@') ).toStrictEqual({newLines: 0, newStart: 11, oldLines: 0, oldStart: 11}); + expect( parseDiffHunk('@@ -11,7 +11,9 @@ import {') ).toStrictEqual({newLines: 0, newStart: 11, oldLines: 0, oldStart: 11}); + expect( parseDiffHunk('@@ -123,5 +156,5 @@ export class Observer {') ).toStrictEqual({newLines: 0, newStart: 156, oldLines: 0, oldStart: 123}); + expect( parseDiffHunk('@@ -124,5 +210,6 @@ export class CommandArgumentUtils {') ).toStrictEqual({newLines: 0, newStart: 210, oldLines: 0, oldStart: 124}); + expect( parseDiffHunk('@@ -13,4 +15,7 @@ import { OpenAPIV3 } from "../../types/openapi";') ).toStrictEqual({newLines: 0, newStart: 15, oldLines: 0, oldStart: 13}); + expect( parseDiffHunk('@@ -130,2 +217,82 @@ export class CommandArgumentUtils {') ).toStrictEqual({newLines: 0, newStart: 217, oldLines: 0, oldStart: 130}); + expect( parseDiffHunk('@@ -141,5 +140,5 @@ export function getCsvFromJsonObjectList (') ).toStrictEqual({newLines: 0, newStart: 140, oldLines: 0, oldStart: 141}); + + expect( parseDiffHunk('@@ -15,4 +17,5 @@ import { BorderStyleLayout } from "./style/layout/BorderStyleLayout";') ).toStrictEqual({newLines: 0, newStart: 17, oldLines: 0, oldStart: 15}); + expect( parseDiffHunk('@@ -15,4 +20,5 @@ import { LogLevel } from "../../types/LogLevel";') ).toStrictEqual({newLines: 0, newStart: 20, oldLines: 0, oldStart: 15}); + expect( parseDiffHunk('@@ -15,4 +42,5 @@ export interface ParsedCommandArgumentObject {') ).toStrictEqual({newLines: 0, newStart: 42, oldLines: 0, oldStart: 15}); + expect( parseDiffHunk('@@ -168,11 +201,9 @@ export class Observer {') ).toStrictEqual({newLines: 0, newStart: 201, oldLines: 0, oldStart: 168}); + expect( parseDiffHunk('@@ -18,5 +50,5 @@ export type ObserverRecord = Record {') ).toStrictEqual({newLines: 0, newStart: 222, oldLines: 0, oldStart: 191}); + + expect( parseDiffHunk('@@ -2,10 +2,13 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,10 +2,8 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,12 +2,9 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,13 +2,11 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,13 +2,8 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,14 +2,8 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,14 +2,9 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,15 +2,9 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,18 +2,9 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + + expect( parseDiffHunk('@@ -2,5 +2,10 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,5 +2,5 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,5 +2,6 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,5 +2,7 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,5 +2,8 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,5 +2,9 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,6 +2,6 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,6 +2,7 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + expect( parseDiffHunk('@@ -2,8 +2,14 @@') ).toStrictEqual({newLines: 0, newStart: 2, oldLines: 0, oldStart: 2}); + + + }); + + it( 'cannot parse invalid lines', () => { + + expect( parseDiffHunk('') ).toBeNull(); + expect( parseDiffHunk('Hello World') ).toBeNull(); + expect( parseDiffHunk('-2,6 +2,4') ).toBeNull(); + expect( parseDiffHunk('12345') ).toBeNull(); + expect( parseDiffHunk('@@') ).toBeNull(); + expect( parseDiffHunk('@@@@') ).toBeNull(); + expect( parseDiffHunk('@@ @@') ).toBeNull(); + + }); + +}); diff --git a/functions/diffReader.ts b/functions/diffReader.ts new file mode 100644 index 0000000..cadfb64 --- /dev/null +++ b/functions/diffReader.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { split } from "./split"; +import { startsWith } from "./startsWith"; +import { parseInteger } from "../types/Number"; + +export interface DiffHunk { + oldStart: number; + newStart: number; + oldLines: number; + newLines: number; +} + +/** + * Splits a diff string into an array of chunks, where each chunk represents a + * single file. + * + * Each chunk is a string that contains the full diff of a file, including the + * hunk headers. + * + * A hunk header has the format `@@ -REMOVED,LINES +ADDED,LINES @@`, and + * specifies the number of lines that were removed or added. + * + * Here is an example of a chunk: + * + * ```diff + * diff --git a/AuthorizationClientService.ts b/AuthorizationClientService.ts + * index 6cacefd..2c1b136 100644 + * --- a/AuthorizationClientService.ts + * +++ b/AuthorizationClientService.ts + * @@ -2,10 +2,10 @@ + * + * import { RequestClient } from "./RequestClient"; + * import { LogService } from "./LogService"; + * -import { isString } from "./modules/lodash"; + * import { RequestError } from "./request/types/RequestError"; + * import { RequestStatus } from "./request/types/RequestStatus"; + * import { AuthorizationUtils } from "./AuthorizationUtils"; + * +import { isString } from "./types/String"; + * + * const LOG = LogService.createLogger('AuthorizationClientService'); + * ``` + * + * A chunk starts with an optional `diff --git` line, followed by an optional + * `index HASH1..HASH2 PERMISSIONS` line, followed by `---`, `+++`, and `@@` + * lines, and then the actual diff. The chunk ends when a new `diff --git` + * line is encountered. + * + * @param diffString The diff string to split into chunks + * @returns An array of chunks + */ +export function diffReader (diffString: string) : string[] { + const chunks: string[] = []; + let currentChunk = ''; + let currentHunk: DiffHunk | null = null; + for (const line of split(diffString, '\n')) { + + if (startsWith(line, 'diff --git')) { + if (currentChunk) { + chunks.push(currentChunk); + } + currentChunk = line + '\n'; + currentHunk = null; + } else if (startsWith(line, '@@')) { + const hunk = parseDiffHunk(line); + if (hunk) { + currentHunk = hunk; + } + currentChunk += line + '\n'; + } else if ( currentHunk || startsWith(line, '-') ) { + currentChunk += line + '\n'; + if (currentHunk) { + if (startsWith(line, '-')) { + currentHunk.oldLines--; + } else if (startsWith(line, '+')) { + currentHunk.newLines--; + } else { + currentHunk.oldLines--; + currentHunk.newLines--; + } + } + } else { + currentChunk += line + '\n'; + } + } + if (currentChunk) { + chunks.push(currentChunk.substring(0, currentChunk.length-1)); + } + return chunks; +} + +/** + * + * @param line + */ +export function parseDiffHunk (line: string) : DiffHunk | null { + let res = /^@@ -(\d+),\d+ \+(\d+)(,\d+)? @@/.exec(line); + if (res && res.length >= 3) { + const [, oldStartString, newStartString] = res; + const oldStart = parseInteger(oldStartString); + const newStart = parseInteger(newStartString); + if ( oldStart !== undefined && newStart !== undefined ) { + return { + oldStart, + oldLines: 0, + newStart, + newLines: 0, + }; + } + } + res = /^@@ -(\d+) \+(\d+)(,\d+)? @@/.exec(line); + if (res && res.length >= 3) { + const [, oldStartString, newStartString] = res; + const oldStart = parseInteger(oldStartString); + const newStart = parseInteger(newStartString); + if ( oldStart !== undefined && newStart !== undefined ) { + return { + oldStart, + oldLines: 0, + newStart, + newLines: 0, + }; + } + } + return null; +} diff --git a/functions/endsWith.ts b/functions/endsWith.ts new file mode 100644 index 0000000..bd672dd --- /dev/null +++ b/functions/endsWith.ts @@ -0,0 +1 @@ +export {default as endsWith} from 'lodash/endsWith.js'; diff --git a/functions/every.ts b/functions/every.ts new file mode 100644 index 0000000..2e92490 --- /dev/null +++ b/functions/every.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { TestCallback, TestCallbackOf } from "../types/TestCallback"; +import { default as _every } from "lodash/every"; +import { explainOk } from "../types/explain"; + +/** + * + * @param value + * @param isValue + * @__PURE__ + * @nosideeffects + */ +export function every ( + value: any, + isValue: TestCallback +): value is T[] { + return _every(value, isValue); +} + +/** + * + * @param value + * @param isValue + * @param valueName + * @__PURE__ + * @nosideeffects + */ +export function explainEvery ( + value: any, + isValue: TestCallbackOf | TestCallbackOf, + valueName: string +): string { + return every(value, isValue) ? explainOk() : `some values were not ${valueName}`; +} diff --git a/functions/everyKey.ts b/functions/everyKey.ts new file mode 100644 index 0000000..e5e9cea --- /dev/null +++ b/functions/everyKey.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { TestCallback, TestCallbackOf } from "../types/TestCallback"; +import { default as _isObject } from "lodash/isObject"; +import { every, explainEvery } from "./every"; +import { keys } from "./keys"; +import { explain, explainNot, explainOk } from "../types/explain"; + +/** + * + * @param value + * @param isKey + * @__PURE__ + * @nosideeffects + */ +export function everyKey ( + value: any, + isKey: TestCallback +): value is { [P in T]: any } { + return _isObject(value) && every(keys(value), isKey); +} + +/** + * + * @param value + * @param isKey + * @param keyTypeName + * @__PURE__ + * @nosideeffects + */ +export function explainEveryKey ( + value: any, + isKey: TestCallbackOf | TestCallbackOf, + keyTypeName: string +): string { + return explain( + [ + // We're implementing this inline to overcome circular dependency + _isObject(value) ? explainOk() : explainNot('object'), + explainEvery(keys(value), isKey, keyTypeName) + ] + ); +} diff --git a/functions/everyProperty.test.ts b/functions/everyProperty.test.ts new file mode 100644 index 0000000..bdd156e --- /dev/null +++ b/functions/everyProperty.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isString } from "../types/String"; +import { everyProperty } from "./everyProperty"; + +describe('everyProperty', () => { + + it('can check string object without value checker', () => { + expect( everyProperty({'foo': 'bar'}, isString) ).toBe(true); + expect( everyProperty({'foo': 123}, isString) ).toBe(true); + }); + + it('can check string object', () => { + expect( everyProperty({'foo': 'bar'}, isString, isString) ).toBe(true); + }); + + it('can check string object with multiple properties', () => { + expect( everyProperty({'foo': 'bar', 'hello': 'world'}, isString, isString) ).toBe(true); + }); + + it('can check non-string object', () => { + expect( everyProperty({'foo': 123}, isString, isString) ).toBe(false); + }); + + it('can check non-string object with multiple properties', () => { + expect( everyProperty({'hello': 'world', 'foo': 123}, isString, isString) ).toBe(false); + }); + + it('can check empty object', () => { + expect( everyProperty({}, isString, isString) ).toBe(true); + }); + +}); diff --git a/functions/everyProperty.ts b/functions/everyProperty.ts new file mode 100644 index 0000000..95c1dc2 --- /dev/null +++ b/functions/everyProperty.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { TestCallback, TestCallbackNonStandardOf, TestCallbackOf } from "../types/TestCallback"; +import { isString } from "../types/String"; +import { everyValue } from "./everyValue"; +import { everyKey, explainEveryKey } from "./everyKey"; +import { ExplainCallback } from "../types/ExplainCallback"; +import { values } from "./values"; +import { findIndex } from "./findIndex"; +import { keys } from "./keys"; +import { find } from "./find"; + +/** + * + * @param value + * @param isKey + * @param isItem + * @__PURE__ + * @nosideeffects + */ +export function everyProperty ( + value: any, + isKey: TestCallback | undefined = isString, + isItem: TestCallback | undefined = undefined +): value is { [P in K]: T } { + if ( isItem !== undefined && !everyValue(value, isItem) ) { + return false; + } + if ( isKey !== undefined ) { + return everyKey(value, isKey); + } + return everyKey(value, isString); +} + +/** + * + * @param value + * @param isKey + * @param isItem + * @__PURE__ + * @nosideeffects + */ +export function explainEveryProperty ( + value: any, + isKey: TestCallbackOf | TestCallbackOf | undefined = isString, + isItem: TestCallbackOf | undefined = undefined +): string { + if ( isItem !== undefined && !everyValue(value, isItem) ) { + return 'values were not correct'; + } + if ( isKey !== undefined ) { + return explainEveryKey(value, isKey, "T"); + } + return explainEveryKey(value, isString, "string"); +} + +/** + * + * @param value + * @param isKey + * @param isItem + * @param explainKey + * @param explainValue + * @__PURE__ + * @nosideeffects + */ +export function assertEveryProperty ( + value: any, + isKey: TestCallbackNonStandardOf | undefined = undefined, + isItem: TestCallbackNonStandardOf | undefined = undefined, + explainKey: ExplainCallback | undefined = undefined, + explainValue: ExplainCallback | undefined = undefined +): void { + + const isKeyTest: TestCallbackNonStandardOf = isKey === undefined ? isString as TestCallbackNonStandardOf : isKey; + + if ( isItem !== undefined && !everyValue(value, (item: T): boolean => isItem(item)) ) { + + const valueArray: T[] = values(value); + const itemIndex: number = findIndex(valueArray, (item: T): boolean => !isItem(item)); + const itemKey: string | Symbol | number = keys(value)[itemIndex]; + const itemValue: T = valueArray[itemIndex]; + if ( explainValue ) { + throw new TypeError(`Property "${itemKey}": value not correct: ${explainValue(itemValue)}`); + } else { + throw new TypeError(`Property "${itemKey}": value not correct: ${JSON.stringify(itemValue, null, 2)}`); + } + + } + + const key: string | Symbol | number | undefined = find(keys(value), (key: Symbol | string | number): boolean => !isKeyTest(key)); + + if ( explainKey ) { + throw new TypeError(`Property "${key}": key was not correct: ${explainKey(key)}`); + } else { + throw new TypeError(`Property "${key}": key was not correct: ${JSON.stringify(key, null, 2)}`); + } + +} diff --git a/functions/everyValue.ts b/functions/everyValue.ts new file mode 100644 index 0000000..436fe2b --- /dev/null +++ b/functions/everyValue.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { TestCallback } from "../types/TestCallback"; +import { default as _isObject } from "lodash/isObject"; +import { every } from "./every"; +import { values } from "./values"; + +/** + * + * @param value + * @param isItem + * @__PURE__ + * @nosideeffects + */ +export function everyValue ( + value: any, + isItem: TestCallback +): value is {[key: string]: T} { + return _isObject(value) && every(values(value), isItem); +} diff --git a/functions/filter.ts b/functions/filter.ts new file mode 100644 index 0000000..83ab6a7 --- /dev/null +++ b/functions/filter.ts @@ -0,0 +1 @@ +export {default as filter} from 'lodash/filter.js'; diff --git a/functions/find.ts b/functions/find.ts new file mode 100644 index 0000000..0a26cd6 --- /dev/null +++ b/functions/find.ts @@ -0,0 +1 @@ +export {default as find} from 'lodash/find.js'; diff --git a/functions/findIndex.ts b/functions/findIndex.ts new file mode 100644 index 0000000..c7d1fcf --- /dev/null +++ b/functions/findIndex.ts @@ -0,0 +1 @@ +export {default as findIndex} from 'lodash/findIndex.js'; diff --git a/functions/first.ts b/functions/first.ts new file mode 100644 index 0000000..7b4c71e --- /dev/null +++ b/functions/first.ts @@ -0,0 +1 @@ +export {default as first} from 'lodash/first.js'; diff --git a/functions/forEach.ts b/functions/forEach.ts new file mode 100644 index 0000000..45ebbd5 --- /dev/null +++ b/functions/forEach.ts @@ -0,0 +1 @@ +export {default as forEach} from 'lodash/forEach.js'; diff --git a/functions/get.ts b/functions/get.ts new file mode 100644 index 0000000..f47ecb2 --- /dev/null +++ b/functions/get.ts @@ -0,0 +1 @@ +export {default as get} from 'lodash/get.js'; diff --git a/functions/has.ts b/functions/has.ts new file mode 100644 index 0000000..3ebfd28 --- /dev/null +++ b/functions/has.ts @@ -0,0 +1 @@ +export {default as has} from 'lodash/has.js'; diff --git a/functions/indexOf.ts b/functions/indexOf.ts new file mode 100644 index 0000000..e261c70 --- /dev/null +++ b/functions/indexOf.ts @@ -0,0 +1 @@ +export {default as indexOf} from 'lodash/indexOf.js'; diff --git a/functions/isEqual.ts b/functions/isEqual.ts new file mode 100644 index 0000000..2ecafab --- /dev/null +++ b/functions/isEqual.ts @@ -0,0 +1 @@ +export {default as isEqual} from 'lodash/isEqual.js'; diff --git a/functions/join.ts b/functions/join.ts new file mode 100644 index 0000000..6c9cd75 --- /dev/null +++ b/functions/join.ts @@ -0,0 +1 @@ +export {default as join} from 'lodash/join.js'; diff --git a/functions/kebabCase.ts b/functions/kebabCase.ts new file mode 100644 index 0000000..1c7891e --- /dev/null +++ b/functions/kebabCase.ts @@ -0,0 +1 @@ +export {default as kebabCase} from 'lodash/kebabCase.js'; diff --git a/functions/keys.test.ts b/functions/keys.test.ts new file mode 100644 index 0000000..b0e6a91 --- /dev/null +++ b/functions/keys.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { keys } from './keys'; + +describe.skip('keys', () => { + + it('should return an array of keys for an array input', () => { + const array = ['a', 'b', 'c']; + const result = keys(array); + expect(result).toEqual(['0', '1', '2']); + }); + + it('should return an array of keys for an object input', () => { + const object = { a: 1, b: 2, c: 3 }; + const result = keys(object); + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('should return an empty array for an invalid input', () => { + const invalidInputs = [null, undefined, 1, 'string', () => {}]; + invalidInputs.forEach(input => { + const result = keys(input); + expect(result).toEqual([]); + }); + }); + + it.skip('should return only string keys when isKey is a function that returns true for strings', () => { + const object = { a: 1, b: 2, c: 3, 1: 4 }; + const result = keys(object, key => typeof key === 'string'); + expect(result).toEqual(['a', 'b', 'c']); + }); + +}); diff --git a/functions/keys.ts b/functions/keys.ts new file mode 100644 index 0000000..38d4595 --- /dev/null +++ b/functions/keys.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { TestCallbackNonStandard } from "../types/TestCallback"; +import { isString } from "../types/String"; +import { isArray } from "../types/Array"; +import { map } from "./map"; +import { filter } from "./filter"; +import { default as _isObject } from "lodash/isObject"; // To overcome circular dependency + +export function keys ( + value: any, + isKey: TestCallbackNonStandard = isString +): T[] { + if ( isArray(value) ) { + const indexes: number[] = map(value, ( + // @ts-ignore + item: any, index: number) => index); + const items: T[] = filter(indexes, (key: number) => isKey(key)) as T[]; + return items; + } else if ( _isObject(value) ) { + const allKeys: (string | Symbol)[] = Reflect.ownKeys(value); + const items = filter(allKeys, (key: string | Symbol) => isKey(key)) as T[]; + return items; + } + return [] as T[]; +} diff --git a/functions/last.ts b/functions/last.ts new file mode 100644 index 0000000..5d6f9e6 --- /dev/null +++ b/functions/last.ts @@ -0,0 +1 @@ +export {default as last} from 'lodash/last.js'; diff --git a/functions/map.ts b/functions/map.ts new file mode 100644 index 0000000..1a52b58 --- /dev/null +++ b/functions/map.ts @@ -0,0 +1 @@ +export {default as map} from 'lodash/map.js'; diff --git a/functions/max.ts b/functions/max.ts new file mode 100644 index 0000000..4d6af91 --- /dev/null +++ b/functions/max.ts @@ -0,0 +1 @@ +export {default as max} from 'lodash/max.js'; diff --git a/functions/merge.ts b/functions/merge.ts new file mode 100644 index 0000000..65fb032 --- /dev/null +++ b/functions/merge.ts @@ -0,0 +1 @@ +export {default as merge} from 'lodash/merge.js'; diff --git a/functions/padStart.ts b/functions/padStart.ts new file mode 100644 index 0000000..2885a03 --- /dev/null +++ b/functions/padStart.ts @@ -0,0 +1 @@ +export {default as padStart} from 'lodash/padStart.js'; diff --git a/functions/pathsToScalarItems.ts b/functions/pathsToScalarItems.ts new file mode 100644 index 0000000..99a9b07 --- /dev/null +++ b/functions/pathsToScalarItems.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isArray } from "../types/Array"; +import forEach from "lodash/forEach"; +import { isObject } from "../types/Object"; +import { keys } from "./keys"; + +/** + * Returns path to every scalar item in the variable. + * + * @param value + * @param baseKey + * @returns Every path to scalar properties. + * If the value is not an array or object, will return the baseKey itself if it's defined. + * If the baseKey is not defined or is empty, will return an empty array. + * @__PURE__ + * @nosideeffects + */ +export function pathsToScalarItems ( + value: any, + baseKey: string = '' +): string[] { + + if ( isArray(value) ) { + let allKeys: string[] = []; + forEach( + value, + (item: any, itemIndex: number) => { + + const itemKey = `${baseKey}${baseKey ? '.' : ''}${itemIndex}`; + + const allItemKeys = pathsToScalarItems(item, itemKey); + + allKeys = allKeys.concat(allItemKeys); + + } + ); + return allKeys; + } + + if ( isObject(value) ) { + let allKeys: string[] = []; + forEach( + keys(value), + (itemKey: any) => { + + const itemFullKey = `${baseKey}${baseKey ? '.' : ''}${itemKey}`; + + const item: any = value[itemKey]; + + const allItemKeys = pathsToScalarItems(item, itemFullKey); + + allKeys = allKeys.concat(allItemKeys); + + } + ); + return allKeys; + } + if ( baseKey === '' ) { + return []; + } + return [ baseKey ]; +} diff --git a/functions/random.ts b/functions/random.ts new file mode 100644 index 0000000..c9deed9 --- /dev/null +++ b/functions/random.ts @@ -0,0 +1 @@ +export {default as random} from 'lodash/random.js'; diff --git a/functions/reduce.ts b/functions/reduce.ts new file mode 100644 index 0000000..5ddad18 --- /dev/null +++ b/functions/reduce.ts @@ -0,0 +1 @@ +export {default as reduce} from 'lodash/reduce.js'; diff --git a/functions/remove.ts b/functions/remove.ts new file mode 100644 index 0000000..db2c66d --- /dev/null +++ b/functions/remove.ts @@ -0,0 +1 @@ +export {default as remove} from 'lodash/remove.js'; diff --git a/functions/replace.ts b/functions/replace.ts new file mode 100644 index 0000000..a399f9f --- /dev/null +++ b/functions/replace.ts @@ -0,0 +1 @@ +export {default as replace} from 'lodash/replace.js'; diff --git a/functions/replaceAll.test.ts b/functions/replaceAll.test.ts new file mode 100644 index 0000000..514a4dd --- /dev/null +++ b/functions/replaceAll.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { replaceAll } from "./replaceAll"; + +describe('functions', () => { + + describe('replaceAll', () => { + + it('replaces all occurrences of a string', () => { + const value = 'this is a test'; + const from = 'is'; + const to = 'X'; + const expected = 'thX X a test'; + const result = replaceAll(value, from, to); + expect(result).toEqual(expected); + }); + + it('replaces all occurrences of a string', () => { + const value = 'this is a test'; + const from = ''; + const to = 'X'; + const expected = 'XtXhXiXsX XiXsX XaX XtXeXsXtX'; + const result = replaceAll(value, from, to); + expect(result).toEqual(expected); + }); + + it('cannot replace missing row', () => { + expect( replaceAll('hello world', 'foo', 'bar') ).toStrictEqual('hello world'); + }); + + it('cannot replace empty row', () => { + expect( replaceAll('', 'foo', 'bar') ).toStrictEqual(''); + }); + + it('can replace string', () => { + expect( replaceAll('hello world', 'hello', 'HELLO') ).toStrictEqual('HELLO world'); + }); + + it('can replace multiple strings', () => { + expect( replaceAll('hello hello world', 'hello', 'HELLO') ).toStrictEqual('HELLO HELLO world'); + }); + + it('can replace multiple middle strings', () => { + expect( replaceAll('123 hello 890 hello world', 'hello', 'HELLO') ).toStrictEqual('123 HELLO 890 HELLO world'); + }); + + it('can replace multiple adjacent strings', () => { + expect( replaceAll('hellohellohello', 'hello', 'HELLO') ).toStrictEqual('HELLOHELLOHELLO'); + }); + + it('can replace multiple strings with replace word inside the replacement', () => { + expect( replaceAll('hellohellohello', 'hello', 'hello123') ).toStrictEqual('hello123hello123hello123'); + }); + + }); + +}); diff --git a/functions/replaceAll.ts b/functions/replaceAll.ts new file mode 100644 index 0000000..26a41f6 --- /dev/null +++ b/functions/replaceAll.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isString } from "../types/String"; + +/** + * Replaces all occurrences of a string in the input string. + * + * @param {string} value - The input string. + * @param {string} from - The string to be replaced. + * @param {string} to - The string to replace all occurrences of `from`. + * @returns {string} The input string with all occurrences of `from` replaced with `to`. + * + * @throws {TypeError} If `value`, `from`, or `to` are not strings. + */ +export function replaceAll (value: string, from: string, to: string): string { + if ( !isString(from) ) throw new TypeError('replaceAll: from is required'); + if ( !isString(value) ) throw new TypeError('replaceAll: value is not a string'); + if ( !isString(to) ) throw new TypeError('replaceAll: to is not a string'); + if ( from === '' ) return [ '', ...value.split(''), '' ].join(to); + let ret = ''; + let p = 0; + let i = value.indexOf(from); + while ( i >= p ) { + ret += value.substring(p, i) + to; + p = i + from.length; + i = value.indexOf(from, p); + } + ret += value.substring(p); + return ret; +} diff --git a/functions/replaceTemplate.test.ts b/functions/replaceTemplate.test.ts new file mode 100644 index 0000000..c5e0550 --- /dev/null +++ b/functions/replaceTemplate.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { replaceTemplate } from './replaceTemplate'; + +describe('replaceTemplate', () => { + + it('replaces all occurrences of a string in the template', () => { + const template = 'Hello, {{name}}! You have {{count}} new messages.'; + const replacements = { '{{name}}': 'Alice', '{{count}}': '3' }; + const expected = 'Hello, Alice! You have 3 new messages.'; + + expect(replaceTemplate(template, replacements)).toEqual(expected); + }); + + it('replaces multiple keys in the template', () => { + const template = '{{greeting}}, {{name}}! You have {{count}} new messages.'; + const replacements = { '{{greeting}}': 'Hi', '{{name}}': 'Alice', '{{count}}': '3' }; + const expected = 'Hi, Alice! You have 3 new messages.'; + + expect(replaceTemplate(template, replacements)).toEqual(expected); + }); + + it('returns the original template string if no replacements are provided', () => { + const template = 'Hello, {{name}}! You have {{count}} new messages.'; + const replacements = {}; + const expected = 'Hello, {{name}}! You have {{count}} new messages.'; + + expect(replaceTemplate(template, replacements)).toEqual(expected); + }); + + it('returns the original template string if no matching keys are found in the replacements', () => { + const template = 'Hello, {{name}}! You have {{count}} new messages.'; + const replacements = { '{{greeting}}': 'Hi' }; + const expected = 'Hello, {{name}}! You have {{count}} new messages.'; + + expect(replaceTemplate(template, replacements)).toEqual(expected); + }); + + it('handles empty string values in the replacements', () => { + const template = 'Hello, {{name}}! You have {{count}} new messages.'; + const replacements = { '{{name}}': '', '{{count}}': '3' }; + const expected = 'Hello, ! You have 3 new messages.'; + + expect(replaceTemplate(template, replacements)).toEqual(expected); + }); + +}); diff --git a/functions/replaceTemplate.ts b/functions/replaceTemplate.ts new file mode 100644 index 0000000..750e2ae --- /dev/null +++ b/functions/replaceTemplate.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { keys } from "./keys"; +import { replaceAll } from "./replaceAll"; + +/** + * An object containing key-value pairs of strings to be replaced in a template + * string. + * + * @interface ReplacementMap + * @property {string} name - The name of the replacement. + * @property {string} value - The value to be used as the replacement. + */ +export interface ReplacementMap { + readonly [name: string]: string; +} + +/** + * Replaces all occurrences of keys in the `replacements` object with their + * corresponding values in the `template` string and returns the resulting + * string. + * + * @param {string} template - The input template string. + * @param {ReplacementMap} replacements - An object containing key-value pairs + * of strings to be replaced in the template string. + * @returns {string} The input template string with all occurrences of the keys + * in `replacements` replaced with their corresponding values. + * + * @throws {TypeError} If `template` is not a string. + */ +export function replaceTemplate ( + template : string, + replacements: ReplacementMap +) : string { + keys(replacements).forEach((key: string) => { + const value = replacements[key]; + template = replaceAll(template, key, value); + }); + return template; +} diff --git a/functions/reverse.ts b/functions/reverse.ts new file mode 100644 index 0000000..28332d7 --- /dev/null +++ b/functions/reverse.ts @@ -0,0 +1 @@ +export {default as reverse} from 'lodash/reverse.js'; diff --git a/functions/set.ts b/functions/set.ts new file mode 100644 index 0000000..95d54d1 --- /dev/null +++ b/functions/set.ts @@ -0,0 +1 @@ +export {default as set} from 'lodash/set.js'; diff --git a/functions/shuffle.ts b/functions/shuffle.ts new file mode 100644 index 0000000..b321469 --- /dev/null +++ b/functions/shuffle.ts @@ -0,0 +1 @@ +export {default as shuffle} from 'lodash/shuffle.js'; diff --git a/functions/slice.ts b/functions/slice.ts new file mode 100644 index 0000000..477b838 --- /dev/null +++ b/functions/slice.ts @@ -0,0 +1 @@ +export {default as slice} from 'lodash/slice.js'; diff --git a/functions/some.ts b/functions/some.ts new file mode 100644 index 0000000..2bc51c9 --- /dev/null +++ b/functions/some.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { TestCallback } from "../types/TestCallback"; +import { default as _some } from "lodash/some"; + +/** + * + * @param value + * @param isValue + * @__PURE__ + * @nosideeffects + */ +export function some ( + value: any, + isValue: TestCallback +): value is (T | any)[] { + return _some(value, isValue); +} diff --git a/functions/someValue.ts b/functions/someValue.ts new file mode 100644 index 0000000..d0dffae --- /dev/null +++ b/functions/someValue.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { TestCallback } from "../types/TestCallback"; +import { default as _isObject } from "lodash/isObject"; +import { values } from "./values"; +import { some } from "./some"; + +/** + * + * @param value + * @param isItem + * @__PURE__ + * @nosideeffects + */ +export function someValue ( + value: any, + isItem: TestCallback +): value is {[key: string]: T | undefined} { + return _isObject(value) && some(values(value), isItem); +} diff --git a/functions/sortBy.ts b/functions/sortBy.ts new file mode 100644 index 0000000..9c4e466 --- /dev/null +++ b/functions/sortBy.ts @@ -0,0 +1 @@ +export {default as sortBy} from 'lodash/sortBy.js'; diff --git a/functions/split.ts b/functions/split.ts new file mode 100644 index 0000000..1fffc21 --- /dev/null +++ b/functions/split.ts @@ -0,0 +1 @@ +export {default as split} from 'lodash/split.js'; diff --git a/functions/startsWith.ts b/functions/startsWith.ts new file mode 100644 index 0000000..c99cb00 --- /dev/null +++ b/functions/startsWith.ts @@ -0,0 +1 @@ +export {default as startsWith} from 'lodash/startsWith.js'; diff --git a/functions/toInteger.ts b/functions/toInteger.ts new file mode 100644 index 0000000..43d7bce --- /dev/null +++ b/functions/toInteger.ts @@ -0,0 +1 @@ +export {default as toInteger} from 'lodash/toInteger.js'; diff --git a/functions/toLower.ts b/functions/toLower.ts new file mode 100644 index 0000000..25a4d83 --- /dev/null +++ b/functions/toLower.ts @@ -0,0 +1 @@ +export {default as toLower} from 'lodash/toLower.js'; diff --git a/functions/toSafeInteger.ts b/functions/toSafeInteger.ts new file mode 100644 index 0000000..bd99585 --- /dev/null +++ b/functions/toSafeInteger.ts @@ -0,0 +1 @@ +export {default as toSafeInteger} from 'lodash/toSafeInteger.js'; diff --git a/functions/toUpper.ts b/functions/toUpper.ts new file mode 100644 index 0000000..a6f9812 --- /dev/null +++ b/functions/toUpper.ts @@ -0,0 +1 @@ +export {default as toUpper} from 'lodash/toUpper.js'; diff --git a/functions/trim.ts b/functions/trim.ts new file mode 100644 index 0000000..10059d3 --- /dev/null +++ b/functions/trim.ts @@ -0,0 +1 @@ +export {default as trim} from 'lodash/trim.js'; diff --git a/functions/trimStart.ts b/functions/trimStart.ts new file mode 100644 index 0000000..2763a96 --- /dev/null +++ b/functions/trimStart.ts @@ -0,0 +1 @@ +export {default as trimStart} from 'lodash/trimStart.js'; diff --git a/functions/uniq.ts b/functions/uniq.ts new file mode 100644 index 0000000..47891eb --- /dev/null +++ b/functions/uniq.ts @@ -0,0 +1 @@ +export {default as uniq} from 'lodash/uniq.js'; diff --git a/functions/uniqBy.ts b/functions/uniqBy.ts new file mode 100644 index 0000000..10de39a --- /dev/null +++ b/functions/uniqBy.ts @@ -0,0 +1 @@ +export {default as uniqBy} from 'lodash/uniqBy.js'; diff --git a/functions/upperFirst.ts b/functions/upperFirst.ts new file mode 100644 index 0000000..df601ec --- /dev/null +++ b/functions/upperFirst.ts @@ -0,0 +1 @@ +export {default as upperFirst} from 'lodash/upperFirst.js'; diff --git a/functions/values.ts b/functions/values.ts new file mode 100644 index 0000000..394eede --- /dev/null +++ b/functions/values.ts @@ -0,0 +1 @@ +export {default as values} from 'lodash/values.js'; diff --git a/hosts/HostEntryModel.ts b/hosts/HostEntryModel.ts new file mode 100644 index 0000000..fdf08b1 --- /dev/null +++ b/hosts/HostEntryModel.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../types/OtherKeys"; +import { explainString, isString } from "../types/String"; +import { explain, explainProperty } from "../types/explain"; +import { explainStringArray, isStringArray } from "../types/StringArray"; +import { map } from "../functions/map"; + +export interface HostEntryModel { + readonly address: string; + readonly hostnames: readonly string[]; +} + +export function createHostEntryModel ( + address : string, + hostnames: readonly string[] | string +) : HostEntryModel { + return { + address, + hostnames: isString(hostnames) ? [hostnames] : map(hostnames, item => item) + }; +} + +export function isHostEntryModel (value: unknown) : value is HostEntryModel { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'address', + 'hostnames', + ]) + && isString(value?.address) + && isStringArray(value?.hostnames) + ); +} + +export function explainHostEntryModel (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'address', + 'hostnames', + ]) + , explainProperty("address", explainString(value?.address)) + , explainProperty("hostnames", explainStringArray(value?.hostnames)) + ] + ); +} + +export function stringifyHostEntryModel (value : HostEntryModel) : string { + return `HostEntryModel(${value})`; +} + +export function parseHostEntryModel (value: unknown) : HostEntryModel | undefined { + if (isHostEntryModel(value)) return value; + return undefined; +} + diff --git a/hosts/HostsModel.ts b/hosts/HostsModel.ts new file mode 100644 index 0000000..8e90d7b --- /dev/null +++ b/hosts/HostsModel.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../types/OtherKeys"; +import { explain, explainProperty } from "../types/explain"; +import { explainHostEntryModel, HostEntryModel, isHostEntryModel } from "./HostEntryModel"; +import { explainArrayOf, isArrayOf } from "../types/Array"; + +export interface HostsModel { + readonly entries: readonly HostEntryModel[]; +} + +export function createHostsModel ( + entries : readonly HostEntryModel[] +) : HostsModel { + return { + entries: entries + }; +} + +export function isHostsModel (value: unknown) : value is HostsModel { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'entries', + ]) + && isArrayOf(value?.entries, isHostEntryModel) + ); +} + +export function explainHostsModel (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'entries', + ]) + , explainProperty("entries", explainArrayOf( + "HostEntryModel", + explainHostEntryModel, + value?.entries, + isHostEntryModel + )) + ] + ); +} + +export function stringifyHostsModel (value : HostsModel) : string { + return `HostsModel(${value})`; +} + +export function parseHostsModel (value: unknown) : HostsModel | undefined { + if (isHostsModel(value)) return value; + return undefined; +} diff --git a/hosts/HostsService.ts b/hosts/HostsService.ts new file mode 100644 index 0000000..b40c334 --- /dev/null +++ b/hosts/HostsService.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { HostsModel } from "./HostsModel"; +import { Disposable } from "../types/Disposable"; +import { ObserverCallback } from "../Observer"; +import { HostsServiceDestructor, HostsServiceEvent } from "./HostsServiceImpl"; +import { HostEntryModel } from "./HostEntryModel"; + +export interface HostsService extends Disposable { + + destroy (): void; + + on ( + name: HostsServiceEvent, + callback: ObserverCallback + ): HostsServiceDestructor; + + /** + * Get full hosts model + */ + getModel () : HostsModel; + + /** + * Set full hosts model + */ + setModel (model: HostsModel) : void; + + /** + * Get entry by IP address + * + * @param address + */ + getEntryByAddress (address: string) : HostEntryModel | undefined; + + /** + * Get entry by DNS hostname + * + * @param hostname + */ + getEntryByHostname (hostname: string) : HostEntryModel | undefined; + + /** + * Set entry by address + * + * @param address + * @param hostnames + */ + setAddress ( + address : string, + hostnames : readonly string[] | string + ) : void; + + /** + * Remove entry by address + * + * @param address + */ + removeAddress ( + address : string + ) : void; + + /** + * Replace entry by hostname. + * + * This will look for old entries for the hostname and remove those before + * updating the new entry. If the old entry with a different address has no + * more hostnames, the whole entry will be removed. + * + * @param address The address for hostnames + * @param hostnames List of hostnames -- or single hostname + */ + replaceByHostnames ( + address : string, + hostnames : readonly string[] | string + ) : void; + + /** + * Returns the internal model in the standard /etc/hosts format + */ + toString () : string; + +} diff --git a/hosts/HostsServiceImpl.test.ts b/hosts/HostsServiceImpl.test.ts new file mode 100644 index 0000000..5fc7ac1 --- /dev/null +++ b/hosts/HostsServiceImpl.test.ts @@ -0,0 +1,306 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { HostsServiceImpl } from "./HostsServiceImpl"; +import { createHostsModel, HostsModel } from "./HostsModel"; +import { createHostEntryModel } from "./HostEntryModel"; + +const HOSTS_MODEL : HostsModel = createHostsModel( + [ + createHostEntryModel('127.0.0.1', 'localhost') + ] +); + +describe('HostsService', () => { + + let service : HostsServiceImpl; + beforeEach(() => { + service = HostsServiceImpl.create(HOSTS_MODEL); + }); + + describe('#getModel', () => { + + it('can read the model', () => { + expect(service.getModel()).toStrictEqual( + { + entries: [ + { + address: '127.0.0.1', + hostnames: [ + 'localhost' + ] + } + ] + } + ) + }); + + }); + + describe('#toString', () => { + + it('can stringify model with single entry', () => { + expect(service.toString()).toBe( + '127.0.0.1\tlocalhost\n' + ) + }); + + it('can stringify model with multiple entries', () => { + service.setAddress( + '10.0.0.1', + 'something.local' + ); + expect(service.toString()).toBe( + '127.0.0.1\tlocalhost\n' + +'10.0.0.1\tsomething.local\n' + ); + }); + + + it('can stringify model with multiple entries and more hostnames', () => { + service.setAddress( + '10.0.0.1', + 'something.local' + ); + service.setAddress( + '10.0.0.5', + ['fs.local', 'fs.test', 'fs.dev'] + ); + expect(service.toString()).toBe( + '127.0.0.1\tlocalhost\n' + +'10.0.0.1\tsomething.local\n' + +'10.0.0.5\tfs.local fs.test fs.dev\n' + ); + }); + + }); + + describe('#getEntryByAddress', () => { + it('can read an entry', () => { + expect(service.getEntryByAddress('127.0.0.1')).toStrictEqual( + { + address: '127.0.0.1', + hostnames: [ + 'localhost' + ] + } + ) + }); + }); + + describe('#getEntryByHostname', () => { + it('can read an entry', () => { + expect(service.getEntryByHostname('localhost')).toStrictEqual( + { + address: '127.0.0.1', + hostnames: [ + 'localhost' + ] + } + ) + }); + }); + + describe('#setAddress', () => { + + it('can set a new model entry', () => { + + service.setAddress( + '10.0.0.1', + 'something.local' + ); + + expect(service.getModel()).toStrictEqual( + { + entries: [ + { + address: '127.0.0.1', + hostnames: [ + 'localhost' + ] + }, + { + address: '10.0.0.1', + hostnames: [ + 'something.local' + ] + } + ] + } + ); + + }); + + it('can replace model entry', () => { + + service.setAddress( + '127.0.0.1', + [ + 'localhost', + 'something.local' + ] + ); + + expect(service.getModel()).toStrictEqual( + { + entries: [ + { + address: '127.0.0.1', + hostnames: [ + 'localhost', + 'something.local' + ] + } + ] + } + ); + + }); + + }); + + describe('#replaceByHostnames', () => { + + it('can set a new model entry for new address', () => { + + service.replaceByHostnames( + '10.0.0.1', + 'something.local' + ); + + expect(service.getModel()).toStrictEqual( + { + entries: [ + { + address: '127.0.0.1', + hostnames: [ + 'localhost' + ] + }, + { + address: '10.0.0.1', + hostnames: [ + 'something.local' + ] + } + ] + } + ); + + }); + + it('can replace model entry for existing address', () => { + + service.replaceByHostnames( + '127.0.0.1', + [ + 'localhost', + 'something.local' + ] + ); + + expect(service.getModel()).toStrictEqual( + { + entries: [ + { + address: '127.0.0.1', + hostnames: [ + 'localhost', + 'something.local' + ] + } + ] + } + ); + + }); + + it('can add new hostname to existing address', () => { + + service.replaceByHostnames( + '127.0.0.1', + 'something.local' + ); + + expect(service.getModel()).toStrictEqual( + { + entries: [ + { + address: '127.0.0.1', + hostnames: [ + 'localhost', + 'something.local' + ] + } + ] + } + ); + + }); + + it('can add new hostname to new address without affecting old hostname', () => { + + service.replaceByHostnames( + '10.0.0.1', + 'something.local' + ); + + expect(service.getModel()).toStrictEqual( + { + entries: [ + { + address: '127.0.0.1', + hostnames: [ + 'localhost' + ] + }, + { + address: '10.0.0.1', + hostnames: [ + 'something.local' + ] + } + ] + } + ); + + }); + + it('can move hostname to new address without affecting old hostname', () => { + + service.setAddress( + '127.0.0.1', + [ + 'localhost', + 'something.local' + ] + ); + + service.replaceByHostnames( + '10.0.0.1', + 'something.local' + ); + + expect(service.getModel()).toStrictEqual( + { + entries: [ + { + address: '127.0.0.1', + hostnames: [ + 'localhost' + ] + }, + { + address: '10.0.0.1', + hostnames: [ + 'something.local' + ] + } + ] + } + ); + + }); + + }); + +}); diff --git a/hosts/HostsServiceImpl.ts b/hosts/HostsServiceImpl.ts new file mode 100644 index 0000000..1ef6b3b --- /dev/null +++ b/hosts/HostsServiceImpl.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createHostsModel, HostsModel } from "./HostsModel"; +import { Observer, ObserverCallback, ObserverDestructor } from "../Observer"; +import { HostsService } from "./HostsService"; +import { createHostEntryModel, HostEntryModel } from "./HostEntryModel"; +import { map } from "../functions/map"; +import { isEqual } from "../functions/isEqual"; +import { find } from "../functions/find"; +import { some } from "../functions/some"; +import { isString } from "../types/String"; +import { concat } from "../functions/concat"; +import { uniq } from "../functions/uniq"; +import { forEach } from "../functions/forEach"; +import { filter } from "../functions/filter"; + +export enum HostsServiceEvent { + MODEL_UPDATED +} + +export type HostsServiceDestructor = ObserverDestructor; + +export class HostsServiceImpl implements HostsService { + + private _model : HostsModel; + + private readonly _observer: Observer; + + public static Event = HostsServiceEvent; + + protected constructor ( + model: HostsModel + ) { + this._model = model; + this._observer = new Observer( "HostsServiceImpl" ); + } + + public static create ( + model: HostsModel + ) : HostsServiceImpl { + return new HostsServiceImpl(model); + } + + /** + * @inheritDoc + */ + public getModel () : HostsModel { + return this._model; + } + + /** + * @inheritDoc + */ + public setModel (model: HostsModel) : void { + this._setModel( model ); + } + + /** + * @inheritDoc + */ + public setAddress ( + address : string, + hostnames : readonly string[] | string + ) : void { + this._setModel( HostsServiceImpl._setAddress(this._model, address, hostnames) ); + } + + /** + * @inheritDoc + */ + public removeAddress ( + address : string + ) : void { + this._setModel( HostsServiceImpl._removeAddress(this._model, address) ); + } + + /** + * @inheritDoc + */ + public replaceByHostnames ( + address : string, + hostnames : readonly string[] | string + ) : void { + hostnames = isString(hostnames) ? [hostnames] : hostnames; + let model = this._model; + + forEach( + hostnames, + (hostname: string) => { + model = HostsServiceImpl._replaceByHostname(model, address, hostname); + } + ); + + this._setModel(model); + + } + + /** + * @inheritDoc + */ + public destroy (): void { + this._observer.destroy(); + } + + public on ( + name: HostsServiceEvent, + callback: ObserverCallback + ): HostsServiceDestructor { + return this._observer.listenEvent( name, callback ); + } + + public getEntryByAddress (address: string) : HostEntryModel | undefined { + return HostsServiceImpl._getEntryByAddress(this._model, address); + } + + public getEntryByHostname (hostname: string) : HostEntryModel | undefined { + return HostsServiceImpl._getEntryByHostname(this._model, hostname); + } + + public toString () : string { + return map( + this._model.entries, + (item: HostEntryModel) : string => `${item.address}\t${item.hostnames.join(' ')}` + ).join('\n') + '\n'; + } + + /** + * @inheritDoc + */ + private static _setAddress ( + model : HostsModel, + address : string, + hostnames : readonly string[] | string + ) : HostsModel { + const newEntry = createHostEntryModel(address, hostnames); + let added : boolean = false; + const newEntries = map( + model.entries, + (item: HostEntryModel) : HostEntryModel => { + if (item.address === address) { + added = true; + return newEntry; + } else { + return item; + } + } + ); + if (!added) { + newEntries.push(newEntry); + } + return createHostsModel(newEntries); + } + + /** + * @inheritDoc + */ + private static _removeAddress ( + model : HostsModel, + address : string + ) : HostsModel { + const newEntries = filter( + model.entries, + (item: HostEntryModel) : boolean => item.address === address + ); + return createHostsModel(newEntries); + } + + public static _replaceByHostname ( + model : HostsModel, + address : string, + hostname : string + ) : HostsModel { + + const oldEntryByHostname : HostEntryModel | undefined = HostsServiceImpl._getEntryByHostname(model, hostname); + const oldEntryByAddress : HostEntryModel | undefined = HostsServiceImpl._getEntryByAddress(model, address); + + if (oldEntryByHostname !== undefined) { + const newHostnames = filter(oldEntryByHostname.hostnames, (item : string) : boolean => item !== hostname); + if (newHostnames.length) { + model = HostsServiceImpl._setAddress(model, oldEntryByHostname.address, newHostnames); + } else { + model = HostsServiceImpl._removeAddress(model, oldEntryByHostname.address); + } + } + + if (oldEntryByAddress !== undefined) { + model = HostsServiceImpl._setAddress(model, address, uniq(concat([], oldEntryByAddress.hostnames, [hostname])) ); + } else { + model = HostsServiceImpl._setAddress(model, address, [hostname]); + } + + return model; + + } + + private _setModel ( + newHostsModel: HostsModel + ) : void { + + if (isEqual(this._model, newHostsModel)) { + return; + } + + this._model = newHostsModel; + + if (this._observer.hasCallbacks(HostsServiceEvent.MODEL_UPDATED)) { + this._observer.triggerEvent(HostsServiceEvent.MODEL_UPDATED); + } + + } + + private static _getEntryByAddress (model: HostsModel, address: string) : HostEntryModel | undefined { + return find( + model.entries, + (entry: HostEntryModel) : boolean => entry.address === address + ); + } + + private static _getEntryByHostname (model: HostsModel, hostname: string) : HostEntryModel | undefined { + return find( + model.entries, + (entry: HostEntryModel) : boolean => some(entry.hostnames, (item: string) : boolean => item === hostname) + ); + } + +} \ No newline at end of file diff --git a/html/Html5.ts b/html/Html5.ts new file mode 100644 index 0000000..b027c2b --- /dev/null +++ b/html/Html5.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { HtmlPage } from "./HtmlPage"; + +export class Html5 implements HtmlPage { + + private readonly _title : string; + private readonly _body : string; + private readonly _lang : string; + + protected constructor ( + title : string, + body : string, + lang : string, + ) { + this._title = title; + this._body = body; + this._lang = lang; + } + + public static createDocument ( + title: string, + body: string, + lang : string = 'en', + ) : Html5 { + return new Html5(title, body, lang); + } + + public getHtml() : string { + // + // + return ` + + + + ${this._title} + + ${this._body} +`; + + } + + public getBody (): string { + return this._body; + } + + public getLang (): string { + return this._lang; + } + + public getTitle (): string { + return this._title; + } + + public toString (): string { + return this.getHtml(); + } + + public valueOf (): string { + return this.getHtml(); + } + +} \ No newline at end of file diff --git a/html/HtmlPage.ts b/html/HtmlPage.ts new file mode 100644 index 0000000..5f4f0c9 --- /dev/null +++ b/html/HtmlPage.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export interface HtmlPage { + getLang() : string; + getTitle() : string; + getBody() : string; + toString() : string; + getHtml() : string; +} diff --git a/i18n/TranslationService.ts b/i18n/TranslationService.ts new file mode 100644 index 0000000..0807cc2 --- /dev/null +++ b/i18n/TranslationService.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { Language } from "../types/Language"; +import { ReadonlyJsonObject } from "../Json"; +import { TranslationResourceObject } from "../types/TranslationResourceObject"; +import { TranslatedObject } from "../types/TranslatedObject"; +import { LogLevel } from "../types/LogLevel"; +import { TranslationFunction } from "../types/TranslationFunction"; + +export interface TranslationService { + + setLogLevel (level: LogLevel) : void; + + initialize ( + defaultLanguage : Language, + resources : TranslationResourceObject + ) : Promise; + + translateKeys ( + lang : Language, + keys : string[], + translationParams : ReadonlyJsonObject + ): Promise; + + translateJob ( + lang : Language, + callback : ((t: TranslationFunction) => T) + ) : Promise; + +} diff --git a/interfaces/callbacks.ts b/interfaces/callbacks.ts new file mode 100644 index 0000000..9ab525b --- /dev/null +++ b/interfaces/callbacks.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export interface VoidCallback { + () : void; +} + +export interface ChangeCallback { + (name : T) : void; +} + +export interface EventCallback { + (event : T) : void; +} + +export interface EventCallbackWithArgs { + (event : T, ...params: any[]) : void; +} + +export interface DropCallback { + (id : T, ...params: any[]) : void; +} + diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..4ebbfc3 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,10 @@ +// See also https://github.com/heusalagroup/test or project specific test folder +/** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + // testTimeout: 30000, + globals: { + window: {} + } +}; diff --git a/jwt/JwtDecodeService.ts b/jwt/JwtDecodeService.ts new file mode 100644 index 0000000..fdda7df --- /dev/null +++ b/jwt/JwtDecodeService.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { ReadonlyJsonObject } from "../Json"; +import { LogLevel } from "../types/LogLevel"; + +export interface JwtDecodeService { + + setLogLevel (level: LogLevel): void; + + decodePayload (token: string) : ReadonlyJsonObject; + + decodePayloadAudience (token: string) : string; + + decodePayloadSubject (token: string) : string; + + decodePayloadVerified (token: string) : boolean; + +} diff --git a/jwt/JwtEncodeService.ts b/jwt/JwtEncodeService.ts new file mode 100644 index 0000000..34c13ce --- /dev/null +++ b/jwt/JwtEncodeService.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { JwtEngine } from "./JwtEngine"; + +export interface JwtEncodeService { + + getDefaultAlgorithm (): string; + + setDefaultAlgorithm (value: string): void; + + /** + * Creates a jwt engine which hides secret + * + * @param secret + * @param defaultAlgorithm + */ + createJwtEngine ( + secret: string, + defaultAlgorithm ?: string + ): JwtEngine; + +} diff --git a/jwt/JwtEngine.ts b/jwt/JwtEngine.ts new file mode 100644 index 0000000..f25c31b --- /dev/null +++ b/jwt/JwtEngine.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { ReadonlyJsonObject } from "../Json"; +import { JwtPayload } from "./types/JwtPayload"; + +export interface JwtEngine { + getDefaultAlgorithm () : string; + setDefaultAlgorithm (value: string) : void; + sign(payload: ReadonlyJsonObject | JwtPayload, alg?: string) : string; + verify(token: string, alg?: string) : boolean; +} diff --git a/jwt/JwtUtils.test.ts b/jwt/JwtUtils.test.ts new file mode 100644 index 0000000..1ebe5c9 --- /dev/null +++ b/jwt/JwtUtils.test.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { JwtUtils } from './JwtUtils'; + +describe('JwtUtils', () => { + + let currentTime: number; + + beforeEach(() => { + currentTime = Math.floor(Date.now() / 1000); + jest.spyOn(Date, 'now').mockImplementation(() => currentTime * 1000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('calculateExpirationInDays', () => { + it('should correctly calculate the expiration timestamp in days', () => { + const days = 1; + expect(JwtUtils.calculateExpirationInDays(days)).toEqual(currentTime + days * 24 * 60 * 60); + }); + }); + + describe('calculateExpirationInMinutes', () => { + it('should correctly calculate the expiration timestamp in minutes', () => { + const minutes = 60; + expect(JwtUtils.calculateExpirationInMinutes(minutes)).toEqual(currentTime + minutes * 60); + }); + }); + + describe('createAudPayloadExpiringInDays', () => { + it('should correctly create a payload with aud and expiration in days', () => { + const aud = 'testAud'; + const days = 1; + expect(JwtUtils.createAudPayloadExpiringInDays(aud, days)).toEqual({ aud, exp: currentTime + days * 24 * 60 * 60 }); + }); + }); + + describe('createAudPayloadExpiringInMinutes', () => { + it('should correctly create a payload with aud and expiration in minutes', () => { + const aud = 'testAud'; + const minutes = 60; + expect(JwtUtils.createAudPayloadExpiringInMinutes(aud, minutes)).toEqual({ aud, exp: currentTime + minutes * 60 }); + }); + }); + + describe('createSubPayloadExpiringInDays', () => { + it('should correctly create a payload with sub and expiration in days', () => { + const sub = 'testSub'; + const days = 1; + expect(JwtUtils.createSubPayloadExpiringInDays(sub, days)).toEqual({ sub, exp: currentTime + days * 24 * 60 * 60 }); + }); + }); + + describe('createSubPayloadExpiringInMinutes', () => { + it('should correctly create a payload with sub and expiration in minutes', () => { + const sub = 'testSub'; + const minutes = 60; + expect(JwtUtils.createSubPayloadExpiringInMinutes(sub, minutes)).toEqual({ sub, exp: currentTime + minutes * 60 }); + }); + }); + +}); diff --git a/jwt/JwtUtils.ts b/jwt/JwtUtils.ts new file mode 100644 index 0000000..628e9c3 --- /dev/null +++ b/jwt/JwtUtils.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { JwtPayload } from "./types/JwtPayload"; + +export class JwtUtils { + + public static calculateExpirationInDays (days: number) : number { + return Math.floor(Date.now() / 1000 + days * 24 * 60 * 60); + } + + public static calculateExpirationInMinutes (minutes: number) : number { + return Math.floor(Date.now() / 1000 + minutes * 60); + } + + public static createAudPayloadExpiringInDays ( + aud: string, + days: number + ) : JwtPayload { + return { + exp: JwtUtils.calculateExpirationInDays(days), + aud + }; + } + + public static createAudPayloadExpiringInMinutes ( + aud: string, + minutes: number + ) : JwtPayload { + return { + exp: JwtUtils.calculateExpirationInMinutes(minutes), + aud + }; + } + + public static createSubPayloadExpiringInDays ( + sub: string, + days: number + ) : JwtPayload { + return { + exp: JwtUtils.calculateExpirationInDays(days), + sub + }; + } + + public static createSubPayloadExpiringInMinutes ( + sub: string, + minutes: number + ) : JwtPayload { + return { + exp: JwtUtils.calculateExpirationInMinutes(minutes), + sub + }; + } + +} diff --git a/jwt/types/JwtPayload.test.ts b/jwt/types/JwtPayload.test.ts new file mode 100644 index 0000000..d995d07 --- /dev/null +++ b/jwt/types/JwtPayload.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + JwtPayload, + createJwtPayload, + isJwtPayload, + stringifyJwtPayload, + parseJwtPayload +} from './JwtPayload'; + +describe('JwtPayload', () => { + let validPayload: JwtPayload; + const exp = 123456; + const aud = 'testAud'; + const sub = 'testSub'; + + beforeEach(() => { + validPayload = createJwtPayload(exp, aud, sub); + }); + + describe('createJwtPayload', () => { + it('should create a JwtPayload object', () => { + expect(validPayload).toEqual({ exp, aud, sub }); + }); + }); + + describe('isJwtPayload', () => { + + it('should return true for a valid payload', () => { + expect(isJwtPayload(validPayload)).toBe(true); + }); + + it('should return false for a payload with additional properties', () => { + const invalidPayload = { ...validPayload, extraProp: 'extra' }; + expect(isJwtPayload(invalidPayload)).toBe(false); + }); + + it('should return true for a payload with missing properties', () => { + let { sub, ...invalidPayload } = { ...validPayload }; + expect(isJwtPayload(invalidPayload)).toBe(true); + }); + + }); + + describe('stringifyJwtPayload', () => { + it('should return a string representation of a JwtPayload', () => { + expect(stringifyJwtPayload(validPayload)).toBe(JSON.stringify(validPayload)); + }); + }); + + describe('parseJwtPayload', () => { + it('should parse a valid payload', () => { + expect(parseJwtPayload(validPayload)).toEqual(validPayload); + }); + + it('should return undefined for an invalid payload', () => { + const invalidPayload = { ...validPayload, extraProp: 'extra' }; + expect(parseJwtPayload(invalidPayload)).toBeUndefined(); + }); + }); + +}); diff --git a/jwt/types/JwtPayload.ts b/jwt/types/JwtPayload.ts new file mode 100644 index 0000000..617cafa --- /dev/null +++ b/jwt/types/JwtPayload.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { isStringOrUndefined } from "../../types/String"; +import { isNumberOrUndefined } from "../../types/Number"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface JwtPayload { + + /** + * Expiration time + * @see https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4 + */ + readonly exp ?: number; + + /** + * Audience, e.g. the recipient(s) who this JWT is intended for. + * @see https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3 + */ + readonly aud ?: string; + + /** + * Subject, e.g. the subject of the JWT. + * @see https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.2 + */ + readonly sub ?: string; + +} + +export function createJwtPayload ( + exp ?: number, + aud ?: string, + sub ?: string +): JwtPayload { + return { + exp, + aud, + sub + }; +} + +export function isJwtPayload (value: any): value is JwtPayload { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'exp', + 'aud', + 'sub' + ]) + && isNumberOrUndefined(value?.exp) + && isStringOrUndefined(value?.aud) + && isStringOrUndefined(value?.sub) + ); +} + +export function stringifyJwtPayload (value: JwtPayload): string { + return JSON.stringify(value); +} + +export function parseJwtPayload (value: any): JwtPayload | undefined { + if ( isJwtPayload(value) ) return value; + return undefined; +} diff --git a/laskurit/FiStockTradeTaxCalculator.ts b/laskurit/FiStockTradeTaxCalculator.ts new file mode 100644 index 0000000..556c415 --- /dev/null +++ b/laskurit/FiStockTradeTaxCalculator.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { LogService } from "../LogService"; + +const TAX_FREE_LIMIT = 1000; +const TRANSFER_TAX_PERCENT = 0.016; +const TAX_RELIEF_LIMIT = 30000; +const CAPITAL_INCOME_TAX_PERCENT_BELOW_RELIEF = 0.30; +const CAPITAL_INCOME_TAX_PERCENT_ABOVE_RELIEF = 0.34; +const TAX_BASIS_AFTER_10_YEARS = 0.40; +const TAX_BASIS_STANDARD = 0.20; + +const LOG = LogService.createLogger('FiStockTradeTaxCalculator'); + +export interface FiStockTradeTaxResult { + + readonly kauppaSummaBrutto: number; + readonly hankintaHintaOlettama: number; + readonly hankintaHintaOlettamaOsuus: number; + readonly hankintaKulut: number; + readonly veroVahennys: number; + readonly verotettavaSumma: number; + readonly yli30k: boolean; + readonly ali30kSumma: number; + readonly yli30kSumma: number; + readonly vero30pSumma: number; + readonly vero34pSumma: number; + readonly veronSumma: number; + readonly kauppaSummaNetto: number; + readonly varainSiirtoVero: number; + +} + +export class FiStockTradeTaxCalculator { + + public static calculate ( + kauppaSummaValue: number, + hankintaHintaValue: number, + hankintaVarainsiirtoVeroValue: number, + valitysPalkkioValue: number, + ostinOsakkeet: boolean, + yli10Years: boolean + ): FiStockTradeTaxResult { + + const isTaxFree = kauppaSummaValue <= TAX_FREE_LIMIT; + + const hankintaHintaOlettamaOsuus = yli10Years ? TAX_BASIS_AFTER_10_YEARS : TAX_BASIS_STANDARD; + const hankintaHintaOlettama = kauppaSummaValue * hankintaHintaOlettamaOsuus; + const hankintaKulut = ostinOsakkeet ? hankintaHintaValue + hankintaVarainsiirtoVeroValue + valitysPalkkioValue : hankintaHintaValue; + const veroVahennys = hankintaHintaOlettama > hankintaKulut ? hankintaHintaOlettama : hankintaKulut; + const verotettavaSumma = isTaxFree ? 0 : (kauppaSummaValue >= veroVahennys ? kauppaSummaValue - veroVahennys : 0); + const yli30k = verotettavaSumma >= TAX_RELIEF_LIMIT; + const ali30kSumma = yli30k ? TAX_RELIEF_LIMIT : verotettavaSumma; + const yli30kSumma = yli30k ? verotettavaSumma - TAX_RELIEF_LIMIT : 0; + const vero30pSumma = ali30kSumma * CAPITAL_INCOME_TAX_PERCENT_BELOW_RELIEF; + const vero34pSumma = yli30kSumma * CAPITAL_INCOME_TAX_PERCENT_ABOVE_RELIEF; + const veronSumma = vero30pSumma + vero34pSumma; + const kauppaSummaNetto = kauppaSummaValue - veronSumma; + const varainSiirtoVero = kauppaSummaValue * TRANSFER_TAX_PERCENT; + + return { + kauppaSummaBrutto: kauppaSummaValue, + hankintaHintaOlettama: hankintaHintaOlettama, + hankintaHintaOlettamaOsuus: hankintaHintaOlettamaOsuus, + hankintaKulut: hankintaKulut, + veroVahennys: veroVahennys, + verotettavaSumma: verotettavaSumma, + yli30k: yli30k, + ali30kSumma: ali30kSumma, + yli30kSumma: yli30kSumma, + vero30pSumma: vero30pSumma, + vero34pSumma: vero34pSumma, + veronSumma: veronSumma, + kauppaSummaNetto, + varainSiirtoVero + }; + + } + + + public static reverseCalculate ( + kauppaSummaNetto: number, + hankintaHintaValue: number, + hankintaVarainsiirtoVeroValue: number, + valitysPalkkioValue: number, + ostinOsakkeet: boolean, + yli10Years: boolean + ): FiStockTradeTaxResult | undefined { + + let minBruttoRange: number = kauppaSummaNetto; + let maxBruttoRange: number = kauppaSummaNetto / (1 - CAPITAL_INCOME_TAX_PERCENT_ABOVE_RELIEF); + + LOG.debug(`range: ${minBruttoRange} - ${maxBruttoRange}`); + + let result: FiStockTradeTaxResult | undefined = undefined; + + let current: number = minBruttoRange; + + for ( ; current <= maxBruttoRange ; current += 0.01 ) { + + result = FiStockTradeTaxCalculator.calculate( + current, + hankintaHintaValue, + hankintaVarainsiirtoVeroValue, + valitysPalkkioValue, + ostinOsakkeet, + yli10Years + ); + + if ( Math.round(result.kauppaSummaNetto * 100) === Math.round( + kauppaSummaNetto * 100) ) { + LOG.debug(`Match found: `, result); + return result; + } + + } + + LOG.debug(`End of loop reached: `, result); + return undefined; + + } + +} diff --git a/logger/buffered/BufferedLogWriter.test.ts b/logger/buffered/BufferedLogWriter.test.ts new file mode 100644 index 0000000..c8dc55d --- /dev/null +++ b/logger/buffered/BufferedLogWriter.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { BufferedLogWriter } from "./BufferedLogWriter"; + +describe('BufferedLogWriter', () => { + + jest.useFakeTimers(); + + let mockWriter : jest.MockedFunction<(value: string) => void>; + let writer : BufferedLogWriter; + + beforeEach( () => { + mockWriter = jest.fn(); + writer = new BufferedLogWriter( + mockWriter, + 10, + 100, + '>>>', + '...\n', + '\n' + ); + }); + + afterEach( () => { + jest.clearAllMocks(); + }); + + describe('#write', () => { + + it('buffers input until chunk size is reached', () => { + writer.write('123456'); + expect(mockWriter).not.toHaveBeenCalled(); + writer.write('7890123456'); + expect(mockWriter).toHaveBeenNthCalledWith(1,'123456...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(2,'>>>789...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(3,'>>>012...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(4,'>>>3456'); + expect(mockWriter).toHaveBeenCalledTimes(4); + }); + + it('flushes buffer when timeout expires', async () => { + writer.write('123456'); + writer.write('789'); + expect(mockWriter).not.toHaveBeenCalled(); + jest.advanceTimersByTime(200); + expect(mockWriter).toHaveBeenNthCalledWith(1, '123456789'); + expect(mockWriter).toHaveBeenCalledTimes(1); + }); + + it('splits input into chunks of chunk size', () => { + writer.write('123456'); + writer.write('7890\nabc\ndefg\nhi'); + writer.write('jk'); + expect(mockWriter).toHaveBeenNthCalledWith(1,'123456...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(2,'>>>7890\n'); + expect(mockWriter).toHaveBeenNthCalledWith(3,'abc\ndefg\n'); + expect(mockWriter).toHaveBeenCalledTimes(3); + }); + + it('does not buffer empty inputs', async () => { + writer.write(''); + expect(mockWriter).not.toHaveBeenCalled(); + jest.advanceTimersByTime(200); + expect(mockWriter).not.toHaveBeenCalled(); + }); + + it('handles inputs larger than chunk size', () => { + writer.write('1234567890abcdefg'); + expect(mockWriter).toHaveBeenNthCalledWith(1,'123456...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(2,'>>>789...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(3,'>>>0ab...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(4,'>>>cdefg'); + expect(mockWriter).toHaveBeenCalledTimes(4); + }); + + it('handles multiple flushes in one write', async () => { + + writer.write('1234'); + writer.write('5678'); + jest.advanceTimersByTime(50); // The timeout is only 100 ms + expect(mockWriter).not.toHaveBeenCalled(); + + writer.write('abcd'); + writer.write('efgh'); + jest.advanceTimersByTime(150); // Timeout should be triggered now + + expect(mockWriter).toHaveBeenNthCalledWith(1, '123456...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(2, '>>>78abcd'); + expect(mockWriter).toHaveBeenNthCalledWith(3, 'efgh'); + expect(mockWriter).toHaveBeenCalledTimes(3); + }); + + }); + + describe('#drain', () => { + + it('flushes current buffer to writer', () => { + writer.write('123456'); + writer.write('7890123456'); + expect(mockWriter).toHaveBeenNthCalledWith(1,'123456...\n'); + writer.drain(); + expect(mockWriter).toHaveBeenNthCalledWith(2,'>>>789...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(3,'>>>012...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(4,'>>>3456'); + expect(mockWriter).toHaveBeenCalledTimes(4); + }); + + it('skips empty inputs', () => { + writer.write(''); + expect(mockWriter).not.toHaveBeenCalled(); + writer.drain(); + expect(mockWriter).not.toHaveBeenCalled(); + }); + + it('handles inputs smaller than chunk size', () => { + writer.write('123456'); + writer.drain(); + expect(mockWriter).toHaveBeenNthCalledWith(1,'123456'); + expect(mockWriter).toHaveBeenCalledTimes(1); + }); + + it('handles input rows larger than chunk size', () => { + writer.write('1234567890abcdefg\n'); + writer.drain(); + expect(mockWriter).toHaveBeenNthCalledWith(1, '123456...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(2, '>>>789...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(3, '>>>0ab...\n'); + expect(mockWriter).toHaveBeenNthCalledWith(4, '>>>cdefg\n'); + expect(mockWriter).toHaveBeenCalledTimes(4); + }); + + it('handles inputs with newlines', () => { + writer.write('1234\n5678\n'); + writer.write('abcd\nefgh\n'); + writer.drain(); + expect(mockWriter).toHaveBeenNthCalledWith(1, '1234\n5678\n'); + expect(mockWriter).toHaveBeenNthCalledWith(2, 'abcd\nefgh\n'); + expect(mockWriter).toHaveBeenCalledTimes(2); + }); + + }); + +}); diff --git a/logger/buffered/BufferedLogWriter.ts b/logger/buffered/BufferedLogWriter.ts new file mode 100644 index 0000000..b34885f --- /dev/null +++ b/logger/buffered/BufferedLogWriter.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogWriter } from "../../types/LogWriter"; +import { LogUtils } from "../../LogUtils"; + +export class BufferedLogWriter implements LogWriter { + + private readonly _writer : (value: string) => void; + private readonly _chunkSize : number; + private readonly _timeout : number; + private readonly _prefix : string; + private readonly _suffix : string; + private readonly _lineBreak : string; + private _data : string; + private _timer : any | undefined; + + /** + * + * @param writer Function to use for writing + * @param bufferSize The maximum length for single chunk (including line + * break characters) + * @param bufferTime The maximum time to wait for new data before flushing + * @param prefix The prefix to use when rows are split (e.g. `...`) + * @param suffix The suffix to use when rows are split (e.g. `...\n`) + * @param lineBreak The line break character + */ + public constructor ( + writer : (value: string) => void, + bufferSize : number, + bufferTime : number, + prefix : string, + suffix : string, + lineBreak : string, + ) { + this._writer = writer; + this._chunkSize = bufferSize; + this._timeout = bufferTime; + this._data = ''; + this._prefix = prefix; + this._suffix = suffix; + this._lineBreak = lineBreak; + this._timer = undefined; + const minimumChunkSize = prefix.length + lineBreak.length + suffix.length + 1; + if ( bufferSize < minimumChunkSize ) throw new TypeError(`Chunk size must be greater than ${minimumChunkSize}`); + } + + public write (input: string): void { + this._data += input; + + if ( this._data.length >= this._chunkSize ) { + this._drainLoop(false); + return; + } + + if (this._data) { + if (this._timer !== undefined) { + clearTimeout(this._timer); + } + this._timer = setTimeout( + () => { + this._drainLoop(true); + }, + this._timeout + ); + } + + } + + public drain () : void { + this._drainLoop(true); + } + + private _drainLoop ( + forceFlush: boolean + ) : void { + while ( this._drain(forceFlush) ) {} + } + + private _drain ( + forceFlush: boolean + ) : boolean { + + let drainableString : string; + if (forceFlush) { + drainableString = this._data; + this._data = ''; + } else { + let lastBreak = this._data.lastIndexOf(this._lineBreak); + drainableString = this._data.substring(0, lastBreak + 1); + let restOfString = this._data.substring(lastBreak + 1); + if ( !drainableString ) { + if ( restOfString.length < this._chunkSize ) { + return false; + } else { + drainableString = restOfString; + restOfString = ''; + } + } else { + if ( restOfString.length >= this._chunkSize ) { + drainableString += restOfString; + restOfString = ''; + } + } + this._data = restOfString; + } + + let origRows : string[] = drainableString.split('\n'); + + let rows : string[] = LogUtils.splitStringArray( + origRows, + this._chunkSize, + this._prefix, + this._suffix, + this._lineBreak, + ); + + let chunks : string[] = LogUtils.mergeStringArray( + rows, + this._chunkSize, + this._lineBreak + ); + + if (chunks.length) { + chunks.forEach( + (chunk: string, index: number) => { + if (index === chunks.length -1) { + this._writer( chunk ); + } else { + this._writer( chunk + this._lineBreak ); + } + } + ); + return true; + } else { + return false; + } + + } + +} diff --git a/logger/buffered/BufferedLogger.test.ts b/logger/buffered/BufferedLogger.test.ts new file mode 100644 index 0000000..a2e79cd --- /dev/null +++ b/logger/buffered/BufferedLogger.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { BufferedLogger } from "./BufferedLogger"; +import { LogLevel } from "../../types/LogLevel"; +import { Logger } from "../../types/Logger"; + +describe('BufferedLogger', () => { + + jest.useFakeTimers(); + + let logger : Logger; + let writtenData: string; + + beforeEach(() => { + writtenData = ''; + logger = new BufferedLogger( + (value: string) => { + writtenData += value; + }, + 25, + 1000, + '>>>', + '...\n', + '\n', + LogLevel.DEBUG + ); + }); + + it('creates a BufferedLogger instance', () => { + expect(logger).toBeInstanceOf(BufferedLogger); + }); + + function setLogLevelInfo () { + logger.setLogLevel(LogLevel.INFO); + } + + function setLogLevelWarn () { + logger.setLogLevel(LogLevel.WARN); + } + function setLogLevelError () { + logger.setLogLevel(LogLevel.ERROR); + } + + function setLogLevelNone () { + logger.setLogLevel(LogLevel.NONE); + } + + function happyDebugTests () { + + it('logs short debug message', () => { + logger.debug('Hello debug doh!'); + jest.advanceTimersByTime(1100); + expect(writtenData).toContain( + '[DEBUG] Hello debug doh!\n' // Exactly 25 characters (including linebreak) + ); + }); + + it('logs long debug message', () => { + logger.debug('Hello debug dude!'); + jest.advanceTimersByTime(1100); + expect(writtenData).toContain( + // Would be exactly 26 characters (including linebreak), so will split + '[DEBUG] Hello debug d...\n' + +'>>>ude!\n' + ); + }); + + } + + function unhappyDebugTests () { + it('cannot write debug log message', () => { + logger.debug('Hello debug doh!'); + jest.advanceTimersByTime(1100); + expect(writtenData).not.toContain('Hello'); + }); + } + + function happyInfoTests () { + + it('logs short info message', () => { + logger.info('Hello debug dude!'); + jest.advanceTimersByTime(1100); + expect(writtenData).toContain( + '[INFO] Hello debug dude!\n' // Exactly 25 characters (including linebreak) + ); + }); + + it('logs long info message', () => { + logger.info('Hello, debug dude!'); + jest.advanceTimersByTime(1100); + expect(writtenData).toContain( + // Would be exactly 26 characters (including linebreak), so will split + '[INFO] Hello, debug d...\n' + +'>>>ude!\n' + ); + }); + + } + + function unhappyInfoTests () { + it('cannot write info log message', () => { + logger.info('Hello debug doh!'); + jest.advanceTimersByTime(1100); + expect(writtenData).not.toContain('Hello'); + }); + } + + function happyWarnTests () { + + it('logs short warn message', () => { + logger.warn('Hello debug dude!'); + jest.advanceTimersByTime(1100); + expect(writtenData).toContain( + '[WARN] Hello debug dude!\n' // Exactly 25 characters (including linebreak) + ); + }); + + it('logs long warn message', () => { + logger.warn('Hello, debug dude!'); + jest.advanceTimersByTime(1100); + expect(writtenData).toContain( + // Would be exactly 26 characters (including linebreak), so will split + '[WARN] Hello, debug d...\n' + +'>>>ude!\n' + ); + }); + + } + + function unhappyWarnTests () { + it('cannot write warn log message', () => { + logger.warn('Hello debug doh!'); + jest.advanceTimersByTime(1100); + expect(writtenData).not.toContain('Hello'); + }); + } + + function happyErrorTests () { + + it('logs short error message', () => { + logger.error('Hello debug doh!'); + jest.advanceTimersByTime(1100); + expect(writtenData).toContain( + '[ERROR] Hello debug doh!\n' // Exactly 25 characters (including linebreak) + ); + }); + + it('logs long error message', () => { + logger.error('Hello, debug dude!'); + jest.advanceTimersByTime(1100); + expect(writtenData).toContain( + // Would be exactly 26 characters (including linebreak), so will split + '[ERROR] Hello, debug ...\n' + +'>>>dude!\n' + ); + }); + + } + + function unhappyErrorTests () { + it('cannot write error log message', () => { + logger.error('Hello debug doh!'); + jest.advanceTimersByTime(1100); + expect(writtenData).not.toContain('Hello'); + }); + } + + function happyGetDebugLevelTest () { + it('gets log level as DEBUG', () => { + expect(logger.getLogLevel()).toBe(LogLevel.DEBUG); + }); + } + + function happyGetInfoLevelTest () { + it('gets log level as INFO', () => { + expect(logger.getLogLevel()).toBe(LogLevel.INFO); + }); + } + + function happyGetWarnLevelTest () { + it('gets log level as WARN', () => { + expect(logger.getLogLevel()).toBe(LogLevel.WARN); + }); + } + + function happyGetErrorLevelTest () { + it('gets log level as ERROR', () => { + expect(logger.getLogLevel()).toBe(LogLevel.ERROR); + }); + } + + function happyGetNoneLevelTest () { + it('gets log level as NONE', () => { + expect(logger.getLogLevel()).toBe(LogLevel.NONE); + }); + } + + function happySetDebugLevelTest () { + it('can set the log level as DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + expect(logger.getLogLevel()).toBe(LogLevel.DEBUG); + }); + } + + function happySetInfoLevelTest () { + it('can set the log level as INFO', () => { + logger.setLogLevel(LogLevel.INFO); + expect(logger.getLogLevel()).toBe(LogLevel.INFO); + }); + } + + function happySetWarnLevelTest () { + it('can set the log level as WARN', () => { + logger.setLogLevel(LogLevel.WARN); + expect(logger.getLogLevel()).toBe(LogLevel.WARN); + }); + } + + function happySetErrorLevelTest () { + it('can set the log level as ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + expect(logger.getLogLevel()).toBe(LogLevel.ERROR); + }); + } + + function happySetNoneLevelTest () { + it('can set the log level as NONE', () => { + logger.setLogLevel(LogLevel.NONE); + expect(logger.getLogLevel()).toBe(LogLevel.NONE); + }); + } + + function happySetLogLevelTests () { + happySetDebugLevelTest(); + happySetInfoLevelTest(); + happySetWarnLevelTest(); + happySetErrorLevelTest(); + happySetNoneLevelTest(); + } + + describe('log level is DEBUG', () => { + describe('#getLogLevel', happyGetDebugLevelTest); + describe('#setLogLevel', happySetLogLevelTests); + describe('#debug', happyDebugTests); + describe('#info', happyInfoTests); + describe('#warn', happyWarnTests); + describe('#error', happyErrorTests); + }); + + describe('log level is INFO', () => { + beforeEach(setLogLevelInfo); + describe('#getLogLevel', happyGetInfoLevelTest); + describe('#setLogLevel', happySetLogLevelTests); + describe('#debug', unhappyDebugTests); + describe('#info', happyInfoTests); + describe('#warn', happyWarnTests); + describe('#error', happyErrorTests); + }); + + describe('log level is WARN', () => { + beforeEach(setLogLevelWarn); + describe('#getLogLevel', happyGetWarnLevelTest); + describe('#setLogLevel', happySetLogLevelTests); + describe('#debug', unhappyDebugTests); + describe('#info', unhappyInfoTests); + describe('#warn', happyWarnTests); + describe('#error', happyErrorTests); + }); + + describe('log level is ERROR', () => { + beforeEach(setLogLevelError); + describe('#getLogLevel', happyGetErrorLevelTest); + describe('#setLogLevel', happySetLogLevelTests); + describe('#debug', unhappyDebugTests); + describe('#info', unhappyInfoTests); + describe('#warn', unhappyWarnTests); + describe('#error', happyErrorTests); + }); + + describe('log level is NONE', () => { + beforeEach(setLogLevelNone); + describe('#getLogLevel', happyGetNoneLevelTest); + describe('#setLogLevel', happySetLogLevelTests); + describe('#debug', unhappyDebugTests); + describe('#info', unhappyInfoTests); + describe('#warn', unhappyWarnTests); + describe('#error', unhappyErrorTests); + }); + +}); diff --git a/logger/buffered/BufferedLogger.ts b/logger/buffered/BufferedLogger.ts new file mode 100644 index 0000000..9d31180 --- /dev/null +++ b/logger/buffered/BufferedLogger.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Logger } from "../../types/Logger"; +import { LogLevel, stringifyLogLevel } from "../../types/LogLevel"; +import { LogUtils } from "../../LogUtils"; +import { BufferedLogWriter } from "./BufferedLogWriter"; +import { LogWriter } from "../../types/LogWriter"; + +/** + * Buffered logger will stringify and write log messages using a writer function + * with a configured chunk size. It will split long rows to shorter ones. + */ +export class BufferedLogger implements Logger { + + private readonly _writer : (value: string) => void; + // private readonly _bufferSize : number; + // private readonly _bufferTime : number; + private readonly _bufferedWriter : LogWriter; + private _logLevel : LogLevel; + // private _bufferData : string; + + /** + * Constructs `BufferedLogger` instance which writes chunks of + * max length of `bufferSize` using the `writer` function. + * + * @param writer Function to use for writing + * @param bufferSize The maximum length for single chunk (including line + * break characters) + * @param bufferTime The maximum time to wait for new data before flushing + * @param prefix The prefix to use when rows are split (e.g. `...`) + * @param suffix The suffix to use when rows are split (e.g. `...\n`) + * @param lineBreak The line break character + * @param logLevel Initial log level + */ + public constructor ( + writer: (value: string) => void, + bufferSize: number, + bufferTime: number, + prefix : string, + suffix : string, + lineBreak : string, + logLevel ?: LogLevel + ) { + this._writer = writer; + // this._bufferSize = bufferSize; + // this._bufferTime = bufferTime; + // this._bufferData = ''; + this._logLevel = logLevel ?? LogLevel.DEBUG; + this._bufferedWriter = new BufferedLogWriter( + this._writer, + bufferSize, + bufferTime, + prefix, + suffix, + lineBreak + ); + } + + /** + * @inheritDoc + */ + public getLogLevel (): LogLevel { + return this._logLevel; + } + + /** + * @inheritDoc + */ + public setLogLevel (level: LogLevel | undefined): this { + this._logLevel = level ?? LogLevel.DEBUG; + return this; + } + + /** + * @inheritDoc + */ + public debug (...args: readonly any[]): void { + if (this._logLevel <= LogLevel.DEBUG) { + this._write(LogLevel.DEBUG, args); + } + } + + /** + * @inheritDoc + */ + public info (...args: readonly any[]): void { + if (this._logLevel <= LogLevel.INFO) { + this._write(LogLevel.INFO, args); + } + } + + /** + * @inheritDoc + */ + public warn (...args: readonly any[]): void { + if (this._logLevel <= LogLevel.WARN) { + this._write(LogLevel.WARN, args); + } + } + + /** + * @inheritDoc + */ + public error (...args: readonly any[]): void { + if (this._logLevel <= LogLevel.ERROR) { + this._write(LogLevel.ERROR, args); + } + } + + private _write ( + logLevel: LogLevel, + args: readonly any[] + ) : void { + const incomingData : string = `[${stringifyLogLevel(logLevel)}] ${LogUtils.stringifyArray(args)}\n`; + this._bufferedWriter.write(incomingData); + } + +} diff --git a/logger/composite/CompositeLogger.test.ts b/logger/composite/CompositeLogger.test.ts new file mode 100644 index 0000000..ce23131 --- /dev/null +++ b/logger/composite/CompositeLogger.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { CompositeLogger } from "./CompositeLogger"; +import { LogLevel } from "../../types/LogLevel"; +import { Logger } from "../../types/Logger"; +import { MockLogger } from "../mock/MockLogger"; + +describe('CompositeLogger', () => { + + let mockLogger1 : Logger; + let mockLogger2 : Logger; + let spyDebug1 : any; + let spyDebug2 : any; + let spyInfo1 : any; + let spyInfo2 : any; + let spyWarn1 : any; + let spyWarn2 : any; + let spyError1 : any; + let spyError2 : any; + let logger : Logger; + + beforeEach( () => { + mockLogger1 = new MockLogger(); + mockLogger2 = new MockLogger(); + spyDebug1 = jest.spyOn(mockLogger1, 'debug').mockImplementation( () => {} ); + spyInfo1 = jest.spyOn(mockLogger1, 'info').mockImplementation( () => {} ); + spyWarn1 = jest.spyOn(mockLogger1, 'warn').mockImplementation( () => {} ); + spyError1 = jest.spyOn(mockLogger1, 'error').mockImplementation( () => {} ); + spyDebug2 = jest.spyOn(mockLogger2, 'debug').mockImplementation( () => {} ); + spyInfo2 = jest.spyOn(mockLogger2, 'info').mockImplementation( () => {} ); + spyWarn2 = jest.spyOn(mockLogger2, 'warn').mockImplementation( () => {} ); + spyError2 = jest.spyOn(mockLogger2, 'error').mockImplementation( () => {} ); + logger = new CompositeLogger( + [ + mockLogger1, + mockLogger2 + ], + LogLevel.DEBUG + ); + }); + + afterEach( () => { + spyDebug1.mockRestore(); + spyInfo1.mockRestore(); + spyWarn1.mockRestore(); + spyError1.mockRestore(); + spyDebug2.mockRestore(); + spyInfo2.mockRestore(); + spyWarn2.mockRestore(); + spyError2.mockRestore(); + logger.setLogLevel(LogLevel.DEBUG); + }); + + describe('#debug', () => { + + it('logs messages with log level DEBUG', () => { + logger.debug('test message'); + expect(mockLogger1.debug).toHaveBeenCalledWith('test message'); + expect(mockLogger2.debug).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.debug('test message'); + expect(mockLogger1.debug).not.toHaveBeenCalled(); + expect(mockLogger2.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.debug('test message'); + expect(mockLogger1.debug).not.toHaveBeenCalled(); + expect(mockLogger2.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.debug('test message'); + expect(mockLogger1.debug).not.toHaveBeenCalled(); + expect(mockLogger2.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.debug('test message'); + expect(mockLogger1.debug).not.toHaveBeenCalled(); + expect(mockLogger2.debug).not.toHaveBeenCalled(); + }); + + }); + + describe('#info', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.info('test message'); + expect(mockLogger1.info).toHaveBeenCalledWith('test message'); + expect(mockLogger2.info).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.info('test message'); + expect(mockLogger1.info).toHaveBeenCalledWith('test message'); + expect(mockLogger2.info).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.info('test message'); + expect(mockLogger1.info).not.toHaveBeenCalled(); + expect(mockLogger2.info).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.info('test message'); + expect(mockLogger1.info).not.toHaveBeenCalled(); + expect(mockLogger2.info).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.info('test message'); + expect(mockLogger1.info).not.toHaveBeenCalled(); + expect(mockLogger2.info).not.toHaveBeenCalled(); + }); + + }); + + describe('#warn', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.warn('test message'); + expect(mockLogger1.warn).toHaveBeenCalledWith('test message'); + expect(mockLogger2.warn).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.warn('test message'); + expect(mockLogger1.warn).toHaveBeenCalledWith('test message'); + expect(mockLogger2.warn).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.warn('test message'); + expect(mockLogger1.warn).toHaveBeenCalledWith('test message'); + expect(mockLogger2.warn).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.warn('test message'); + expect(mockLogger1.warn).not.toHaveBeenCalled(); + expect(mockLogger2.warn).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.warn('test message'); + expect(mockLogger1.warn).not.toHaveBeenCalled(); + expect(mockLogger2.warn).not.toHaveBeenCalled(); + }); + + }); + + describe('#error', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.error('test message'); + expect(mockLogger1.error).toHaveBeenCalledWith('test message'); + expect(mockLogger2.error).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.error('test message'); + expect(mockLogger1.error).toHaveBeenCalledWith('test message'); + expect(mockLogger2.error).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.error('test message'); + expect(mockLogger1.error).toHaveBeenCalledWith('test message'); + expect(mockLogger2.error).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.error('test message'); + expect(mockLogger1.error).toHaveBeenCalledWith('test message'); + expect(mockLogger2.error).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.error('test message'); + expect(mockLogger1.error).not.toHaveBeenCalled(); + expect(mockLogger2.error).not.toHaveBeenCalled(); + }); + + }); + +}); diff --git a/logger/composite/CompositeLogger.ts b/logger/composite/CompositeLogger.ts new file mode 100644 index 0000000..cdbfe60 --- /dev/null +++ b/logger/composite/CompositeLogger.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Logger } from "../../types/Logger"; +import { LogLevel } from "../../types/LogLevel"; +import { map } from "../../functions/map"; + +/** + * A logger implementation that aggregates multiple loggers and delegates log + * messages to each of them. + */ +export class CompositeLogger implements Logger { + + /** + * Internal logger implementations + * + * @private + */ + private readonly _loggers : Logger[]; + + /** + * The current log level for this service + * + * @private + */ + private _logLevel : LogLevel; + + /** + * + * @param loggers + * @param logLevel + */ + public constructor ( + loggers ?: readonly Logger[], + logLevel ?: LogLevel + ) { + this._loggers = map(loggers ?? [], (logger) => logger); + this._logLevel = logLevel ?? LogLevel.DEBUG; + } + + /** + * Add a new logger implementation + * + * @param logger + */ + public addLogger (logger: Logger) : void { + this._loggers.push(logger); + } + + /** + * Remove a logger implementation + * + * @param logger + */ + public removeLogger (logger: Logger) : void { + const index = this._loggers.indexOf(logger); + if (index >= 0) { + this._loggers.splice(index, 1); + } + } + + /** + * Return the log level of this logger + * + * Note, child loggers may have their own levels configured. + */ + public getLogLevel (): LogLevel { + return this._logLevel; + } + + /** + * Set the log level of this logger + * + * Note, child loggers may have their own levels configured. + * + * @param level + */ + public setLogLevel (level: LogLevel | undefined): this { + this._logLevel = level ?? LogLevel.DEBUG; + return this; + } + + /** + * @inheritDoc + */ + public debug (...args: readonly any[]): void { + if (this._logLevel <= LogLevel.DEBUG) { + for ( const logger of this._loggers ) { + logger.debug(...args); + } + } + } + + /** + * @inheritDoc + */ + public info (...args: readonly any[]): void { + if (this._logLevel <= LogLevel.INFO) { + for ( const logger of this._loggers ) { + logger.info(...args); + } + } + } + + /** + * @inheritDoc + */ + public warn (...args: readonly any[]): void { + if (this._logLevel <= LogLevel.WARN) { + for ( const logger of this._loggers ) { + logger.warn(...args); + } + } + } + + /** + * @inheritDoc + */ + public error (...args: readonly any[]): void { + if (this._logLevel <= LogLevel.ERROR) { + for ( const logger of this._loggers ) { + logger.error(...args); + } + } + } + +} diff --git a/logger/console/ConsoleLogger.test.ts b/logger/console/ConsoleLogger.test.ts new file mode 100644 index 0000000..1ea8639 --- /dev/null +++ b/logger/console/ConsoleLogger.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { ConsoleLogger } from "./ConsoleLogger"; +import { LogLevel } from "../../types/LogLevel"; +import { Logger } from "../../types/Logger"; + +describe('ConsoleLogger', () => { + + let spyDebug : any; + let spyInfo : any; + let spyWarn : any; + let spyError : any; + let logger : Logger; + + beforeEach( () => { + spyDebug = jest.spyOn(console, 'debug').mockImplementation( () => {} ); + spyInfo = jest.spyOn(console, 'info').mockImplementation( () => {} ); + spyWarn = jest.spyOn(console, 'warn').mockImplementation( () => {} ); + spyError = jest.spyOn(console, 'error').mockImplementation( () => {} ); + logger = new ConsoleLogger(LogLevel.DEBUG); + }); + + afterEach( () => { + spyDebug.mockRestore(); + spyInfo.mockRestore(); + spyWarn.mockRestore(); + spyError.mockRestore(); + logger.setLogLevel(LogLevel.DEBUG); + }); + + describe('#debug', () => { + + it('logs messages with log level DEBUG', () => { + logger.debug('test message'); + expect(console.debug).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.debug('test message'); + expect(console.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.debug('test message'); + expect(console.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.debug('test message'); + expect(console.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.debug('test message'); + expect(console.debug).not.toHaveBeenCalled(); + }); + + }); + + describe('#info', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.info('test message'); + expect(console.info).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.info('test message'); + expect(console.info).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.info('test message'); + expect(console.info).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.info('test message'); + expect(console.info).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.info('test message'); + expect(console.info).not.toHaveBeenCalled(); + }); + + }); + + describe('#warn', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.warn('test message'); + expect(console.warn).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.warn('test message'); + expect(console.warn).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.warn('test message'); + expect(console.warn).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.warn('test message'); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.warn('test message'); + expect(console.warn).not.toHaveBeenCalled(); + }); + + }); + + describe('#error', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.error('test message'); + expect(console.error).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.error('test message'); + expect(console.error).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.error('test message'); + expect(console.error).toHaveBeenCalledWith('test message'); + }); + + it('logs messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.error('test message'); + expect(console.error).toHaveBeenCalledWith('test message'); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.error('test message'); + expect(console.error).not.toHaveBeenCalled(); + }); + + }); + +}); diff --git a/logger/console/ConsoleLogger.ts b/logger/console/ConsoleLogger.ts new file mode 100644 index 0000000..ced252f --- /dev/null +++ b/logger/console/ConsoleLogger.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Logger } from "../../types/Logger"; +import { LogLevel } from "../../types/LogLevel"; + +/** + * A logger implementation that writes log messages to standard console with + * its own control for LogLevel and a name of the context. + */ +export class ConsoleLogger implements Logger { + + /** + * The log level for this logger. If undefined, the parent logger's log + * level will be used. + * + * @private + */ + private _level : LogLevel; + + /** + * Constructs a new ConsoleLogger instance. + */ + public constructor ( + level?: LogLevel + ) { + this._level = level ?? LogLevel.DEBUG; + } + + /** + * @inheritDoc + */ + public getLogLevel () : LogLevel { + return this._level ?? LogLevel.DEBUG; + } + + /** + * @inheritDoc + */ + public setLogLevel (level : LogLevel | undefined) : this { + this._level = level ?? LogLevel.DEBUG; + return this; + } + + /** + * @inheritDoc + */ + public debug (...args: readonly any[]) { + if (this.getLogLevel() <= LogLevel.DEBUG) { + console.debug(...args); + } + } + + /** + * @inheritDoc + */ + public info (...args: readonly any[]) { + if (this.getLogLevel() <= LogLevel.INFO) { + console.info(...args); + } + } + + /** + * @inheritDoc + */ + public warn (...args: readonly any[]) { + if (this.getLogLevel() <= LogLevel.WARN) { + console.warn(...args); + } + } + + /** + * @inheritDoc + */ + public error (...args: readonly any[]) { + if (this.getLogLevel() <= LogLevel.ERROR) { + console.error(...args); + } + } + +} diff --git a/logger/context/ContextLogger.test.ts b/logger/context/ContextLogger.test.ts new file mode 100644 index 0000000..689e793 --- /dev/null +++ b/logger/context/ContextLogger.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { ContextLogger } from "./ContextLogger"; +import { LogLevel } from "../../types/LogLevel"; +import { Logger } from "../../types/Logger"; +import { MockLogger } from "../mock/MockLogger"; + +const TEST_CONTEXT_NAME = 'test'; + +describe('ContextLogger', () => { + + let mockLogger : Logger; + let spyDebug : any; + let spyInfo : any; + let spyWarn : any; + let spyError : any; + let logger : Logger; + + beforeEach( () => { + mockLogger = new MockLogger(); + spyDebug = jest.spyOn(mockLogger, 'debug').mockImplementation( () => {}); + spyInfo = jest.spyOn(mockLogger, 'info').mockImplementation( () => {}); + spyWarn = jest.spyOn(mockLogger, 'warn').mockImplementation( () => {}); + spyError = jest.spyOn(mockLogger, 'error').mockImplementation( () => {}); + logger = new ContextLogger( + TEST_CONTEXT_NAME, + mockLogger, + LogLevel.DEBUG + ); + }); + + afterEach( () => { + spyDebug.mockRestore(); + spyInfo.mockRestore(); + spyWarn.mockRestore(); + spyError.mockRestore(); + logger.setLogLevel(LogLevel.DEBUG); + }); + + describe('#debug', () => { + + it('logs messages with log level DEBUG', () => { + logger.debug('test message'); + expect(mockLogger.debug).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('does not log messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.debug('test message'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.debug('test message'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.debug('test message'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.debug('test message'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + }); + + describe('#info', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.info('test message'); + expect(mockLogger.info).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.info('test message'); + expect(mockLogger.info).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('does not log messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.info('test message'); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.info('test message'); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.info('test message'); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + }); + + describe('#warn', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.warn('test message'); + expect(mockLogger.warn).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.warn('test message'); + expect(mockLogger.warn).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('logs messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.warn('test message'); + expect(mockLogger.warn).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('does not log messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.warn('test message'); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.warn('test message'); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + }); + + describe('#error', () => { + + it('logs messages with log level DEBUG', () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.error('test message'); + expect(mockLogger.error).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('logs messages with log level INFO', () => { + logger.setLogLevel(LogLevel.INFO); + logger.error('test message'); + expect(mockLogger.error).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('logs messages with log level WARN', () => { + logger.setLogLevel(LogLevel.WARN); + logger.error('test message'); + expect(mockLogger.error).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('logs messages with log level ERROR', () => { + logger.setLogLevel(LogLevel.ERROR); + logger.error('test message'); + expect(mockLogger.error).toHaveBeenCalledWith(`[${TEST_CONTEXT_NAME}]`, 'test message'); + }); + + it('does not log messages with log level NONE', () => { + logger.setLogLevel(LogLevel.NONE); + logger.error('test message'); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + }); + +}); diff --git a/logger/context/ContextLogger.ts b/logger/context/ContextLogger.ts new file mode 100644 index 0000000..7bf5fc9 --- /dev/null +++ b/logger/context/ContextLogger.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021-2022. Sendanor . All rights reserved. + +import { Logger } from "../../types/Logger"; +import { LogLevel } from "../../types/LogLevel"; + +/** + * A logger implementation that writes log messages to a parent logger with + * its own control for LogLevel and a name of the context. + */ +export class ContextLogger implements Logger { + + /** + * The parent logger to which log messages will be written. + * + * @private + * @readonly + */ + private readonly _parentLogger : Logger; + + /** + * The name of the context to be used in log messages. + * + * @readonly + */ + public readonly name : string; + + /** + * The log level for this logger. If undefined, the parent logger's log + * level will be used. + * + * @private + */ + private _level : LogLevel | undefined; + + /** + * Constructs a new ContextLogger instance. + * + * @param name The name of the context to be used in log messages. + * @param logService The parent logger to which log messages will be written. + * @param level The initial log level. Undefined means to use value from parent. + */ + public constructor ( + name : string, + logService : Logger, + level ?: LogLevel | undefined + ) { + this.name = name; + this._parentLogger = logService; + this._level = level ?? undefined; + } + + /** + * @inheritDoc + */ + public getLogLevel () : LogLevel { + return this._level ?? this._parentLogger.getLogLevel() ?? LogLevel.DEBUG; + } + + /** + * @inheritDoc + */ + public setLogLevel (level : LogLevel | undefined) : this { + this._level = level; + return this; + } + + /** + * @inheritDoc + */ + public debug (...args: readonly any[]) { + if (this.getLogLevel() <= LogLevel.DEBUG) { + this._parentLogger.debug(`[${this.name}]`, ...args); + } + } + + /** + * @inheritDoc + */ + public info (...args: readonly any[]) { + if (this.getLogLevel() <= LogLevel.INFO) { + this._parentLogger.info(`[${this.name}]`, ...args); + } + } + + /** + * @inheritDoc + */ + public warn (...args: readonly any[]) { + if (this.getLogLevel() <= LogLevel.WARN) { + this._parentLogger.warn(`[${this.name}]`, ...args); + } + } + + /** + * @inheritDoc + */ + public error (...args: readonly any[]) { + if (this.getLogLevel() <= LogLevel.ERROR) { + this._parentLogger.error(`[${this.name}]`, ...args); + } + } + +} diff --git a/logger/discord/DiscordLogger.test.ts b/logger/discord/DiscordLogger.test.ts new file mode 100644 index 0000000..e997042 --- /dev/null +++ b/logger/discord/DiscordLogger.test.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { LogLevel } from "../../types/LogLevel"; +import { DiscordLogger } from "./DiscordLogger"; +import { MockRequestClientAdapter } from "../../requestClient/mock/MockRequestClientAdapter"; +import { Logger } from "../../types/Logger"; +import { RequestClientImpl } from "../../RequestClientImpl"; +import { RequestClientAdapter } from "../../requestClient/RequestClientAdapter"; +import { RequestMethod } from "../../request/types/RequestMethod"; +import { HttpService } from "../../HttpService"; + +const TEST_NAME = 'test'; +const TEST_DISCORD_URL = 'https://discord.com/api/webhooks/1234567890/ABCDEFG'; + +describe('DiscordLogger', () => { + + jest.useFakeTimers(); + + let logger : Logger; + let prevRequestClient : RequestClientAdapter | undefined; + let mockRequestClient : RequestClientAdapter; + let spy : any; + + beforeEach(() => { + HttpService.setLogLevel(LogLevel.NONE); + mockRequestClient = new MockRequestClientAdapter(); + prevRequestClient = RequestClientImpl.hasClient() ? RequestClientImpl.getClient() : undefined; + RequestClientImpl.setClient(mockRequestClient); + spy = jest.spyOn(mockRequestClient, 'jsonRequest'); + logger = new DiscordLogger( + TEST_NAME, + TEST_DISCORD_URL, + LogLevel.DEBUG, + undefined, + 2000, + 1000, + '>>>', + '...\n', + '\n' + ); + }); + + afterEach( () => { + if (prevRequestClient) { + RequestClientImpl.setClient(prevRequestClient); + } + spy.mockRestore(); + logger.setLogLevel(LogLevel.DEBUG); + }); + + describe('#debug', () => { + + it('should send message with level INFO to Discord when log level is DEBUG', async () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.debug('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [DEBUG] test message\n' }), + ); + }); + + it('should not send message with level INFO to Discord when log level is INFO', async () => { + logger.setLogLevel(LogLevel.INFO); + logger.debug('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + it('should not send message with level INFO to Discord when log level is WARN', async () => { + logger.setLogLevel(LogLevel.WARN); + logger.debug('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + it('should not send message with level INFO to Discord when log level is ERROR', async () => { + logger.setLogLevel(LogLevel.ERROR); + logger.debug('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + it('should not send message with level INFO to Discord when log level is NONE', async () => { + logger.setLogLevel(LogLevel.NONE); + logger.debug('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + }); + + describe('#info', () => { + + it('should send message with level INFO to Discord when log level is DEBUG', async () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.info('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [INFO] test message\n' }), + ); + }); + + it('should send message with level INFO to Discord when log level is INFO', async () => { + logger.setLogLevel(LogLevel.INFO); + logger.info('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [INFO] test message\n' }), + ); + }); + + it('should not send message with level INFO to Discord when log level is WARN', async () => { + logger.setLogLevel(LogLevel.WARN); + logger.info('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + it('should not send message with level INFO to Discord when log level is ERROR', async () => { + logger.setLogLevel(LogLevel.ERROR); + logger.info('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + it('should not send message with level INFO to Discord when log level is NONE', async () => { + logger.setLogLevel(LogLevel.NONE); + logger.info('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + }); + + describe('#warn', () => { + + it('should send message with level WARN to Discord when log level is DEBUG', async () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.warn('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [WARN] test message\n' }), + ); + }); + + it('should send message with level WARN to Discord when log level is INFO', async () => { + logger.setLogLevel(LogLevel.INFO); + logger.warn('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [WARN] test message\n' }), + ); + }); + + it('should send message with level WARN to Discord when log level is WARN', async () => { + logger.setLogLevel(LogLevel.WARN); + logger.warn('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [WARN] test message\n' }), + ); + }); + + it('should not send message with level WARN to Discord when log level is ERROR', async () => { + logger.setLogLevel(LogLevel.ERROR); + logger.warn('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + it('should not send message with level WARN to Discord when log level is NONE', async () => { + logger.setLogLevel(LogLevel.NONE); + logger.warn('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + }); + + describe('#error', () => { + + it('should send message with level ERROR to Discord when log level is DEBUG', async () => { + logger.setLogLevel(LogLevel.DEBUG); + logger.error('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [ERROR] test message\n' }), + ); + }); + + it('should send message with level ERROR to Discord when log level is INFO', async () => { + logger.setLogLevel(LogLevel.INFO); + logger.error('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [ERROR] test message\n' }), + ); + }); + + it('should send message with level ERROR to Discord when log level is WARN', async () => { + logger.setLogLevel(LogLevel.WARN); + logger.error('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [ERROR] test message\n' }), + ); + }); + + it('should send message with level ERROR to Discord when log level is ERROR', async () => { + logger.setLogLevel(LogLevel.ERROR); + logger.error('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).toHaveBeenCalledWith( + RequestMethod.POST, + TEST_DISCORD_URL, + undefined, // e.g. headers + expect.objectContaining({ content: '[test] [ERROR] test message\n' }), + ); + }); + + it('should not send message with level ERROR to Discord when log level is NONE', async () => { + logger.setLogLevel(LogLevel.NONE); + logger.error('test message'); + jest.advanceTimersByTime(1100); + expect(mockRequestClient.jsonRequest).not.toHaveBeenCalled(); + }); + + }); + +}); diff --git a/logger/discord/DiscordLogger.ts b/logger/discord/DiscordLogger.ts new file mode 100644 index 0000000..b9e836d --- /dev/null +++ b/logger/discord/DiscordLogger.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Logger } from "../../types/Logger"; +import { LogLevel } from "../../types/LogLevel"; +import { HttpService } from "../../HttpService"; +import { createDefaultHttpRetryPolicy, HttpRetryPolicy } from "../../request/types/HttpRetryPolicy"; +import { isNumber } from "../../types/Number"; +import { BufferedLogger } from "../buffered/BufferedLogger"; + +/** + * Maximum message size to send to Discord. + * + * @See Discord Webhook guide at https://birdie0.github.io/discord-webhooks-guide/discord_webhook.html + */ +const MAX_DISCORD_MESSAGE_LENGTH = 2000; + +/** + * A logger implementation that writes log messages to Discord using webhooks. + * + * @see https://birdie0.github.io/discord-webhooks-guide/discord_webhook.html + * @see {@link CompositeLogger} + */ +export class DiscordLogger implements Logger { + + /** + * Name which will be appended to the log message. + * + * It can be for example the service from where these messages are coming. + * + * @private + */ + private readonly _name : string; + + /** + * The webhook URL where to post values. + */ + private readonly _url : string; + + /** + * + * @private + */ + private readonly _retryPolicy : HttpRetryPolicy; + + private readonly _bufferedLogger : Logger; + + /** + * Constructs a new DiscordLogger instance. + */ + public constructor ( + name : string, + url : string, + level ?: LogLevel | undefined, + retryPolicy ?: HttpRetryPolicy | undefined, + maxMessageLength ?: number | undefined, + bufferDrainTimeout ?: number | undefined, + prefix ?: string, + suffix ?: string, + lineBreak ?: string + ) { + this._name = name; + this._url = url; + this._retryPolicy = retryPolicy ?? createDefaultHttpRetryPolicy(); + maxMessageLength = maxMessageLength !== undefined && isNumber(maxMessageLength) && maxMessageLength < MAX_DISCORD_MESSAGE_LENGTH ? maxMessageLength : MAX_DISCORD_MESSAGE_LENGTH; + + this._bufferedLogger = new BufferedLogger( + (value: string) => { + this._sendMessageAsStringSync(value); + }, + maxMessageLength ?? 2000, + bufferDrainTimeout ?? 1000, + prefix ?? '...', + suffix ?? '...\n', + lineBreak ?? '\n', + level ?? LogLevel.DEBUG + ); + + } + + /** + * @inheritDoc + */ + public getLogLevel () : LogLevel { + return this._bufferedLogger.getLogLevel(); + } + + /** + * @inheritDoc + */ + public setLogLevel (level : LogLevel | undefined) : this { + this._bufferedLogger.setLogLevel(level); + return this; + } + + /** + * @inheritDoc + */ + public debug (...args: readonly any[]) { + this._bufferedLogger.debug(...args); + } + + /** + * @inheritDoc + */ + public info (...args: readonly any[]) { + this._bufferedLogger.info(...args); + } + + /** + * @inheritDoc + */ + public warn (...args: readonly any[]) { + this._bufferedLogger.warn(...args); + } + + /** + * @inheritDoc + */ + public error (...args: readonly any[]) { + this._bufferedLogger.error(...args); + } + + private _sendMessageAsStringSync ( + content : string + ) : void { + this._sendMessageAsString(`[${this._name}] ${content}`).catch( + (err: any) => { + console.warn(`Warning! Error "${err}" while sending the log message: "${content}"`); + console.warn(`Error object: `, err); + } + ); + } + + private async _sendMessageAsString ( + content : string + ) : Promise { + const body = { + content, + options: {} + }; + await HttpService.postJson( + this._url, + body, + undefined, + this._retryPolicy + ); + } + +} diff --git a/logger/mock/MockLogger.ts b/logger/mock/MockLogger.ts new file mode 100644 index 0000000..7c73348 --- /dev/null +++ b/logger/mock/MockLogger.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Logger } from "../../types/Logger"; +import { LogLevel } from "../../types/LogLevel"; + +/** + * @inheritDoc + */ +export class MockLogger implements Logger { + + /** + * @inheritDoc + */ + public debug ( + // @ts-ignore + ...args: readonly any[]): void { + } + + /** + * @inheritDoc + */ + public error ( + // @ts-ignore + ...args: readonly any[]): void { + } + + /** + * @inheritDoc + */ + public getLogLevel (): LogLevel { + return LogLevel.DEBUG; + } + + /** + * @inheritDoc + */ + public info ( + // @ts-ignore + ...args: readonly any[]): void { + } + + /** + * @inheritDoc + */ + public setLogLevel ( + // @ts-ignore + level: LogLevel | undefined): this { + return this; + } + + /** + * @inheritDoc + */ + public warn ( + // @ts-ignore + ...args: readonly any[]): void { + } + +} diff --git a/mailhog/MailHogClient.ts b/mailhog/MailHogClient.ts new file mode 100644 index 0000000..28b508d --- /dev/null +++ b/mailhog/MailHogClient.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { + MAIL_HOG_API_GET_MESSAGES_PATH, + MAIL_HOG_API_DELETE_MESSAGES_PATH +} from "./mailhog-api"; +import { LogLevel } from "../types/LogLevel"; +import { LogService } from "../LogService"; +import { HttpService } from "../HttpService"; +import { isMailHogMessagesDTO, MailHogMessageListDTO } from "./dto/MailHogMessageListDTO"; + +const LOG = LogService.createLogger('MailHogClient'); + +/** + * @see https://github.com/mailhog/MailHog/blob/master/docs/APIv1.md + */ +export class MailHogClient { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + HttpService.setLogLevel(level); + } + + private static _defaultUrl : string = '/'; + + private readonly _url : string; + + public static setDefaultUrl (url : string) { + this._defaultUrl = url; + } + + public static getDefaultUrl () : string { + return this._defaultUrl; + } + + public static create ( + url : string = MailHogClient._defaultUrl + ) : MailHogClient { + return new MailHogClient(url); + } + + public constructor ( + url : string = MailHogClient._defaultUrl + ) { + this._url = url; + } + + public getUrl () : string { + return this._url; + } + + public async getMessages () : Promise { + const result = await HttpService.getJson(`${this._url}${MAIL_HOG_API_GET_MESSAGES_PATH}`); + if (!isMailHogMessagesDTO(result)) { + LOG.debug(`getIndex: result = `, result); + throw new TypeError(`Result was not MailHogMessagesDTO: ` + result); + } + return result; + } + + public async deleteMessages () : Promise { + await HttpService.deleteText(`${this._url}${MAIL_HOG_API_DELETE_MESSAGES_PATH}`); + } + +} diff --git a/mailhog/dto/MailHogAddressDTO.ts b/mailhog/dto/MailHogAddressDTO.ts new file mode 100644 index 0000000..da0a788 --- /dev/null +++ b/mailhog/dto/MailHogAddressDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isNull } from "../../types/Null"; +import { isString } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; + +export interface MailHogAddressDTO { + readonly Domain : string; + readonly Mailbox : string; + readonly Params : string; + readonly Relays : null; +} + +export function isMailHogAddressDTO (value: any): value is MailHogAddressDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'Domain', + 'Mailbox', + 'Params', + 'Relays' + ]) + && isString(value?.Domain) + && isString(value?.Mailbox) + && isString(value?.Params) + && isNull(value?.Relays) + ); +} + +export function stringifyMailHogAddressDTO (value: MailHogAddressDTO): string { + return `MailHogAddressDTO(${value})`; +} + +export function parseMailHogAddressDTO (value: any): MailHogAddressDTO | undefined { + if ( isMailHogAddressDTO(value) ) return value; + return undefined; +} diff --git a/mailhog/dto/MailHogContentDTO.ts b/mailhog/dto/MailHogContentDTO.ts new file mode 100644 index 0000000..95786d3 --- /dev/null +++ b/mailhog/dto/MailHogContentDTO.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isNull } from "../../types/Null"; +import { HeadersObject, isHeadersObject } from "../../request/types/HeadersObject"; +import { isString } from "../../types/String"; +import { isNumber } from "../../types/Number"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; + +export interface MailHogContentDTO { + readonly Body : string; + readonly Headers : HeadersObject; + readonly MIME : null; + readonly Size : number; +} + +export function isMailHogContentDTO (value: any): value is MailHogContentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'Body' + , 'Headers' + , 'MIME' + , 'Size' + ]) + && isString(value?.Body) + && isHeadersObject(value?.Headers) + && isNull(value?.MIME) + && isNumber(value?.Size) + ); +} + +export function stringifyMailHogContentDTO (value: MailHogContentDTO): string { + return `MailHogContentDTO(${value})`; +} + +export function parseMailHogContentDTO (value: any): MailHogContentDTO | undefined { + if ( isMailHogContentDTO(value) ) return value; + return undefined; +} diff --git a/mailhog/dto/MailHogMessageDTO.ts b/mailhog/dto/MailHogMessageDTO.ts new file mode 100644 index 0000000..43aa82d --- /dev/null +++ b/mailhog/dto/MailHogMessageDTO.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isMailHogContentDTO, MailHogContentDTO } from "./MailHogContentDTO"; +import { isMailHogAddressDTO, MailHogAddressDTO } from "./MailHogAddressDTO"; +import { isMailHogMimeDTO, MailHogMimeDTO } from "./MailHogMimeDTO"; +import { isString } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { isArrayOf } from "../../types/Array"; + +export interface MailHogMessageDTO { + readonly ID : string; + readonly Created : string; + readonly From : MailHogAddressDTO; + readonly To : MailHogAddressDTO[]; + readonly Content : MailHogContentDTO; + readonly MIME : MailHogMimeDTO; +} + +export function isMailHogMessageDTO (value: any): value is MailHogMessageDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ID', + 'Created', + 'From', + 'MIME', + 'Raw', + 'To', + 'Content' + ]) + && isString(value?.ID) + && isString(value?.Created) + && isMailHogAddressDTO(value?.From) + && isMailHogMimeDTO(value?.MIME) + && isMailHogContentDTO(value?.Content) + && isArrayOf(value?.To, isMailHogAddressDTO) + ); +} + +export function stringifyMailHogMessageDTO (value: MailHogMessageDTO): string { + return `MailHogMessageDTO(${value})`; +} + +export function parseMailHogMessageDTO (value: any): MailHogMessageDTO | undefined { + if ( isMailHogMessageDTO(value) ) return value; + return undefined; +} diff --git a/mailhog/dto/MailHogMessageListDTO.ts b/mailhog/dto/MailHogMessageListDTO.ts new file mode 100644 index 0000000..43d4c34 --- /dev/null +++ b/mailhog/dto/MailHogMessageListDTO.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isMailHogMessageDTO, MailHogMessageDTO } from "./MailHogMessageDTO"; +import { isArrayOf } from "../../types/Array"; + +export type MailHogMessageListDTO = MailHogMessageDTO[]; + +export function isMailHogMessagesDTO (value: any): value is MailHogMessageListDTO { + return isArrayOf(value, isMailHogMessageDTO); +} + +export function stringifyMailHogMessagesDTO (value: MailHogMessageListDTO): string { + return `MailHogMessagesDTO(${value})`; +} + +export function parseMailHogMessagesDTO (value: any): MailHogMessageListDTO | undefined { + if ( isMailHogMessagesDTO(value) ) return value; + return undefined; +} diff --git a/mailhog/dto/MailHogMimeDTO.ts b/mailhog/dto/MailHogMimeDTO.ts new file mode 100644 index 0000000..00ddfcf --- /dev/null +++ b/mailhog/dto/MailHogMimeDTO.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isMailHogContentDTO, MailHogContentDTO } from "./MailHogContentDTO"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { isArrayOf } from "../../types/Array"; + +export interface MailHogMimeDTO { + readonly Parts : MailHogContentDTO[]; +} + +export function isMailHogMimeDTO (value: any): value is MailHogMimeDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'Parts' + ]) + && isArrayOf(value?.Parts, isMailHogContentDTO) + ); +} + +export function stringifyMailHogMimeDTO (value: MailHogMimeDTO): string { + return `MailHogMimeDTO(${value})`; +} + +export function parseMailHogMimeDTO (value: any): MailHogMimeDTO | undefined { + if ( isMailHogMimeDTO(value) ) return value; + return undefined; +} diff --git a/mailhog/mailhog-api.ts b/mailhog/mailhog-api.ts new file mode 100644 index 0000000..5e3c5f9 --- /dev/null +++ b/mailhog/mailhog-api.ts @@ -0,0 +1,10 @@ + +/** + * `GET /api/v1/messages` + */ +export const MAIL_HOG_API_GET_MESSAGES_PATH = '/api/v1/messages'; + +/** + * `DELETE /api/v1/messages` + */ +export const MAIL_HOG_API_DELETE_MESSAGES_PATH = '/api/v1/messages'; diff --git a/matrix/.github/FUNDING.yml b/matrix/.github/FUNDING.yml new file mode 100644 index 0000000..831a77b --- /dev/null +++ b/matrix/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [heusalagroup] diff --git a/matrix/.gitignore b/matrix/.gitignore new file mode 100644 index 0000000..01896fd --- /dev/null +++ b/matrix/.gitignore @@ -0,0 +1,106 @@ +.idea + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port diff --git a/matrix/LICENSE.md b/matrix/LICENSE.md new file mode 100644 index 0000000..f4efd97 --- /dev/null +++ b/matrix/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022 Heusala Group Ltd +Copyright (c) 2021 Sendanor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/matrix/MatrixCrudCLI.ts b/matrix/MatrixCrudCLI.ts new file mode 100644 index 0000000..e3c8cea --- /dev/null +++ b/matrix/MatrixCrudCLI.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2021. Heusala Group Oy . All rights reserved. + +export class MatrixCrudCLI { + + public getAll () { + + } + +} + + diff --git a/matrix/MatrixCrudRepository.ts b/matrix/MatrixCrudRepository.ts new file mode 100644 index 0000000..3008043 --- /dev/null +++ b/matrix/MatrixCrudRepository.ts @@ -0,0 +1,894 @@ +// Copyright (c) 2021-2022. Sendanor . All rights reserved. + +import { SimpleRepositoryEntry } from "../simpleRepository/types/SimpleRepositoryEntry"; +import { SimpleRepository, REPOSITORY_NEW_IDENTIFIER } from "../simpleRepository/types/SimpleRepository"; +import { SimpleMatrixClient } from "./SimpleMatrixClient"; +import { MatrixCreateRoomResponseDTO } from "./types/response/createRoom/MatrixCreateRoomResponseDTO"; +import { MatrixCreateRoomPreset } from "./types/request/createRoom/types/MatrixCreateRoomPreset"; +import { isJsonObject, ReadonlyJsonAny, ReadonlyJsonObject } from "../Json"; +import { MatrixSyncResponseDTO } from "./types/response/sync/MatrixSyncResponseDTO"; +import { LogService } from "../LogService"; +import { concat} from "../functions/concat"; +import { filter } from "../functions/filter"; +import { forEach } from "../functions/forEach"; +import { get } from "../functions/get"; +import { map } from "../functions/map"; +import { reduce } from "../functions/reduce"; +import { uniq } from "../functions/uniq"; +import { MatrixRoomId } from "./types/core/MatrixRoomId"; +import { MatrixSyncResponseJoinedRoomDTO } from "./types/response/sync/types/MatrixSyncResponseJoinedRoomDTO"; +import { MatrixSyncResponseRoomEventDTO } from "./types/response/sync/types/MatrixSyncResponseRoomEventDTO"; +import { isMatrixType, MatrixType } from "./types/core/MatrixType"; +import { RequestError } from "../request/types/RequestError"; +import { PutRoomStateWithEventTypeResponseDTO } from "./types/response/setRoomStateByType/PutRoomStateWithEventTypeResponseDTO"; +import { MatrixCreateRoomDTO } from "./types/request/createRoom/MatrixCreateRoomDTO"; +import { createMatrixStateEvent } from "./types/core/MatrixStateEvent"; +import { MatrixRoomCreateEventDTO } from "./types/event/roomCreate/MatrixRoomCreateEventDTO"; +import { MatrixUserId } from "./types/core/MatrixUserId"; +import { MatrixHistoryVisibility } from "./types/event/roomHistoryVisibility/MatrixHistoryVisibility"; +import { MatrixJoinRule } from "./types/event/roomJoinRules/MatrixJoinRule"; +import { MatrixGuestAccess } from "./types/event/roomGuestAccess/MatrixGuestAccess"; +import { MatrixRoomJoinedMembersDTO } from "./types/response/roomJoinedMembers/MatrixRoomJoinedMembersDTO"; +import { SimpleRepositoryMember } from "../simpleRepository/types/SimpleRepositoryMember"; +import { LogLevel } from "../types/LogLevel"; +import { createRoomGuestAccessStateEventDTO } from "./types/event/roomGuestAccess/RoomGuestAccessStateEventDTO"; +import { createRoomGuestAccessContentDTO } from "./types/event/roomGuestAccess/RoomGuestAccessContentDTO"; +import { MatrixStateEventOf } from "./types/core/MatrixStateEventOf"; +import { createRoomHistoryVisibilityStateEventDTO } from "./types/event/roomHistoryVisibility/RoomHistoryVisibilityStateEventDTO"; +import { createRoomHistoryVisibilityStateContentDTO } from "./types/event/roomHistoryVisibility/RoomHistoryVisibilityStateContentDTO"; +import { createRoomJoinRulesAllowConditionDTO, RoomJoinRulesAllowConditionDTO } from "./types/event/roomJoinRules/RoomJoinRulesAllowConditionDTO"; +import { RoomMembershipType } from "./types/event/roomJoinRules/RoomMembershipType"; +import { createRoomJoinRulesStateContentDTO } from "./types/event/roomJoinRules/RoomJoinRulesStateContentDTO"; +import { createRoomJoinRulesStateEventDTO } from "./types/event/roomJoinRules/RoomJoinRulesStateEventDTO"; +import { SetRoomStateByTypeRequestDTO } from "./types/request/setRoomStateByType/SetRoomStateByTypeRequestDTO"; +import { GetRoomStateByTypeResponseDTO } from "./types/response/getRoomStateByType/GetRoomStateByTypeResponseDTO"; +import { isStoredRepositoryItem, SimpleStoredRepositoryItem, StoredRepositoryItemExplainCallback, StoredRepositoryItemTestCallback } from "../simpleRepository/types/SimpleStoredRepositoryItem"; +import { SimpleRepositoryUtils } from "../simpleRepository/SimpleRepositoryUtils"; +import { MatrixRoomVersion } from "./types/MatrixRoomVersion"; +import { explainNot, explainOk } from "../types/explain"; +import { parseNonEmptyString } from "../types/String"; +import { isInteger, isNumber } from "../types/Number"; +import { keys } from "../functions/keys"; + +const LOG = LogService.createLogger('MatrixCrudRepository'); + +/** + * Saves JSON-able objects of type T as special Matrix.org rooms identified by `stateType` and + * `stateKey`. + * + * See also [MemoryRepository](https://github.com/sendanor/typescript/tree/main/simpleRepository) + */ +export class MatrixCrudRepository implements SimpleRepository { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _client : SimpleMatrixClient; + private readonly _serviceAccount : SimpleMatrixClient | undefined; + private readonly _stateType : MatrixType; + private readonly _stateKey : string; + private readonly _deletedType : string; + private readonly _deletedKey : string; + private readonly _allowedGroups : readonly MatrixRoomId[] | undefined; + private readonly _allowedEvents : readonly string[] | undefined; + private readonly _isT : StoredRepositoryItemTestCallback; + private readonly _explainT : StoredRepositoryItemExplainCallback; + private readonly _tName : string; + + /** + * Creates an instance of MatrixCrudRepository. + * + * @param client Use `SimpleMatrixClient.login(user, pw) : + * Promise` + * to get a client instance which has been authenticated. + * + * @param stateType The MatrixType for this type of resource. Use matrix-style namespace + * syntax, eg. `com.example.foo.dto`. + * + * @param stateKey Optional. The state key, defaults to ''. + * + * @param serviceAccount Optional. If defined, this service account user will be joined to any + * created rooms and removed from them when resoure-room is destroyed. + * + * @param deletedType Optional. The state event type to add to any resource which is + * deleted. Defaults to `MatrixType.FI_NOR_DELETED`. + * NOTE! This only has partial support. Filtering for example does not support it yet. + * + * @param deletedKey Optional. The state key for deletedType, defaults to ''. + * + * @param allowedGroups Optional. List of Matrix rooms who's members will be able to access + * any resources (eg. rooms) created in this repository without an invite. + * + * @param allowedEvents Optional. List of allowed event IDs in the room. + * + * @param isT Optional. Test function to check if the type really is T. + * @param explainT Optional. Function to explain if isT fails + * @param tName Optional. The name of the T type for debugging purposes. Defaults to "T". + * + */ + public constructor ( + client : SimpleMatrixClient, + stateType : string, + stateKey : string | undefined = undefined, + serviceAccount : SimpleMatrixClient | undefined = undefined, + deletedType : string | undefined = undefined, + deletedKey : string | undefined = undefined, + allowedGroups : readonly MatrixRoomId[] | undefined = undefined, + allowedEvents : readonly string[] | undefined = undefined, + isT : StoredRepositoryItemTestCallback | undefined = undefined, + tName : string | undefined = undefined, + explainT : StoredRepositoryItemExplainCallback | undefined = undefined + ) { + + if (!isMatrixType(stateType)) { + throw new TypeError('MatrixCrudRepository: stateType invalid: ' + stateType); + } + + this._client = client; + this._stateType = stateType; + this._stateKey = stateKey ?? ''; + this._serviceAccount = serviceAccount ?? undefined; + this._deletedType = parseNonEmptyString(deletedType) ?? MatrixType.FI_NOR_DELETED; + this._deletedKey = deletedKey ?? ''; + this._allowedEvents = allowedEvents; + this._isT = isT ?? isStoredRepositoryItem; + this._tName = tName ?? 'T'; + this._explainT = explainT ?? ( (value: any) : string => this._isT(value) ? explainOk() : explainNot(this._tName) ); + + if (allowedGroups === undefined) { + this._allowedGroups = undefined; + } else { + this._allowedGroups = [...allowedGroups]; + } + + } + + /** + * Returns all resources (eg. Matrix rooms) from the repository which are of this type. + * + * @returns Array of resources + */ + public async getAll () : Promise[]> { + const list = this._getAll(); + if (!this.isRepositoryEntryList(list)) { + throw new TypeError(`MatrixCrudRepository.getAll: Illegal data from database: ${this.explainRepositoryEntryList(list)}`); + } + return list; + } + + /** + * Returns all resources (eg. Matrix rooms) from the repository which are of this type. + * + * @returns Array of resources + */ + public async getSome (idList : readonly string[]) : Promise[]> { + const allList : readonly SimpleRepositoryEntry[] = await this._getAll(); + const list = filter( + allList, + (item : SimpleRepositoryEntry) : boolean => !!item?.id && idList.includes(item?.id) + ); + if (!this.isRepositoryEntryList(list)) { + throw new TypeError(`MatrixCrudRepository.getSome: Illegal data from database: ${this.explainRepositoryEntryList(list)}`); + } + return list; + } + + /** + * Returns all resources (eg. Matrix rooms) which have this property defined in their state. + * + * @param propertyName This may also be a path to value inside the model, + * eg. `user.id` to match `{user: {id: 123}}`. + * + * @param propertyValue The value to find + * + * @returns Array of resources + */ + public async getAllByProperty ( + propertyName : string, + propertyValue : any + ): Promise[]> { + const items = await this._getAll(); + const list = map( + filter( + items, + (item: SimpleRepositoryEntry) : boolean => get(item?.data, propertyName) === propertyValue + ), + (item: SimpleRepositoryEntry) : SimpleRepositoryEntry => ({ + id : item.id, + version : item.version, + data : item.data + }) + ); + if (!this.isRepositoryEntryList(list)) { + throw new TypeError(`MatrixCrudRepository.getAllByProperty: Illegal data from database: ${this.explainRepositoryEntryList(list)}`); + } + return list; + } + + /** + * Creates a resource in the repository for `data`, eg. a room in Matrix for this resource. + * + * @param data The data of the resource. + * @param members Any members which will be invited to this resource + * + * @returns The new resource + */ + public async createItem ( + data : T, + members ?: readonly string[] + ) : Promise> { + + const clientUserId : string | undefined = this._client.getUserId(); + LOG.debug(`createItem: clientUserId = `, clientUserId); + + const jsonData : ReadonlyJsonAny = data as unknown as ReadonlyJsonAny; + const version : number = 1; + + const content : ReadonlyJsonObject = { + data : jsonData, + version : version + }; + LOG.debug(`createItem: content = `, content); + + const serviceAccountId = this._serviceAccount?.getUserId(); + LOG.debug(`createItem: serviceAccountId = `, serviceAccountId); + + const invitedMembers : readonly MatrixUserId[] = ( + filter( + uniq(concat( + serviceAccountId ? [ serviceAccountId ]: [], + members ? members : [] + )), + item => item !== clientUserId + ) + ); + LOG.debug(`createItem: invitedMembers = `, invitedMembers); + + const allowedGroups : readonly MatrixRoomId[] | undefined = this._allowedGroups; + LOG.debug(`createItem: allowedGroups = `, allowedGroups); + + const creationContent : Partial = { + [MatrixType.M_FEDERATE]: false + }; + + let initialState : readonly MatrixStateEventOf[] = [ + + // Set our own state which indicates this is a special group for our CRUD item, + // including our CRUD item value. + createMatrixStateEvent( + this._stateType, + this._stateKey, + content + ), + + // Allow visibility to older events + createRoomHistoryVisibilityStateEventDTO( + createRoomHistoryVisibilityStateContentDTO( + MatrixHistoryVisibility.SHARED + ) + ), + + // Disallow guest from joining + createRoomGuestAccessStateEventDTO( + createRoomGuestAccessContentDTO(MatrixGuestAccess.FORBIDDEN) + ) + + ]; + + // Allow members from these groups to access the item. + // See also https://github.com/matrix-org/matrix-doc/blob/master/proposals/3083-restricted-rooms.md + if (allowedGroups !== undefined) { + initialState = [ + ...initialState, + ...[ + createRoomJoinRulesStateEventDTO( + createRoomJoinRulesStateContentDTO( + MatrixJoinRule.RESTRICTED, + map( + allowedGroups, + (item : MatrixRoomId) : RoomJoinRulesAllowConditionDTO => createRoomJoinRulesAllowConditionDTO(RoomMembershipType.M_ROOM_MEMBERSHIP, item) + ) + ) + ) + ] + ]; + } + + LOG.debug(`createItem: initialState = `, initialState); + + const inviteOptions : Partial = ( + invitedMembers.length ? {invite: invitedMembers} : {} + ); + LOG.debug(`createItem: inviteOptions = `, inviteOptions); + + const allowedEventsObject = { + [this._stateType]: 0, + [this._deletedType]: 0 + }; + + if (this._allowedEvents?.length) { + forEach(this._allowedEvents, (eventName : string ) => { + allowedEventsObject[eventName] = 0; + }); + } + + const options : MatrixCreateRoomDTO = { + ...inviteOptions, + preset: MatrixCreateRoomPreset.PRIVATE_CHAT, + creation_content: creationContent, + initial_state: initialState, + room_version: MatrixRoomVersion.V8, + power_level_content_override: { + events: allowedEventsObject + } + }; + + const response : MatrixCreateRoomResponseDTO = await this._client.createRoom(options); + LOG.debug(`createItem: response = `, response); + + const room_id = response.room_id; + LOG.debug(`createItem: room_id = `, room_id); + + if ( this._serviceAccount && clientUserId && clientUserId !== this._serviceAccount.getUserId() ) { + try { + await this._serviceAccount.joinRoom(room_id); + } catch (err : any) { + LOG.warn(`Warning! Could not join service account to room "${room_id}": `, err); + } + } + + return { + id : room_id, + version : version, + data : data, + deleted : false + }; + + } + + /** + * Search a resource from the repository with this ID. + * + * @param id The ID of the resource. It's also a Matrix Room ID. + * + * @param includeMembers Include list of members who have access to this item. + * + * @returns Promise of the latest resource with this ID, if it's defined, otherwise + * `undefined`. + */ + public async findById ( + id : string, + includeMembers ?: boolean + ) : Promise | undefined> { + + const response : GetRoomStateByTypeResponseDTO | undefined = await this._client.getRoomStateByType( + id, + this._stateType, + this._stateKey + ); + + if (response === undefined) { + LOG.debug(`findById: response not found for ${id}`); + return undefined; + } + + LOG.debug(`findById: response = `, JSON.stringify(response, null, 2)); + + const data = response?.data; + if (!isJsonObject(data)) { + throw new TypeError(`MatrixCrudRepository.findById: data was not JsonObject: ${data}`); + } + + const version = response?.version; + if (!isInteger(version)) { + throw new TypeError(`MatrixCrudRepository.findById: version was not integer: ${version}`); + } + + let members : readonly SimpleRepositoryMember[] | undefined = undefined; + if (includeMembers) { + const dto : MatrixRoomJoinedMembersDTO = await this._client.getJoinedMembers(id); + members = map(keys(dto.joined), (memberId: string) : SimpleRepositoryMember => { + const member = dto.joined[memberId]; + return { + id : memberId, + displayName : member.display_name, + avatarUrl : member?.avatar_url ? member.avatar_url : undefined + }; + }); + } + + return { + // @ts-ignore + data: data, + id: id, + version: version, + members + }; + + } + + /** + * Returns one resource (eg. Matrix room) which have this property defined in their state. + * + * If no resource found, returns `undefined`. + * + * @param propertyName This may also be a path to value inside the model, + * eg. `user.id` to match `{user: {id: 123}}`. + * + * @param propertyValue The value to find + * + * @throws TypeError if multiple values found + * + */ + public async findByProperty ( + propertyName : string, + propertyValue : any + ) : Promise | undefined> { + const result = await this.getAllByProperty(propertyName, propertyValue); + const resultCount : number = result?.length ?? 0; + if (resultCount === 0) return undefined; + if (resultCount !== 1) throw new TypeError(`MemoryRepository.findByProperty: Multiple items found by property "${propertyName}" as: ${propertyValue}`); + return result[0]; + } + + /** + * Find a record by an ID and update it. + * + * @param id + * @param item + * @protected + */ + public async findByIdAndUpdate ( + id: string, + item: T + ) : Promise> { + const rItem : SimpleRepositoryEntry | undefined = await this.findById(id); + if (rItem === undefined) throw new TypeError(`findByIdAndUpdate: Could not find item for "${id}"`); + return await this.update(rItem.id, item); + } + + /** + * Update the state of a resource located by this ID. + * + * It will set the state of the Matrix room to `jsonData` with a newer version number. + * + * @param id The ID of the resource. It's a Matrix Room ID. + * + * @param jsonData New data + */ + public async update (id: string, jsonData: T) : Promise> { + + if (!isJsonObject(jsonData)) { + throw new TypeError(`MatrixCrudRepository.update: jsonData was not JsonObject: ${jsonData}`); + } + + const record = await this.findById(id); + + if (record === undefined) { + throw new RequestError(404); + } + + const newVersion : number = record.version + 1; + if (!isInteger(newVersion)) { + throw new TypeError(`MatrixCrudRepository.update: newVersion was not integer: ${newVersion}`); + } + + const content : SetRoomStateByTypeRequestDTO = { + // @ts-ignore + data : jsonData, + version : newVersion + }; + + const response : PutRoomStateWithEventTypeResponseDTO = await this._client.setRoomStateByType( + id, + this._stateType, + this._stateKey, + content + ); + + LOG.debug(`response = `, JSON.stringify(response, null, 2)); + + return { + data: jsonData, + id: id, + version: newVersion, + deleted: false + }; + + } + + /** + * Update the state of a resource located by this ID. + * + * It will set the state of the Matrix room to `jsonData` with a newer version number. + * + * @param item New data + */ + public async updateOrCreateItem (item: T) : Promise> { + if (!isJsonObject(item)) { + throw new TypeError(`MatrixCrudRepository.updateOrCreateItem: jsonData was not JsonObject: ${item}`); + } + const id = item.id; + const foundItem : SimpleRepositoryEntry | undefined = id !== REPOSITORY_NEW_IDENTIFIER ? await this.findById(id) : undefined; + if (foundItem) { + return await this.update(foundItem.id, item); + } else { + return await this.createItem(item); + } + } + + /** + * Removes a resource by `id` from repository. + * + * This will make the client leave & forget the Matrix room for this resource. + * + * If the service account is defined, it will also make the service account to leave & forget + * the room. + * + * @FIXME Make the client and/or service account kick every other user out of the room also. + * + * @param id The ID of the resource to delete. This is a Matrix room ID. + * + * @returns The resource with `deleted` property as `false` + * + */ + public async deleteById (id: string) : Promise> { + + let record : SimpleRepositoryEntry | undefined; + + try { + + record = await this.findById(id); + LOG.debug(`deleteById: record = `, record); + + if (record === undefined) { + // FIXME: Create our own errors. HTTP error is wrong here. + throw new RequestError(404); + } + + const newVersion : number = record.version + 1; + if (!isInteger(newVersion)) { + throw new TypeError(`MatrixCrudRepository.deleteById: newVersion was not integer: ${newVersion}`); + } + LOG.debug(`deleteById: newVersion = `, newVersion); + + const content : SetRoomStateByTypeRequestDTO = { + data : record.data as unknown as ReadonlyJsonObject, + version : newVersion, + deleted : true + }; + LOG.debug(`deleteById: content = `, content); + + const response : PutRoomStateWithEventTypeResponseDTO = await this._client.setRoomStateByType( + id, + this._stateType, + this._stateKey, + content + ); + LOG.debug(`deleteById: response = `, response); + + const deletedResponse : PutRoomStateWithEventTypeResponseDTO = await this._client.setRoomStateByType( + id, + this._deletedType, + this._deletedKey, + {} + ); + LOG.debug(`deleteById: deletedResponse = `, deletedResponse); + + if (this._serviceAccount) { + + try { + LOG.debug(`Leaving from room "${id}" as service account`); + await this._serviceAccount.leaveRoom(id); + } catch (err : any) { + LOG.warn(`Warning! Service account could not leave from the room ${id}: `, err); + } + + try { + LOG.debug(`Forgetting room "${id}" as service account`); + await this._serviceAccount.forgetRoom(id); + } catch (err : any) { + LOG.warn(`Warning! Service account could not forget the room ${id}: `, err); + } + + } + + LOG.debug(`Leaving from room "${id}"`); + await this._client.leaveRoom(id); + + LOG.debug(`Forgetting room "${id}"`); + await this._client.forgetRoom(id); + + LOG.debug(`response = `, JSON.stringify(response, null, 2)); + return { + data: record.data, + id: id, + version: newVersion, + deleted: true + }; + + } catch (err : any) { + + if ( err instanceof RequestError && [401, 403, 404].includes(err.getStatusCode()) ) { + throw err; + } + + LOG.error(`Error in deleteById(${id}): `, err); + + // FIXME: Create our own errors. HTTP error is wrong here. + throw new RequestError(500); + + } + + } + + /** + * + * @param list + */ + public async deleteByIdList (list: readonly string[]) : Promise[]> { + const results : SimpleRepositoryEntry[] = []; + let i = 0; + for (; i < list.length; i += 1) { + results.push( await this.deleteById(list[i]) ); + } + if (!this.isRepositoryEntryList(results)) { + throw new TypeError(`MatrixCrudRepository.getAllByProperty: Illegal data from database: ${this.explainRepositoryEntryList(results)}`); + } + return results; + } + + /** + * Delete by item list + * + * @param list + */ + public async deleteByList (list: readonly SimpleRepositoryEntry[]) : Promise[]> { + return this.deleteByIdList( map(list, item => item.id) ); + } + + /** + * Delete all + */ + public async deleteAll () : Promise[]> { + const list : readonly SimpleRepositoryEntry[] = await this._getAll(); + return this.deleteByIdList( map(list, item => item.id) ); + } + + /** + * + * @param id + * @param members + */ + public async inviteToItem ( + id : string, + members : readonly string[] + ): Promise { + + let serviceAccountUserId : string | undefined; + if (this._serviceAccount) { + serviceAccountUserId = this._serviceAccount?.getUserId(); + if (!serviceAccountUserId) { + serviceAccountUserId = await this._serviceAccount.whoami(); + } + } + + await reduce( + members, + async (p : Promise, item : string) => { + + await p; + + if ( serviceAccountUserId && item === serviceAccountUserId ) { + return; + } + + try { + await this._client.inviteToRoom(id, item); + } catch (err : any) { + + if ( this._client.isAlreadyInTheRoom(err?.body) ) return; + + LOG.error(`Warning! Could not invite user ${item} to room ${id}: `, err); + throw err; + + } + + }, + Promise.resolve() + ) + + } + + /** + * + * @param id + */ + public async subscribeToItem ( + id : string + ): Promise { + + await this._client.joinRoom(id); + + } + + /** + * Wait for item to change + * @param id + * @param includeMembers + * @param timeout + */ + public async waitById ( + id : string, + includeMembers ?: boolean, + timeout ?: number + ) : Promise< SimpleRepositoryEntry | undefined > { + + if (!id) throw new TypeError(`MatrixCrudRepository.waitById: id is required: ${id}`); + + const isNotTimeout : boolean = await this._client.waitForEvents( + [ + this._stateType + ], [ + id + ], + timeout + ); + + if (!isNotTimeout) { + LOG.debug(`waitById: Timeout received for ${id}`); + } + + return await this.findById(id, includeMembers); + + } + + /** + * Returns true if the list is in correct format. + * + * @param list + * @private + */ + public isRepositoryEntryList (list: any) : list is SimpleRepositoryEntry[] { + return SimpleRepositoryUtils.isRepositoryEntryList(list, this._isT); + } + + public explainRepositoryEntryList (list: any): string { + return SimpleRepositoryUtils.explainRepositoryEntryList(list, this._isT, this._explainT, this._tName); + } + + /** + * Returns all resources (eg. Matrix rooms) from the repository which are of this type. + * + * @returns Array of resources + */ + private async _getAll () : Promise[]> { + + const response: MatrixSyncResponseDTO = await this._client.sync( + { + filter: { + presence: { + limit: 0 + }, + account_data: { + limit: 0 + }, + room: { + account_data: { + limit: 0 + }, + ephemeral: { + limit: 0 + }, + timeline: { + limit: 0 + }, + state: { + limit: 1, + include_redundant_members: true, + types: [ this._stateType ], + not_types: [ this._deletedType ] + } + } + }, + full_state: true + } + ); + + LOG.debug(`getAll: response = `, JSON.stringify(response, null, 2)); + + const joinObject = response?.rooms?.join ?? {}; + const inviteObject = response?.rooms?.invite ?? {}; + + const joinedRooms : readonly MatrixRoomId[] = keys(joinObject); + LOG.debug(`joinedRooms = `, joinedRooms); + + const invitedRooms : readonly MatrixRoomId[] = keys(inviteObject); + LOG.debug(`invitedRooms = `, invitedRooms); + + const roomsNotYetJoined : readonly MatrixRoomId[] = filter( + invitedRooms, + (item : MatrixRoomId) : boolean => !joinedRooms.includes(item) + ); + + if (roomsNotYetJoined.length) { + + LOG.debug("Joining to rooms = ", roomsNotYetJoined); + + let joinedRooms : number = 0; + + await reduce( + roomsNotYetJoined, + async (p, roomId : MatrixRoomId) : Promise => { + + await p; + + try { + + LOG.debug("Joining to room = ", roomId); + await this._client.joinRoom(roomId); + + joinedRooms += 1; + + } catch (err : any) { + LOG.warn(`Warning! Could not join client "${this._client.getUserId()}" to room: ${roomId}`); + } + + }, + Promise.resolve() + ) + + if (joinedRooms >= 1) { + LOG.debug("Fetching results again after joining"); + return await this._getAll(); + } + + } + + return reduce( + joinedRooms, + (result : readonly SimpleRepositoryEntry[], roomId: MatrixRoomId) : readonly SimpleRepositoryEntry[] => { + const value : MatrixSyncResponseJoinedRoomDTO = joinObject[roomId]; + const events : readonly MatrixSyncResponseRoomEventDTO[] = filter( + value?.state?.events ?? [], + (item : MatrixSyncResponseRoomEventDTO) : boolean => { + return ( + (item?.type === this._stateType) + && (item?.state_key === this._stateKey) + && isNumber(item?.content?.version) + ); + } + ); + return concat( + result, + map( + events, + (item : MatrixSyncResponseRoomEventDTO) : SimpleRepositoryEntry => { + + // @ts-ignore + const data : T = item?.content?.data ?? {}; + + // @ts-ignore + const version : number = item?.content?.version; + + // @ts-ignore + const deleted : boolean = !!(item?.content?.deleted); + + return { + data: data, + id: roomId, + version: version, + deleted: deleted + }; + + } + ) + ); + }, + [] + ); + + } + +} diff --git a/matrix/MatrixRepositoryInitializer.ts b/matrix/MatrixRepositoryInitializer.ts new file mode 100644 index 0000000..24dbba8 --- /dev/null +++ b/matrix/MatrixRepositoryInitializer.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SimpleStoredRepositoryItem, StoredRepositoryItemExplainCallback, StoredRepositoryItemTestCallback } from "../simpleRepository/types/SimpleStoredRepositoryItem"; +import { SimpleRepository } from "../simpleRepository/types/SimpleRepository"; +import { SimpleRepositoryInitializer } from "../simpleRepository/types/SimpleRepositoryInitializer"; +import { SimpleRepositoryClient } from "../simpleRepository/types/SimpleRepositoryClient"; +import { MatrixCrudRepository } from "./MatrixCrudRepository"; +import { SimpleMatrixClient } from "./SimpleMatrixClient"; +import { explainNot, explainOk } from "../types/explain"; + +export class MatrixRepositoryInitializer implements SimpleRepositoryInitializer { + + private readonly _roomType : string; + private readonly _isT : StoredRepositoryItemTestCallback; + private readonly _explainT : StoredRepositoryItemExplainCallback; + private readonly _tName : string; + + public constructor ( + roomType : string, + isT : StoredRepositoryItemTestCallback, + tName : string | undefined = undefined, + explainT : StoredRepositoryItemExplainCallback | undefined = undefined + ) { + this._roomType = roomType; + this._isT = isT; + this._tName = tName ?? 'T'; + this._explainT = explainT ?? ( (value: any) : string => isT(value) ? explainOk() : explainNot(this._tName) ); + } + + public async initializeRepository ( client : SimpleRepositoryClient ) : Promise> { + if (!(client instanceof SimpleMatrixClient)) throw new TypeError(`MatrixRepositoryInitializer.initializeRepository: Shared client not defined or not SimpleMatrixClient`); + return new MatrixCrudRepository( + client, + this._roomType, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + this._isT, + this._tName, + this._explainT + ); + } + +} diff --git a/matrix/MatrixSharedClientService.ts b/matrix/MatrixSharedClientService.ts new file mode 100644 index 0000000..1845372 --- /dev/null +++ b/matrix/MatrixSharedClientService.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { SimpleMatrixClient } from "./SimpleMatrixClient"; +import { LogService } from "../LogService"; +import { Observer, ObserverCallback } from "../Observer"; +import { DEFAULT_IO_SERVER_HOSTNAME } from "./constants/matrix-backend"; +import { SimpleSharedClientService, SharedClientServiceDestructor} from "../simpleRepository/types/SimpleSharedClientService"; +import { SimpleSharedClientServiceEvent } from "../simpleRepository/types/SimpleSharedClientServiceEvent"; + +const LOG = LogService.createLogger('MatrixSharedClientService'); + +/** + * This service can be used to offer shared access to SimpleMatrixClient + * instance. We use it for our services using MatrixCrudRepository. + */ +export class MatrixSharedClientService implements SimpleSharedClientService { + + public Event = SimpleSharedClientServiceEvent; + + private _observer : Observer; + private _client : SimpleMatrixClient | undefined; + private _initInProgress : boolean; + private _loginInProgress : boolean; + private _defaultServer : string; + + public constructor () { + this._observer = new Observer("MatrixSharedClientService"); + this._client = undefined; + this._initInProgress = true; + this._loginInProgress = false; + this._defaultServer = DEFAULT_IO_SERVER_HOSTNAME; + } + + public destroy (): void { + this._observer.destroy(); + } + + public getClient () : SimpleMatrixClient | undefined { + return this._client; + } + + public setDefaultServer (value: string) { + this._defaultServer = value; + } + + public isInitializing () : boolean { + return this._initInProgress; + } + + /** + * + * @param name + * @param callback + */ + public on ( + name: SimpleSharedClientServiceEvent, + callback: ObserverCallback + ): SharedClientServiceDestructor { + return this._observer.listenEvent(name, callback); + } + + /** + * + * @param url + */ + public async login ( + url: string + ) : Promise { + if (this._loginInProgress) { + throw new TypeError('Another login already in progress'); + } + LOG.debug(`login: Parsing URL "${url}"`); + const u = new URL(url); + const proto = u?.protocol; + const hostname = u?.hostname ?? this._defaultServer; + const port = u?.port; + const username = decodeURIComponent(u?.username ?? ''); + const password = decodeURIComponent(u?.password ?? ''); + const hsUrl = `${proto}//${hostname}:${port}`; + LOG.debug(`Creating client to "${hsUrl}"`); + let client : SimpleMatrixClient = new SimpleMatrixClient(hsUrl); + this._loginInProgress = true; + LOG.debug(`Logging in to "https://${hostname}" as "${username}" with "${password}"`); + client = await client.login(username, password); + LOG.info(`Logged in to "${hostname}" as "${username}"`); + this._loginInProgress = false; + this._client = client; + if (this._observer.hasCallbacks(SimpleSharedClientServiceEvent.LOGGED_IN)) { + this._observer.triggerEvent(SimpleSharedClientServiceEvent.LOGGED_IN); + } + } + + /** + * + * @param url + */ + public async initialize ( + url : string + ) : Promise { + LOG.debug(`Initialization started: `, url); + this._initInProgress = true; + await this.login(url); + LOG.debug(`Initialization finished: `, url); + this._initInProgress = false; + if(this._observer.hasCallbacks(SimpleSharedClientServiceEvent.INITIALIZED)) { + this._observer.triggerEvent(SimpleSharedClientServiceEvent.INITIALIZED); + } + } + + /** + * + */ + public async waitForInitialization () : Promise { + if (this._initInProgress) { + let listener : any; + try { + await new Promise((resolve, reject) => { + try { + listener = this._observer.listenEvent( + SimpleSharedClientServiceEvent.INITIALIZED, + () => { + try { + if (listener) { + listener(); + listener = undefined; + } + + resolve(); + } catch(err) { + reject(err); + } + } + ); + } catch(err) { + reject(err); + } + }); + } finally { + if (listener) { + listener(); + listener = undefined; + } + } + } + } + +} diff --git a/matrix/MatrixUtils.ts b/matrix/MatrixUtils.ts new file mode 100644 index 0000000..9e87994 --- /dev/null +++ b/matrix/MatrixUtils.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { MatrixRoomAlias } from "./types/core/MatrixRoomAlias"; +import { MatrixUserId } from "./types/core/MatrixUserId"; +import { MatrixRoomId } from "./types/core/MatrixRoomId"; +import { MatrixCreateRoomPreset } from "./types/request/createRoom/types/MatrixCreateRoomPreset"; +import { MatrixVisibility } from "./types/request/createRoom/types/MatrixVisibility"; +import { MatrixStateEvent } from "./types/core/MatrixStateEvent"; +import { LogService } from "../LogService"; +import { createRoomJoinRulesStateEventDTO, RoomJoinRulesStateEventDTO } from "./types/event/roomJoinRules/RoomJoinRulesStateEventDTO"; +import { createRoomJoinRulesStateContentDTO } from "./types/event/roomJoinRules/RoomJoinRulesStateContentDTO"; +import { MatrixJoinRule } from "./types/event/roomJoinRules/MatrixJoinRule"; +import { createRoomJoinRulesAllowConditionDTO } from "./types/event/roomJoinRules/RoomJoinRulesAllowConditionDTO"; +import { RoomMembershipType } from "./types/event/roomJoinRules/RoomMembershipType"; +import { createRoomHistoryVisibilityStateEventDTO, RoomHistoryVisibilityStateEventDTO } from "./types/event/roomHistoryVisibility/RoomHistoryVisibilityStateEventDTO"; +import { createRoomHistoryVisibilityStateContentDTO } from "./types/event/roomHistoryVisibility/RoomHistoryVisibilityStateContentDTO"; +import { MatrixHistoryVisibility } from "./types/event/roomHistoryVisibility/MatrixHistoryVisibility"; +import { createRoomGuestAccessStateEventDTO, RoomGuestAccessStateEventDTO } from "./types/event/roomGuestAccess/RoomGuestAccessStateEventDTO"; +import { createRoomGuestAccessContentDTO } from "./types/event/roomGuestAccess/RoomGuestAccessContentDTO"; +import { MatrixGuestAccess } from "./types/event/roomGuestAccess/MatrixGuestAccess"; +import { createRoomMemberStateEventDTO, RoomMemberStateEventDTO } from "./types/event/roomMember/RoomMemberStateEventDTO"; +import { createRoomMemberContentDTO } from "./types/event/roomMember/RoomMemberContentDTO"; +import { RoomMembershipState } from "./types/event/roomMember/RoomMembershipState"; +import { RoomMemberContent3rdPartyInviteDTO } from "./types/event/roomMember/RoomMemberContent3rdPartyInviteDTO"; + +const LOG = LogService.createLogger('MatrixUtils'); + +export class MatrixUtils { + + public static getUserId ( + username: string, + hostname: string + ) : MatrixUserId { + return `@${username}:${hostname}`; + } + + public static getRoomAlias ( + alias: string, + hostname: string + ) : MatrixRoomAlias { + return `#${alias}:${hostname}`; + } + + public static getRoomId ( + id: string, + hostname: string + ) : MatrixRoomId { + return `!${id}:${hostname}`; + } + + /** + * Get default preset from visibility setting + * + * @param visibility + * @see https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom + */ + public static getRoomPresetFromVisibility ( + visibility : MatrixVisibility + ) : MatrixCreateRoomPreset { + switch (visibility) { + case MatrixVisibility.PUBLIC : return MatrixCreateRoomPreset.PUBLIC_CHAT; + default: + case MatrixVisibility.PRIVATE : return MatrixCreateRoomPreset.PRIVATE_CHAT; + } + } + + public static createRoomJoinRulesEventDTO ( + roomId : string, + joinRule: MatrixJoinRule + ) : RoomJoinRulesStateEventDTO { + return createRoomJoinRulesStateEventDTO( + createRoomJoinRulesStateContentDTO( + joinRule, + [ + createRoomJoinRulesAllowConditionDTO( + RoomMembershipType.M_ROOM_MEMBERSHIP, + roomId + ) + ] + ) + ); + } + + public static createRoomHistoryVisibilityEventDTO ( + visibility: MatrixHistoryVisibility + ) : RoomHistoryVisibilityStateEventDTO { + return createRoomHistoryVisibilityStateEventDTO( + createRoomHistoryVisibilityStateContentDTO( + visibility + ) + ); + } + + public static createRoomGuestAccessEventDTO ( + access: MatrixGuestAccess + ) : RoomGuestAccessStateEventDTO { + return createRoomGuestAccessStateEventDTO( + createRoomGuestAccessContentDTO(access) + ); + } + + public static createRoomMemberEventDTO ( + userId : string, + membership : RoomMembershipState, + reason ?: string | undefined, + avatar_url ?: string | undefined, + displayname ?: string | null | undefined, + is_direct ?: boolean | undefined, + join_authorised_via_users_server ?: string | undefined, + third_party_invite ?: RoomMemberContent3rdPartyInviteDTO + ) : RoomMemberStateEventDTO { + return createRoomMemberStateEventDTO( + userId, + createRoomMemberContentDTO( + membership, + reason, + avatar_url, + displayname, + is_direct, + join_authorised_via_users_server, + third_party_invite + ) + ); + } + + /** + * Get initial room state events for specific preset + * + * @param roomId + * @param preset + * @see https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom + */ + public static getRoomStateEventsFromPreset ( + roomId : string, + preset : MatrixCreateRoomPreset + ) : readonly MatrixStateEvent[] { + switch (preset) { + + case MatrixCreateRoomPreset.PRIVATE_CHAT: + return [ + this.createRoomJoinRulesEventDTO(roomId, MatrixJoinRule.INVITE), + this.createRoomHistoryVisibilityEventDTO(MatrixHistoryVisibility.SHARED), + this.createRoomGuestAccessEventDTO(MatrixGuestAccess.CAN_JOIN) + ]; + + case MatrixCreateRoomPreset.PUBLIC_CHAT: + return [ + this.createRoomJoinRulesEventDTO(roomId, MatrixJoinRule.PUBLIC), + this.createRoomHistoryVisibilityEventDTO(MatrixHistoryVisibility.SHARED), + this.createRoomGuestAccessEventDTO(MatrixGuestAccess.CAN_JOIN) + ]; + + case MatrixCreateRoomPreset.TRUSTED_PRIVATE_CHAT: + return [ + this.createRoomJoinRulesEventDTO(roomId, MatrixJoinRule.INVITE), + this.createRoomHistoryVisibilityEventDTO(MatrixHistoryVisibility.SHARED), + this.createRoomGuestAccessEventDTO(MatrixGuestAccess.FORBIDDEN) + ]; + + } + LOG.warn(`MatrixUtils: Warning! Unimplemented preset: ${preset}`); + return []; + } + +} diff --git a/matrix/README.md b/matrix/README.md new file mode 100644 index 0000000..01bf371 --- /dev/null +++ b/matrix/README.md @@ -0,0 +1,54 @@ +**Join our [Discord](https://discord.gg/UBTrHxA78f) to discuss about our software!** + +# @heusalagroup/fi.hg.matrix + +Our lightweight Matrix.org library written in TypeScript. + +### Why? + +The official SDK was too complex and bloat for OpenWRT devices and did not easily rollup as a full +minified single file. + +Our compiled version takes space about 50kB. That's *including* all dependencies except standard +library. It runs on the browser as well as on the NodeJS LTS v8 and up. + +### It doesn't have many runtime dependencies + +This library expects [@heusalagroup/fi.hg.core](https://github.com/heusalagroup/fi.hg.core) to be located +in the relative path `../ts` and only required dependency it has is for [Lodash +library](https://lodash.com/). + +### We don't have traditional releases + +This project evolves directly to our git repository in an agile manner. + +This git repository contains only the source code for compile time use case. It is meant to be used +as a git submodule in a NodeJS or webpack project. + +Recommended way to initialize your project is like this: + +``` +mkdir -p src/fi/hg + +git submodule add git@github.com:heusalagroup/fi.hg.core.git src/fi/hg/core +git config -f .gitmodules submodule.src/fi/hg/core.branch main + +git submodule add git@github.com:heusalagroup/fi.hg.matrix.git src/fi/hg/matrix +git config -f .gitmodules submodule.src/fi/hg/matrix.branch main +``` + +Only required dependency is to [the Lodash library](https://lodash.com/): + +``` +npm install --save-dev lodash @types/lodash +``` + +Some of our code may use reflect metadata. It's optional otherwise. + +``` +npm install --save-dev reflect-metadata +``` + +### License + +Copyright (c) Heusala Group. All rights reserved. Licensed under the MIT License (the "[License](./LICENSE)"); diff --git a/matrix/SimpleMatrixClient.ts b/matrix/SimpleMatrixClient.ts new file mode 100644 index 0000000..af92d3a --- /dev/null +++ b/matrix/SimpleMatrixClient.ts @@ -0,0 +1,1749 @@ +// Copyright (c) 2021-2023 Sendanor. All rights reserved. + +import { concat } from "../functions/concat"; +import { forEach } from "../functions/forEach"; +import { Observer, ObserverCallback, ObserverDestructor } from "../Observer"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { LogService } from "../LogService"; +import { JsonAny } from "../Json"; +import { JsonAny as Json, isJsonObject, JsonObject } from "../Json"; +import { createMatrixPasswordLoginRequestDTO, MatrixPasswordLoginRequestDTO } from "./types/request/passwordLogin/MatrixPasswordLoginRequestDTO"; +import { createMatrixTextMessageDTO, MatrixTextMessageDTO } from "./types/message/textMessage/MatrixTextMessageDTO"; +import { MatrixType } from "./types/core/MatrixType"; +import { isMatrixLoginResponseDTO } from "./types/response/login/MatrixLoginResponseDTO"; +import { MatrixCreateRoomDTO } from "./types/request/createRoom/MatrixCreateRoomDTO"; +import { MatrixCreateRoomResponseDTO, isMatrixCreateRoomResponseDTO } from "./types/response/createRoom/MatrixCreateRoomResponseDTO"; +import { isGetDirectoryRoomAliasResponseDTO } from "./types/response/directoryRoomAlias/GetDirectoryRoomAliasResponseDTO"; +import { RequestError } from "../request/types/RequestError"; +import { RequestStatus } from "../request/types/RequestStatus"; +import { MatrixSyncPresence } from "./types/request/sync/types/MatrixSyncPresence"; +import { MatrixSyncResponseDTO, + explainMatrixSyncResponseDTO, + isMatrixSyncResponseDTO +} from "./types/response/sync/MatrixSyncResponseDTO"; +import { MatrixSyncResponseEventDTO } from "./types/response/sync/types/MatrixSyncResponseEventDTO"; +import { MatrixSyncResponseAnyEventDTO } from "./types/response/sync/types/MatrixSyncResponseAnyEventDTO"; +import { getEventsFromMatrixSyncResponsePresenceDTO } from "./types/response/sync/types/MatrixSyncResponsePresenceDTO"; +import { getEventsFromMatrixSyncResponseAccountDataDTO } from "./types/response/sync/types/MatrixSyncResponseAccountDataDTO"; +import { getEventsFromMatrixSyncResponseToDeviceDTO } from "./types/response/sync/types/MatrixSyncResponseToDeviceDTO"; +import { MatrixRoomId, isMatrixRoomId } from "./types/core/MatrixRoomId"; +import { MatrixSyncResponseJoinedRoomDTO, getEventsFromMatrixSyncResponseJoinedRoomDTO } from "./types/response/sync/types/MatrixSyncResponseJoinedRoomDTO"; +import { MatrixSyncResponseInvitedRoomDTO, getEventsFromMatrixSyncResponseInvitedRoomDTO } from "./types/response/sync/types/MatrixSyncResponseInvitedRoomDTO"; +import { MatrixSyncResponseLeftRoomDTO, getEventsFromMatrixSyncResponseLeftRoomDTO } from "./types/response/sync/types/MatrixSyncResponseLeftRoomDTO"; +import { MatrixUserId, isMatrixUserId } from "./types/core/MatrixUserId"; +import { MatrixJoinRoomRequestDTO } from "./types/request/joinRoom/MatrixJoinRoomRequestDTO"; +import { MatrixJoinRoomResponseDTO, isMatrixJoinRoomResponseDTO } from "./types/response/joinRoom/MatrixJoinRoomResponseDTO"; +import { + SimpleMatrixClientState, + stringifySimpleMatrixClientState +} from "./types/SimpleMatrixClientState"; +import { PutRoomStateWithEventTypeResponseDTO, isPutRoomStateWithEventTypeResponseDTO } from "./types/response/setRoomStateByType/PutRoomStateWithEventTypeResponseDTO"; +import { MatrixRoomJoinedMembersDTO, isMatrixRoomJoinedMembersDTO } from "./types/response/roomJoinedMembers/MatrixRoomJoinedMembersDTO"; +import { MatrixRegisterKind } from "./types/request/register/types/MatrixRegisterKind"; +import { MatrixRegisterRequestDTO } from "./types/request/register/MatrixRegisterRequestDTO"; +import { MatrixRegisterResponseDTO, isMatrixRegisterResponseDTO } from "./types/response/register/MatrixRegisterResponseDTO"; +import { isMatrixErrorDTO } from "./types/response/error/MatrixErrorDTO"; +import { MatrixErrorCode } from "./types/response/error/types/MatrixErrorCode"; +import { SynapseRegisterResponseDTO, isSynapseRegisterResponseDTO } from "./types/synapse/SynapseRegisterResponseDTO"; +import { SynapseRegisterRequestDTO } from "./types/synapse/SynapseRegisterRequestDTO"; +import { VoidCallback } from "../interfaces/callbacks"; +import { LogLevel } from "../types/LogLevel"; +import { + MATRIX_AUTHORIZATION_HEADER_NAME, + MATRIX_CREATE_ROOM_URL, + MATRIX_JOIN_ROOM_URL, + MATRIX_JOINED_MEMBERS_URL, + MATRIX_LOGIN_URL, + MATRIX_REGISTER_URL, + MATRIX_ROOM_DIRECTORY_URL, + MATRIX_ROOM_EVENT_STATE_FETCH_URL, + MATRIX_ROOM_EVENT_STATE_UPDATE_URL, + MATRIX_ROOM_FORGET_URL, + MATRIX_ROOM_INVITE_URL, + MATRIX_ROOM_LEAVE_URL, + MATRIX_ROOM_SEND_EVENT_URL, + MATRIX_SYNC_URL, + MATRIX_WHOAMI_URL, + MatrixSyncQueryParams, + SYNAPSE_REGISTER_URL +} from "./constants/matrix-routes"; +import { AuthorizationUtils } from "../AuthorizationUtils"; +import { isMatrixWhoAmIResponseDTO, MatrixWhoAmIResponseDTO } from "./types/response/whoami/MatrixWhoAmIResponseDTO"; +import { createMatrixIdentifierDTO } from "./types/request/login/types/MatrixIdentifierDTO"; +import { GetRoomStateByTypeResponseDTO, isGetRoomStateByTypeResponseDTO } from "./types/response/getRoomStateByType/GetRoomStateByTypeResponseDTO"; +import { SetRoomStateByTypeRequestDTO } from "./types/request/setRoomStateByType/SetRoomStateByTypeRequestDTO"; +import { SimpleRepositoryClient } from "../simpleRepository/types/SimpleRepositoryClient"; +import { isString } from "../types/String"; +import { keys } from "../functions/keys"; + +const LOG = LogService.createLogger('SimpleMatrixClient'); + +export enum SimpleMatrixClientEvent { + EVENT = "SimpleMatrixClient:event" +} + +export type SimpleMatrixClientDestructor = ObserverDestructor; + +const DEFAULT_WAIT_FOR_EVENTS_TIMEOUT = 30000; + +/** + * Super lightweight Matrix client and simple event listener. + * + * Far from perfect, but works both on browser and on OpenWRT with NodeJS 8 and full POC takes only + * 50k as compiled single bundle file (including all the dependencies) :) + */ +export class SimpleMatrixClient implements SimpleRepositoryClient { + + public static Event = SimpleMatrixClientEvent; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _observer : Observer; + private readonly _originalUrl : string; + private readonly _homeServerUrl : string; + private readonly _identityServerUrl : string; + private readonly _accessToken : string | undefined; + + private readonly _syncAgainTimeMs : number; + private readonly _syncRequestTimeoutMs : number; + private readonly _syncAgainTimeoutCallback : VoidCallback; + + private _state : SimpleMatrixClientState; + private _userId : string | undefined; + private _stopSyncOnNext : boolean; + private _nextSyncBatch : string | undefined; + private _syncAgainTimer : any | undefined; + private _initSyncAgainTimer : any | undefined; + + /** + * Create an instance of SimpleMatrixClient. + * + * @param url The URL of the Matrix server to login + * + * @param homeServerUrl Optional. The Matrix server URL for a logged in session. + * + * @param identityServerUrl Optional. The Matrix identity server URL for a logged in session. + * + * @param accessToken Optional. The access key for a logged in session. + * + * @param userId Optional. The Matrix user ID for a logged in session. + * + * @param pollTimeout Optional. The default poll time in milliseconds to poll changes + * from upstream Matrix server. + * + * @param pollWaitTime Optional. The default wait time between polls, in milliseconds. + * + */ + public constructor ( + url : string, + homeServerUrl : string | undefined = undefined, + identityServerUrl : string | undefined = undefined, + accessToken : string | undefined = undefined, + userId : MatrixUserId | undefined = undefined, + pollTimeout : number = 30000, + pollWaitTime : number = 1000 + ) { + + this._stopSyncOnNext = false; + this._state = accessToken ? SimpleMatrixClientState.AUTHENTICATED : SimpleMatrixClientState.UNAUTHENTICATED; + this._originalUrl = url; + this._homeServerUrl = SimpleMatrixClient._normalizeUrl( homeServerUrl ?? url ); + this._identityServerUrl = identityServerUrl ?? url; + this._nextSyncBatch = undefined; + this._accessToken = accessToken; + this._userId = userId; + this._syncRequestTimeoutMs = pollTimeout; + this._syncAgainTimeMs = pollWaitTime; + this._observer = new Observer("SimpleMatrixClient"); + this._syncAgainTimeoutCallback = this._onSyncAgainTimeout.bind(this); + + } + + /** + * Returns the current state of the client instance. + */ + public getState () : SimpleMatrixClientState { + return this._state; + } + + /** + * Returns the current access token + */ + public getAccessToken () : string | undefined { + return this._accessToken; + } + + /** + * Returns the current logged in Matrix user ID + */ + public getUserId () : MatrixUserId | undefined { + return this._userId; + } + + /** + * Returns the URL of the current Matrix home server + */ + public getHomeServerUrl () : string { + return this._homeServerUrl; + } + + /** + * Returns the hostname of the current Matrix home server + */ + public getHomeServerName () : string { + const u = new URL(this._homeServerUrl); + return u.hostname; + } + + public isAlreadyInTheRoom (body: any) : boolean { + if (isMatrixErrorDTO(body)) { + const errCode : string = body?.errcode ?? ''; + const errString : string = body?.error ?? ''; + if ( errCode === MatrixErrorCode.M_FORBIDDEN + && errString.indexOf('already in the room') >= 0 + ) { + return true; + } + } + return false; + } + + public isUnauthenticated () : boolean { + return this._state === SimpleMatrixClientState.UNAUTHENTICATED; + } + + public isAuthenticating () : boolean { + return this._state === SimpleMatrixClientState.AUTHENTICATING; + } + + public isAuthenticated () : boolean { + return this._state === SimpleMatrixClientState.AUTHENTICATED; + } + + public isStarting () : boolean { + return this._state === SimpleMatrixClientState.AUTHENTICATED_AND_STARTING; + } + + public isRestarting () : boolean { + return this._state === SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING; + } + + public isStarted () : boolean { + return this._state === SimpleMatrixClientState.AUTHENTICATED_AND_STARTED; + } + + public isStopping () : boolean { + return this._stopSyncOnNext; + } + + public isSyncing () : boolean { + return this._state === SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING; + } + + /** + * Destroys the current client instance, including all observers. + * + * You should not use this instance anymore after you call this method. + */ + public destroy (): void { + + switch (this._state) { + + case SimpleMatrixClientState.UNAUTHENTICATED: + case SimpleMatrixClientState.AUTHENTICATING: + case SimpleMatrixClientState.AUTHENTICATED: + break; + + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTED: + case SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING: + this._stopSyncing(); + break; + + } + + this._clearSyncAgainTimer(); + this._clearInitSyncAgainTimer(); + this._observer.destroy(); + + } + + /** + * Start listening some events. + * + * @param name + * @param callback + */ + public on ( + name: SimpleMatrixClientEvent, + callback: ObserverCallback + ): SimpleMatrixClientDestructor { + return this._observer.listenEvent(name, callback); + } + + /** + * Start the long polling event listener from Matrix server. + * + * @FIXME: This could be started automatically from listeners in our own observer. If so, this + * method could be changed to private later. + */ + public start (triggerEvents : boolean = true) { + this._startSyncing(triggerEvents); + } + + /** + * Stop the long polling event listener from Matrix server. + * + * It will not remove any listeners. + * + * @FIXME: This could be stopped automatically when listeners are removed from our own + * observer. If so, this method could be changed to private later. + */ + public stop () { + this._stopSyncing(); + } + + /** + * + * @param requestBody + * @param kind + * @param accessToken + */ + public async register ( + requestBody : MatrixRegisterRequestDTO, + kind : MatrixRegisterKind | undefined = undefined, + accessToken ?: string + ) : Promise { + try { + LOG.debug(`register: Registering user:`, requestBody, kind); + const access_token : string | undefined = this?._accessToken ?? accessToken ?? undefined; + const response : any = await this._postJson( + this._homeServerUrl + MATRIX_REGISTER_URL(kind), + requestBody as unknown as JsonAny, + access_token ? { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(access_token) + } : undefined + ); + if (!isMatrixRegisterResponseDTO(response)) { + LOG.debug(`Invalid response received: `, response); + throw new TypeError(`${this._observer.getName()}.register: Response was invalid`); + } + LOG.debug(`register: RegisterResponseDTO received: `, response); + return response; + } catch (err : any) { + LOG.warn(`register: Could not register user: `, err); + if (err instanceof RequestError) { + const statusCode = err?.getStatusCode(); + if ( statusCode === 400 ) { + const errorBody: any = SimpleMatrixClient._getErrorBody(err); + if ( isMatrixErrorDTO(errorBody) ) { + switch (errorBody.errcode) { + case MatrixErrorCode.M_USER_IN_USE: + throw new RequestError(RequestStatus.Conflict, `User already exists`); + case MatrixErrorCode.M_INVALID_USERNAME: + throw new RequestError(RequestStatus.BadRequest, `Username invalid`); + case MatrixErrorCode.M_EXCLUSIVE: + throw new RequestError(RequestStatus.Conflict, `User name conflicts with exclusive namespace`); + default: + throw new RequestError(RequestStatus.InternalServerError, `Failed to register user`); + } + } else { + throw new RequestError(RequestStatus.InternalServerError, `Failed to register user`); + } + } else if ( statusCode === 401 ) { + throw new RequestError(RequestStatus.Unauthorized); + } else if ( statusCode === 403 ) { + throw new RequestError(RequestStatus.Forbidden); + + } else if ( statusCode === 429 ) { + // Rate limited + // FIXME: implement special exception that contains the retry_after_ms property and/or handle it here + throw new RequestError(429); + + } else { + throw new RequestError(RequestStatus.InternalServerError, `Failed to register user`); + } + } else { + throw new RequestError(RequestStatus.InternalServerError, `Failed to register user`); + } + } + } + + public async whoamiDTO () : Promise { + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.whoamiDTO: Client did not have access token`); + } + LOG.debug(`whoamiDTO: Fetching account whoamiDTO... `); + const response : any = await this._getJson( + this._homeServerUrl + MATRIX_WHOAMI_URL, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + LOG.debug(`whoamiDTO: response = `, response); + if (!isMatrixWhoAmIResponseDTO(response)) { + throw new TypeError(`${this._observer.getName()}.whoamiDTO: Response was not MatrixWhoAmIResponseDTO: ${JSON.stringify(response)}`); + } + LOG.debug(`whoamiDTO: response.user_id = `, response.user_id); + this._userId = response.user_id; + return response; + } + + public async whoami () : Promise { + LOG.debug(`whoami: Updating whoami state... `); + await this.whoamiDTO(); + LOG.debug(`whoami: userId: ${this._userId}`); + return this._userId; + } + + public async getRegisterNonce () : Promise { + try { + LOG.debug(`Fetching nonce for registration...`); + const nonceResponse : any = await this._getJson(this._homeServerUrl + SYNAPSE_REGISTER_URL); + const nonce = nonceResponse?.nonce ?? undefined; + if (!nonce) throw new TypeError(`${this._observer.getName()}.getRegisterNonce: No nonce detected`); + return nonce; + } catch (err : any) { + LOG.debug(`Could not fetch nonce for registration: `, err); + throw new TypeError(`${this._observer.getName()}.getRegisterNonce: Could not fetch nonce for the register request. Is it Synapse?`); + } + } + + /** + * This call requires correctly configured Synapse and a shared secret code. + * + * See `SynapseUtils.createRegisterDTO(...)` and `.getRegisterNonce()` to create a DTO. + * Note, it requires NodeJS crypto module. + * + * @param requestBody + * @see https://matrix-org.github.io/synapse/latest/admin_api/register_api.html + */ + public async registerWithSharedSecret ( + requestBody : SynapseRegisterRequestDTO + ) : Promise { + try { + LOG.debug(`registerWithSharedSecret: Registering user:`, requestBody); + const response : any = await this._postJson( + this._homeServerUrl + SYNAPSE_REGISTER_URL, + requestBody as unknown as JsonAny + ); + if (!isSynapseRegisterResponseDTO(response)) { + LOG.debug(`registerWithSharedSecret: Invalid response received: `, response); + throw new TypeError(`${this._observer.getName()}.registerWithSharedSecret: Response was invalid`); + } + LOG.debug(`registerWithSharedSecret: RegisterResponseDTO received: `, response); + return response; + } catch (err : any) { + LOG.warn(`registerWithSharedSecret: Could not register user: `, err); + if (err instanceof RequestError) { + const statusCode = err?.getStatusCode(); + if ( statusCode === 400 ) { + const errorBody: any = SimpleMatrixClient._getErrorBody(err); + if ( isMatrixErrorDTO(errorBody) ) { + switch (errorBody.errcode) { + case MatrixErrorCode.M_USER_IN_USE: + throw new RequestError(RequestStatus.Conflict, `User already exists`); + case MatrixErrorCode.M_INVALID_USERNAME: + throw new RequestError(RequestStatus.BadRequest, `Username invalid`); + case MatrixErrorCode.M_EXCLUSIVE: + throw new RequestError(RequestStatus.Conflict, `User name conflicts with exclusive namespace`); + default: + throw new RequestError(RequestStatus.InternalServerError, `Failed to register user`); + } + } else { + throw new RequestError(RequestStatus.InternalServerError, `Failed to register user`); + } + } else if ( statusCode === 401 ) { + throw new RequestError(RequestStatus.Unauthorized); + + } else if ( statusCode === 403 ) { + throw new RequestError(RequestStatus.Forbidden); + + } else if ( statusCode === 429 ) { + // Rate limited + // FIXME: implement special exception that contains the retry_after_ms property and/or handle it here + throw new RequestError(429); + + } else { + throw new RequestError(RequestStatus.InternalServerError, `Failed to register user`); + } + } else { + throw new RequestError(RequestStatus.InternalServerError, `Failed to register user`); + } + } + } + + /** + * Log in to the matrix server + * + * @param userId The Matrix user ID to log into + * @param password The Matrix user password + * @returns New instance of SimpleMatrixClient which is initialized in to the authenticated + * state + */ + public async login ( + userId : MatrixUserId, + password : string + ) : Promise { + try { + const requestBody : MatrixPasswordLoginRequestDTO = createMatrixPasswordLoginRequestDTO( + createMatrixIdentifierDTO(userId), + password + ); + LOG.debug(`Sending login with userId:`, userId); + const response : any = await this._postJson( + this._homeServerUrl + MATRIX_LOGIN_URL, + requestBody as unknown as JsonAny + ); + if (!isMatrixLoginResponseDTO(response)) { + LOG.debug(`Invalid response received: `, response); + throw new TypeError(`${this._observer.getName()}.login: Response was invalid`); + } + LOG.debug(`Login response received: `, response); + let originalUrl = this._originalUrl; + let homeServerUrl = this._homeServerUrl; + let identityServerUrl = this._identityServerUrl; + if (response?.well_known) { + const responseHomeServerUrl = response.well_known[MatrixType.M_HOMESERVER]?.base_url; + if (responseHomeServerUrl) { + homeServerUrl = responseHomeServerUrl; + } else { + homeServerUrl = originalUrl; + } + const responseIdentityServerUrl = response.well_known[MatrixType.M_IDENTITY_SERVER]?.base_url; + if (responseIdentityServerUrl) { + identityServerUrl = responseIdentityServerUrl; + } else { + identityServerUrl = homeServerUrl; + } + } else { + homeServerUrl = originalUrl; + identityServerUrl = originalUrl; + } + const access_token = response?.access_token; + if (!access_token) { + throw new TypeError(`${this._observer.getName()}.login: Response did not have access_token`); + } + const user_id = response?.user_id; + if (!user_id) { + throw new TypeError(`${this._observer.getName()}.login: Response did not have user_id`); + } + return new SimpleMatrixClient( + originalUrl, + homeServerUrl, + identityServerUrl, + access_token, + user_id, + this._syncRequestTimeoutMs, + this._syncAgainTimeMs + ); + } catch (err : any) { + LOG.debug(`Could not login: `, err); + throw new RequestError(RequestStatus.Forbidden, `Access denied`); + } + } + + /** + * Authenticate to the Matrix server using access key + * + * @param access_token The Matrix access key + * @returns New instance of SimpleMatrixClient which is initialized in to the authenticated + * state + */ + public async authenticate ( + access_token : string + ) : Promise { + + try { + + return new SimpleMatrixClient( + this._originalUrl, + undefined, + undefined, + access_token, + undefined, + this._syncRequestTimeoutMs, + this._syncAgainTimeMs + ); + + } catch (err : any) { + + LOG.debug(`Could not login: `, err); + + throw new RequestError(RequestStatus.Forbidden, `Access denied`); + + } + + } + + /** + * Resolve room name (eg. alias) into room ID. + * + * Eg. if you say `'foo'` here, it will be converted to `'#foo:homeServerHostname'`. + * + * @param name + */ + public async resolveRoomId (name: string) : Promise { + + try { + + const roomName : string = this._normalizeRoomName(name); + + const response : any = await this._getJson( + this._homeServerUrl + MATRIX_ROOM_DIRECTORY_URL(roomName) + ); + + if (!isGetDirectoryRoomAliasResponseDTO(response)) { + LOG.debug(`resolveRoomId: response was not GetDirectoryRoomAliasResponseDTO: `, response); + throw new TypeError(`${this._observer.getName()}.resolveRoomId: Response was not GetDirectoryRoomAliasResponseDTO: ${response}`); + } + + LOG.debug(`resolveRoomId: received: `, response); + + return response.room_id; + + } catch (err : any) { + if (err instanceof RequestError && err.getStatusCode() === RequestStatus.NotFound) { + return undefined; + } else { + LOG.warn(`resolveRoomId: Passing on error: `, err); + throw err; + } + } + + } + + /** + * Returns joined members from the homeserver + * + * @param roomId + */ + public async getJoinedMembers (roomId: MatrixRoomId) : Promise { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.getJoinedMembers: Client did not have access token`); + } + + const response : any = await this._getJson( + this._homeServerUrl + MATRIX_JOINED_MEMBERS_URL(roomId), + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + if (!isMatrixRoomJoinedMembersDTO(response)) { + LOG.debug(`getJoinedMembers: response was not MatrixRoomJoinedMembersDTO: `, response); + throw new TypeError(`${this._observer.getName()}.getJoinedMembers: Response was not MatrixRoomJoinedMembersDTO: ${response}`); + } + + LOG.debug(`getJoinedMembers: received: `, response); + + return response; + + } + + /** + * Returns a room state value of tuple `roomId,eventType,StateKey`. + * + * @param roomId + * @param eventType + * @param stateKey + */ + public async getRoomStateByType ( + roomId : string, + eventType : string, + stateKey : string + ) : Promise { + + try { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.getRoomStateByType: Client did not have access token`); + } + + LOG.debug(`getRoomStateByType: roomId="${roomId}", eventType="${eventType}", stateKey="${stateKey}" `); + + const response : any = await this._getJson( + this._homeServerUrl + MATRIX_ROOM_EVENT_STATE_FETCH_URL(roomId, eventType, stateKey), + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + if (!isGetRoomStateByTypeResponseDTO(response)) { + LOG.debug(`getRoomStateByType: response was not GetRoomStateByTypeResponseDTO: `, response); + throw new TypeError(`${this._observer.getName()}.getRoomStateByType: Response was not GetRoomStateByTypeResponseDTO: ${JSON.stringify(response)}`); + } + + LOG.debug(`getRoomStateByType: received: `, response); + + return response; + + } catch (err : any) { + if (err instanceof RequestError && err.getStatusCode() === RequestStatus.NotFound) { + return undefined; + } else { + LOG.warn(`getRoomStateByType: Passing on error: `, err); + throw err; + } + } + + } + + /** + * Sets room state value of tuple `roomId,eventType,StateKey` + * + * @param roomId + * @param eventType + * @param stateKey + * @param body + */ + public async setRoomStateByType ( + roomId : string, + eventType : string, + stateKey : string, + body : SetRoomStateByTypeRequestDTO, + ) : Promise { + try { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.setRoomStateByType: Client did not have access token`); + } + + const response : JsonAny | undefined = await this._putJson( + this._homeServerUrl + MATRIX_ROOM_EVENT_STATE_UPDATE_URL(roomId, eventType, stateKey), + body as unknown as JsonObject, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + if (!isPutRoomStateWithEventTypeResponseDTO(response)) { + LOG.debug(`setRoomStateByType: response was not PutRoomStateWithEventTypeDTO: `, response); + throw new TypeError(`${this._observer.getName()}.setRoomStateByType: Response was not PutRoomStateWithEventTypeDTO: ${JSON.stringify(response)}`); + } + + LOG.debug(`setRoomStateByType: received: `, response); + + // @ts-ignore + return response; + + } catch (err : any) { + LOG.error(`setRoomStateByType: Passing on error: `, err); + throw err; + } + + } + + /** + * Forgets a room. + * + * Once every member has forgot it, the room will be marked for deletion in homeserver. + * + * @param roomId + */ + public async forgetRoom ( + roomId : string + ) : Promise { + + try { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.forgetRoom: Client did not have access token`); + } + + const response : JsonAny | undefined = await this._postJson( + this._homeServerUrl + MATRIX_ROOM_FORGET_URL(roomId), + {}, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + LOG.debug(`forgetRoom: received: `, response); + + } catch (err : any) { + LOG.warn(`forgetRoom: Passing on error: `, err); + throw err; + } + + } + + /** + * Leave from a room. + * + * @param roomId + */ + public async leaveRoom ( + roomId : string + ) : Promise { + + try { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.leaveRoom: Client did not have access token`); + } + + const response : JsonAny | undefined = await this._postJson( + this._homeServerUrl + MATRIX_ROOM_LEAVE_URL(roomId), + {}, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + LOG.debug(`leaveRoom: received: `, response); + + } catch (err : any) { + LOG.warn(`leaveRoom: Passing on error: `, err); + throw err; + } + + } + + /** + * Invite user to a room. + * + * @param roomId + * @param userId + */ + public async inviteToRoom ( + roomId : MatrixRoomId, + userId : MatrixUserId + ) : Promise { + + try { + + if (!isMatrixRoomId(roomId)) { + throw new TypeError(`${this._observer.getName()}.inviteToRoom: roomId invalid: ${roomId}`); + } + + if (!isMatrixUserId(userId)) { + throw new TypeError(`${this._observer.getName()}.inviteToRoom: userId invalid: ${userId}`); + } + + LOG.info(`Inviting user ${userId} to ${roomId}`); + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.inviteToRoom: Client did not have access token`); + } + + const response : JsonAny | undefined = await this._postJson( + this._homeServerUrl + MATRIX_ROOM_INVITE_URL(roomId), + { + user_id: userId + }, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + LOG.debug(`inviteToRoom: received: `, response); + + } catch (err : any) { + + if ( this.isAlreadyInTheRoom(err?.body) ) return; + + LOG.warn(`inviteToRoom: Passing on error: `, err); + throw err; + } + + } + + /** + * Send text message to the room. + * + * @param roomId The room ID + * @param body The message string + */ + public async sendTextMessage (roomId: string, body: string) : Promise { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.sendTextMessage: Client did not have access token`); + } + + const requestBody : MatrixTextMessageDTO = createMatrixTextMessageDTO(body); + LOG.debug(`Sending message with body:`, requestBody); + + const response : Json | undefined = await this._postJson( + this._homeServerUrl + MATRIX_ROOM_SEND_EVENT_URL(roomId, MatrixType.M_ROOM_MESSAGE), + requestBody as unknown as JsonAny, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + LOG.debug(`sendTextMessage response received: `, response); + + } + + /** + * Create a room. + * + * @param body + */ + public async createRoom ( + body: MatrixCreateRoomDTO + ) : Promise { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.createRoom: Client did not have access token`); + } + + LOG.debug(`Creating room with body:`, body); + + const response : MatrixCreateRoomResponseDTO | any = await this._postJson( + this._homeServerUrl + MATRIX_CREATE_ROOM_URL, + body as unknown as JsonAny, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + if (!isMatrixCreateRoomResponseDTO(response)) { + LOG.debug(`response = `, response); + throw new TypeError(`${this._observer.getName()}.createRoom: Response was not MatrixCreateRoomResponseDTO: ` + response); + } + + LOG.debug(`Create room response received: `, response); + + return response; + + } + + /** + * Join to a room. + * + * @param roomId + * @param body + */ + public async joinRoom ( + roomId : MatrixRoomId, + body : MatrixJoinRoomRequestDTO | undefined = undefined + ) : Promise { + + try { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.joinRoom: Client did not have access token`); + } + + LOG.debug(`joinRoom: Joining to room "${roomId}" with body:`, body); + + const response : MatrixCreateRoomResponseDTO | any = await this._postJson( + this._homeServerUrl + MATRIX_JOIN_ROOM_URL( roomId ), + (body ?? {}) as unknown as JsonAny, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + if (!isMatrixJoinRoomResponseDTO(response)) { + LOG.debug(`response = `, response); + throw new TypeError(`${this._observer.getName()}.joinRoom: Could not join to "${roomId}": Response was not MatrixJoinRoomResponseDTO: ` + response); + } + + LOG.debug(`joinRoom: Joined to room "${roomId}": `, response); + + return response; + + } catch (err : any) { + + LOG.warn(`joinRoom: Error: `, err); + + if ( this.isAlreadyInTheRoom(err?.body) ) { + return {room_id: roomId}; + } + + const body: any = SimpleMatrixClient._getErrorBody(err); + if ( isMatrixErrorDTO(body) && body.errcode === MatrixErrorCode.M_FORBIDDEN ) { + LOG.warn(`joinRoom: Passing on error: Could not join to room "${roomId}": ${body?.errcode}: ${body?.error}`); + throw err; + } else { + LOG.warn(`joinRoom: Passing on error: Could not join to room "${roomId}": `, err); + throw err; + } + + } + + } + + /** + * Create the sync request. + * + * Note! This is the raw method for the Matrix HTTP request, and is not related to the internal + * sync states. + * + * @param options + */ + public async sync (options : { + filter ?: string | JsonObject, + since ?: string, + full_state ?: boolean, + set_presence ?: MatrixSyncPresence, + timeout ?: number + }) : Promise { + + LOG.debug(`sync with `, options); + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}.sync: Client ${this._userId} did not have access token`); + } + + const { + filter, + since, + full_state, + set_presence, + timeout + } = options; + + const queryParams : MatrixSyncQueryParams = {}; + + if (filter !== undefined) { + if ( isString(filter) ) { + queryParams.filter = filter; + } else if (isJsonObject(filter)) { + queryParams.filter = JSON.stringify(filter); + } else { + throw new TypeError(`${this._observer.getName()}.sync: Invalid value for filter option: ${filter}`); + } + } + + if (since !== undefined) { + queryParams.since = since; + } + + if (full_state !== undefined) { + queryParams.full_state = full_state ? 'true' : 'false'; + } + + if (set_presence !== undefined) { + queryParams.set_presence = set_presence; + } + + if (timeout !== undefined) { + queryParams.timeout = `${timeout}`; + } + + const response : any = await this._getJson( + `${this._homeServerUrl}${MATRIX_SYNC_URL(queryParams)}`, + { + [MATRIX_AUTHORIZATION_HEADER_NAME]: AuthorizationUtils.createBearerHeader(accessToken) + } + ); + + if (!isMatrixSyncResponseDTO(response)) { + LOG.debug(`sync: response not MatrixSyncResponseDTO: `, JSON.stringify(response, null ,2)); + throw new TypeError(`${this._observer.getName()}.sync: Response was not MatrixSyncResponseDTO: ${explainMatrixSyncResponseDTO(response)}`); + } + + return response; + + } + + /** + * + * @param events + * @param onlyInRooms + * @param timeout Optional. The default is 30 seconds. + * @param triggerEvents Optional. If true, events from the first sync call are triggered. + */ + public async waitForEvents ( + events : readonly string[], + onlyInRooms : readonly string[] | undefined = undefined, + timeout : number | undefined = undefined, + triggerEvents : boolean = true + ) : Promise { + + if (timeout === undefined) { + timeout = DEFAULT_WAIT_FOR_EVENTS_TIMEOUT; + } + + if (onlyInRooms === undefined) { + LOG.debug(`Waiting for events ${events.join(' | ')} in all rooms`); + } else { + LOG.debug(`Waiting for events ${events.join(' | ')} in rooms ${onlyInRooms.join(', ')}`); + } + + return await new Promise((resolve, reject) => { + try { + + let listener : any; + let timeoutListener : any; + + const onStop = () => { + + LOG.debug(`waitForEvents: On stop`); + + if ( listener ) { + listener(); + listener = undefined; + } + + if ( timeoutListener ) { + clearTimeout(timeoutListener); + timeoutListener = undefined; + } + + this._stopSyncing(); + + }; + + const onTimeout = () => { + LOG.debug(`waitForEvents: On timeout`); + timeoutListener = undefined; + onStop(); + resolve(false); + }; + + const onEvent = ( + // @ts-ignore + event : SimpleMatrixClientEvent, + data : MatrixSyncResponseAnyEventDTO & {room_id?: string} + ) => { + + const type = data?.type; + const roomId = data?.room_id; + + if ( onlyInRooms !== undefined && !(roomId && onlyInRooms.includes(roomId)) ) { + LOG.debug(`waitForEvents: Event was not in watched room list: `, type, roomId, data); + return; + } + + if ( type && events.includes(type) ) { + LOG.debug(`waitForEvents: Event found: `, type, roomId, data); + onStop(); + resolve(true); + } else { + LOG.debug(`waitForEvents: Ignored event: `, type, roomId, data); + } + + }; + + try { + timeoutListener = setTimeout(onTimeout, timeout); + listener = this.on(SimpleMatrixClientEvent.EVENT, onEvent); + LOG.debug(`waitForEvents: Started listening events`); + this._startSyncing(triggerEvents); + } catch (err : any) { + LOG.error(`waitForEvents: Error: `, err); + reject(err); + onStop(); + } + + } catch (err : any) { + LOG.error(`waitForEvents: Outer error: `, err); + reject(err); + } + }); + + } + + + private async _retryLater (callback : any, timeout : number) : Promise { + let timer : any; + return await new Promise((resolve, reject) => { + try { + LOG.debug(`_retryLater: Waiting for a moment (${timeout})`); + timer = setTimeout(() => { + + timer = undefined; + + try { + LOG.debug(`_retryLater: Restoring now`); + resolve(callback()); + } catch (err: any) { + reject(err); + } + }, timeout); + } catch (err: any) { + + if (timer) { + clearTimeout(timer); + timer = undefined; + } + + reject(err); + } + }); + } + + private async _postJson ( + url : string, + body ?: JsonAny, + headers ?: {[key: string]: string} + ) : Promise< Json| undefined > { + try { + LOG.debug(`_postJson: Executing POST request ${url} with `, body, headers); + const result = await RequestClientImpl.postJson(url, body, headers); + LOG.debug(`_postJson: Response received for POST request ${url} as `, result); + return result; + } catch (err : any) { + LOG.warn(`_postJson: Error: `, err); + const responseBody = SimpleMatrixClient._getErrorBody(err); + if ( isMatrixErrorDTO(responseBody) ) { + const errCode = responseBody?.errcode; + if ( errCode === MatrixErrorCode.M_LIMIT_EXCEEDED ) { + const retry_after_ms = responseBody?.retry_after_ms ?? 1000; + LOG.warn(`_postJson: Limit reached, retrying: `, retry_after_ms, url, body, headers); + return await this._retryLater(async () => { + LOG.error(`Calling again: `, url, body, headers); + return await this._postJson(url, body, headers); + }, retry_after_ms) + } else { + LOG.warn(`_postJson: Passing on error code ${errCode}: ${responseBody?.error}`); + } + } else { + LOG.warn(`_postJson: Passing on error with no body: `, err); + } + throw err; + } + } + + private async _putJson ( + url : string, + body ?: JsonAny, + headers ?: {[key: string]: string} + ) : Promise { + try { + LOG.debug(`_putJson: Executing PUT request ${url} with `, body, headers); + const result = await RequestClientImpl.putJson(url, body, headers); + LOG.debug(`_putJson: Response received for PUT request ${url} as `, result); + return result; + } catch (err : any) { + LOG.warn(`_putJson: Error: `, err); + const responseBody = SimpleMatrixClient._getErrorBody(err); + if ( isMatrixErrorDTO(responseBody) ) { + const errCode = responseBody?.errcode; + if ( responseBody?.errcode === MatrixErrorCode.M_LIMIT_EXCEEDED ) { + const retry_after_ms = responseBody?.retry_after_ms ?? 1000; + LOG.warn(`_putJson: Limit reached, retrying: `, retry_after_ms, url, body, headers); + return await this._retryLater(async () => { + LOG.error(`Calling again: `, url, body, headers); + return await this._putJson(url, body, headers); + }, retry_after_ms) + } else { + LOG.warn(`Passing on: Error with code ${errCode}: ${responseBody.error}`); + } + } else { + LOG.warn(`Passing on: Error did not have body: `, err); + } + throw err; + } + } + + private async _getJson ( + url : string, + headers ?: {[key: string]: string} + ) : Promise { + try { + LOG.debug(`_getJson: Executing GET request ${url} with `, headers); + const result = await RequestClientImpl.getJson(url, headers); + LOG.debug(`_getJson: Response received for PUT request ${url} as `, result); + return result; + } catch (err : any) { + LOG.warn(`_getJson: Error: `, err); + const responseBody = SimpleMatrixClient._getErrorBody(err); + if ( isMatrixErrorDTO(responseBody) ) { + const errCode = responseBody?.errcode; + if ( responseBody?.errcode === MatrixErrorCode.M_LIMIT_EXCEEDED ) { + const retry_after_ms = responseBody?.retry_after_ms ?? 1000; + LOG.error(`_getJson: Limit reached, retrying: `, retry_after_ms, url, headers); + return await this._retryLater(async () => { + LOG.error(`Calling again: `, url, headers); + return await this._getJson(url, headers); + }, retry_after_ms) + } else { + LOG.warn(`_getJson: Passing on: Error with code ${errCode}: ${responseBody?.error}`); + } + } else { + LOG.warn(`_getJson: Passing on: Error did not have body: `, err); + } + throw err; + } + } + + private static _getErrorBody (err : any) : any { + try { + return (err?.getBody ? err?.getBody() : undefined) ?? err?.body;; + } catch (err2) { + LOG.error(`_getErrorBody: Could not get body of error: `, err2); + LOG.error(`_getErrorBody: Original error: `, err); + return undefined; + } + } + + private _normalizeRoomName (name : string) { + + if ( !name || !isString(name) ) { + throw new TypeError(`${this._observer.getName()}._normalizeRoomName: name is invalid: ${name}`); + } + + if (name[0] !== '#') { + name = `#${name}`; + } + + if ( name.indexOf(':') < 0 ) { + name = `${name}:${this.getHomeServerName()}`; + } + + return name; + + } + + private static _normalizeUrl (url : string) { + if ( url && url[url.length-1] === '/' ) { + return url.substring(0, url.length-1); + } else { + return url; + } + } + + // ***************** Methods related to event listening and syncing below ***************** // + + private _setState (value : SimpleMatrixClientState) { + LOG.debug(`_setState: `, value, stringifySimpleMatrixClientState(value), this._stopSyncOnNext ); + this._state = value; + } + + /** + * Start the long polling event listener from Matrix server. + * + * The state SHOULD be AUTHENTICATED. + * + * Nothing is done if the state is AUTHENTICATED_AND_STARTING, AUTHENTICATED_AND_RESTARTING, + * AUTHENTICATED_AND_STARTED or AUTHENTICATED_AND_SYNCING -- except if stop request has been + * scheduled, which will be cancelled. + * + * The state must not be UNAUTHENTICATED or AUTHENTICATING. + * + * @FIXME: This could be started automatically from listeners in our own observer. If so, this + * method could be changed to private later. + */ + public _startSyncing (triggerEvents: boolean) { + + switch (this._state) { + + case SimpleMatrixClientState.AUTHENTICATED: + break; + + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTED: + case SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING: + if (this._stopSyncOnNext) { + this._stopSyncOnNext = false; + LOG.debug(`_startSyncing: Cancelled previous stop request (state was ${stringifySimpleMatrixClientState(this._state)})`); + } else { + LOG.warn(`_startSyncing: Warning! Client was already started (was ${stringifySimpleMatrixClientState(this._state)})`); + } + return; + + default: + case SimpleMatrixClientState.UNAUTHENTICATED: + case SimpleMatrixClientState.AUTHENTICATING: + throw new TypeError(`${this._observer.getName()}._startSyncing: Client was ${stringifySimpleMatrixClientState(this._state)}`); + + } + + if (this.isStopping()) { + LOG.warn(`_startSyncing: Warning! Cancelled previous stop request, although state was AUTHENTICATED.`); + this._stopSyncOnNext = false; + } + + this._clearSyncAgainTimer(); + + LOG.debug(`start: Initializing sync`); + this._initSync(triggerEvents).catch((err : any) => { + LOG.error('SYNC ERROR: ', err); + }); + + } + + /** + * Stops the internal long polling loop against the Matrix server. + * + * State should be AUTHENTICATED_AND_STARTED. + * + * Will schedule stop later if state is AUTHENTICATED_AND_STARTING, + * AUTHENTICATED_AND_RESTARTING or AUTHENTICATED_AND_SYNCING. + * + * Will not do anything (but warning) if state is UNAUTHENTICATED, AUTHENTICATING or + * AUTHENTICATED. + * + * @FIXME: This could be stopped automatically when listeners are removed from our own + * observer. If so, this method could be changed to private later. + */ + public _stopSyncing () { + + switch (this._state) { + + case SimpleMatrixClientState.UNAUTHENTICATED: + case SimpleMatrixClientState.AUTHENTICATING: + case SimpleMatrixClientState.AUTHENTICATED: + LOG.warn(`_stopSyncing: Warning! Client was not started (was ${stringifySimpleMatrixClientState(this._state)})`); + return; + + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING: + if (!this._stopSyncOnNext) { + LOG.debug(`_stopSyncing: Scheduled stop (state was ${stringifySimpleMatrixClientState(this._state)})`); + this._stopSyncOnNext = true; + } else { + LOG.warn(`_stopSyncing: Warning! Stop was already scheduled (state was ${stringifySimpleMatrixClientState(this._state)})`); + } + return; + + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTED: + LOG.debug(`_stopSyncing: Stopping timer and moving to AUTHENTICATED state (was AUTHENTICATED_AND_STARTED)`); + this._clearSyncAgainTimer(); + this._setState(SimpleMatrixClientState.AUTHENTICATED); + return; + + } + + } + + /** + * Will start a timeout until this._syncNextBatch() is called. + * + * The state must be AUTHENTICATED_AND_STARTED; + * + * @private + */ + private _startSyncAgainTimer () { + + if ( this._state !== SimpleMatrixClientState.AUTHENTICATED_AND_STARTED ) { + throw new TypeError(`${this._observer.getName()}._startSyncAgainTimer: Client was not AUTHENTICATED_AND_STARTED (was ${stringifySimpleMatrixClientState(this._state)})`); + } + + this._clearSyncAgainTimer(); + + this._syncAgainTimer = setTimeout(this._syncAgainTimeoutCallback, this._syncAgainTimeMs); + + } + + /** + * Will start a timeout until this._initSync() is called again after a failed request. + * + * The state must be AUTHENTICATED_AND_RESTARTING. + * + * @private + */ + private _startInitSyncAgainLater (triggerEvents: boolean) { + + if ( this._state !== SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING ) { + throw new TypeError(`${this._observer.getName()}._startInitSyncAgainLater: Client was not AUTHENTICATED_AND_RESTARTING (${stringifySimpleMatrixClientState(this._state)})`); + } + + this._clearInitSyncAgainTimer(); + + this._initSyncAgainTimer = setTimeout( + () => this._onInitSyncAgain(triggerEvents), + this._syncAgainTimeMs + ); + + } + + /** + * Called when normal syncing is active and timeout is received. + * + * @private + */ + private _onSyncAgainTimeout () { + + this._syncAgainTimer = undefined; + + if (this._stopSyncOnNext) { + this._stopSyncOnNext = false; + LOG.debug(`_onSyncRetryTimeout: Sync cancelled by previous stop request.`); + return; + } + + if ( this._state !== SimpleMatrixClientState.AUTHENTICATED_AND_STARTED ) { + LOG.error(`_onSyncRetryTimeout: Client was not AUTHENTICATED_AND_STARTED (was ${stringifySimpleMatrixClientState(this._state)})`); + } else { + this._syncNextBatch().catch(err => { + LOG.error(`_onSyncRetryTimeout: Error: `, err); + }); + } + + } + + /** + * Called when it's time to try again previous failed init sync + * + * @private + */ + private _onInitSyncAgain (triggerEvents: boolean) { + + this._initSyncAgainTimer = undefined; + + if (this._stopSyncOnNext) { + this._stopSyncOnNext = false; + LOG.debug(`_onInitSyncAgain: Sync cancelled by previous stop request.`); + return; + } + + if ( this._state !== SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING ) { + LOG.error(`_onInitSyncAgain: Client was not AUTHENTICATED_AND_RESTARTING (${stringifySimpleMatrixClientState(this._state)})`); + return; + } + + this._setState(SimpleMatrixClientState.AUTHENTICATED); + + this._initSync(triggerEvents).catch(err => { + LOG.error(`_onInitSyncAgain: Error: `, err); + }); + + } + + private _clearSyncAgainTimer () { + if (this._syncAgainTimer !== undefined) { + clearTimeout(this._syncAgainTimer); + this._syncAgainTimer = undefined; + } + } + + private _clearInitSyncAgainTimer () { + if (this._initSyncAgainTimer !== undefined) { + clearTimeout(this._initSyncAgainTimer); + this._initSyncAgainTimer = undefined; + } + } + + private _triggerMatrixEventList (events : readonly MatrixSyncResponseAnyEventDTO[], room_id : string | undefined) { + forEach(events, (event) => { + this._triggerMatrixEvent(event, room_id); + }); + } + + private _triggerMatrixEvent (event : MatrixSyncResponseAnyEventDTO, room_id : string | undefined) { + if (this._observer.hasCallbacks(SimpleMatrixClientEvent.EVENT)) { + this._observer.triggerEvent(SimpleMatrixClientEvent.EVENT, room_id ? {...event, room_id} : event); + } else { + LOG.warn(`Warning! Client received an event but nothing was listening it: `, event, room_id); + } + } + + /** + * The state MUST be AUTHENTICATED_AND_STARTED to call this method. + * + * While this method is executing the state will be AUTHENTICATED_AND_SYNCING. + * + * It will result in a state: + * + * 1) AUTHENTICATED_AND_STARTED if successful + * 2) AUTHENTICATED if previous stop request was received + * + * @private + */ + private async _syncNextBatch () { + + switch (this._state) { + + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTED: + break; + + default: + case SimpleMatrixClientState.UNAUTHENTICATED: + case SimpleMatrixClientState.AUTHENTICATING: + case SimpleMatrixClientState.AUTHENTICATED: + case SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING: + throw new TypeError(`${this._observer.getName()}._syncNextBatch: State was ${stringifySimpleMatrixClientState(this._state)}`); + + } + + const nextBatch = this._nextSyncBatch; + if (!nextBatch) throw new TypeError(`${this._observer.getName()}._syncNextBatch: No previous nextBatch defined`); + + const restartTimer = () => { + + this._clearSyncAgainTimer(); + + if (this._stopSyncOnNext) { + this._stopSyncOnNext = false; + this._setState(SimpleMatrixClientState.AUTHENTICATED); + } else { + this._setState(SimpleMatrixClientState.AUTHENTICATED_AND_STARTED); + this._startSyncAgainTimer(); + } + + }; + + try { + LOG.debug('_syncNextBatch: ', nextBatch); + this._setState(SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING); + await this._syncSinceBatch(nextBatch); + restartTimer(); + } catch (err : any) { + LOG.error(`_syncNextBatch: ERROR: `, err); + restartTimer(); + } + + } + + /** + * The state must be AUTHENTICATED to call this method. + * + * While this method is executing the state will be AUTHENTICATED_AND_STARTING. + * + * This method controls state change to: + * + * 1) AUTHENTICATED_AND_STARTED if successful + * 2) AUTHENTICATED if previous stop request was received while the request was executing + * 3) AUTHENTICATED_AND_RESTARTING if not successful (see _startInitSyncAgainLater) + * + * @private + */ + private async _initSync ( + triggerEvents : boolean + ) { + + if ( this._state !== SimpleMatrixClientState.AUTHENTICATED ) { + throw new TypeError(`${this._observer.getName()}._initSync: Client was not authenticated (${stringifySimpleMatrixClientState(this._state)})`); + } + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}._initSync: Client did not have access token`); + } + + LOG.info(`_initSync: Initial sync request started`); + + try { + + this._setState(SimpleMatrixClientState.AUTHENTICATED_AND_STARTING); + + const response : MatrixSyncResponseDTO = await this.sync({ + // FIXME: Create reusable filter on the server + filter: { + room:{ + timeline:{ + limit:1 + } + } + } + }); + + LOG.debug(`_initSync: Initial sync response received`); + + if (this._stopSyncOnNext) { + this._stopSyncOnNext = false; + this._setState(SimpleMatrixClientState.AUTHENTICATED); + LOG.debug('_initSync: Started successfully, but stop was already scheduled.'); + if (triggerEvents) this._triggerSyncEvents(response); + return; + } + + const next_batch : string | undefined = response.next_batch; + if ( !next_batch ) { + LOG.warn(`_initSync: Warning! No next_batch in the response: `, response); + this._setState(SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING); + this._startInitSyncAgainLater(triggerEvents); + // if (triggerEvents) this._triggerSyncEvents(response); + return; + } + + this._nextSyncBatch = next_batch; + this._setState(SimpleMatrixClientState.AUTHENTICATED_AND_STARTED); + LOG.debug('_initSync: Started successfully'); + if (triggerEvents) this._triggerSyncEvents(response); + + this._startSyncAgainTimer(); + + } catch (err : any) { + LOG.error(`_initSync: Error: `, err); + if (this._stopSyncOnNext) { + this._stopSyncOnNext = false; + this._setState(SimpleMatrixClientState.AUTHENTICATED); + } else { + this._setState(SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING); + this._startInitSyncAgainLater(triggerEvents); + } + } + + } + + private async _syncSinceBatch (next: string) { + + const accessToken : string | undefined = this._accessToken; + if (!accessToken) { + throw new TypeError(`${this._observer.getName()}._syncSinceBatch: Client did not have access token`); + } + + const response : MatrixSyncResponseDTO = await this.sync({ + since: next, + timeout: this._syncRequestTimeoutMs + }); + + const next_batch : string | undefined = response.next_batch; + if (next_batch) { + this._nextSyncBatch = next_batch; + LOG.debug(`next_batch = `, next_batch); + } else { + LOG.error(`No next_batch in the response: `, response) + } + + // LOG.debug('Response: ', response); + this._triggerSyncEvents(response); + + } + + private _triggerSyncEvents (response: MatrixSyncResponseDTO) { + + const nonRoomEvents : readonly MatrixSyncResponseEventDTO[] = concat( + response?.presence ? getEventsFromMatrixSyncResponsePresenceDTO(response?.presence) : [], + response?.account_data ? getEventsFromMatrixSyncResponseAccountDataDTO(response?.account_data) : [], + response?.to_device ? getEventsFromMatrixSyncResponseToDeviceDTO(response?.to_device) : [], + ); + + this._triggerMatrixEventList(nonRoomEvents, undefined); + + const joinObject = response?.rooms?.join ?? {}; + const joinRoomIds = keys(joinObject); + forEach(joinRoomIds, (roomId : MatrixRoomId) => { + const roomObject : MatrixSyncResponseJoinedRoomDTO = joinObject[roomId]; + const events = getEventsFromMatrixSyncResponseJoinedRoomDTO(roomObject); + this._triggerMatrixEventList(events, roomId); + }); + + const inviteObject = response?.rooms?.invite ?? {}; + const inviteRoomIds = keys(inviteObject); + forEach(inviteRoomIds, (roomId : MatrixRoomId) => { + const roomObject : MatrixSyncResponseInvitedRoomDTO = inviteObject[roomId]; + const events = getEventsFromMatrixSyncResponseInvitedRoomDTO(roomObject); + this._triggerMatrixEventList(events, roomId); + }); + + const leaveObject = response?.rooms?.leave ?? {}; + const leaveRoomIds = keys(leaveObject); + forEach(leaveRoomIds, (roomId : MatrixRoomId) => { + const roomObject : MatrixSyncResponseLeftRoomDTO = leaveObject[roomId]; + const events = getEventsFromMatrixSyncResponseLeftRoomDTO(roomObject); + this._triggerMatrixEventList(events, roomId); + }); + + + } + +} + diff --git a/matrix/constants/matrix-backend.ts b/matrix/constants/matrix-backend.ts new file mode 100644 index 0000000..e4e6064 --- /dev/null +++ b/matrix/constants/matrix-backend.ts @@ -0,0 +1 @@ +export const DEFAULT_IO_SERVER_HOSTNAME = 'io.nor.fi'; diff --git a/matrix/constants/matrix-routes.ts b/matrix/constants/matrix-routes.ts new file mode 100644 index 0000000..7e2320e --- /dev/null +++ b/matrix/constants/matrix-routes.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { MatrixRegisterKind } from "../types/request/register/types/MatrixRegisterKind"; +import { MatrixType } from "../types/core/MatrixType"; +import { join } from "../../functions/join"; +import { map } from "../../functions/map"; +import { keys } from "../../functions/keys"; + +export interface MatrixSyncQueryParams { + filter ?: string, + since ?: string, + full_state ?: string, + set_presence ?: string, + timeout ?: string +} + +export const MATRIX_AUTHORIZATION_HEADER_NAME = 'Authorization'; + +export const SYNAPSE_REGISTER_URL = '/_synapse/admin/v1/register'; + +export const MATRIX_WHOAMI_URL = '/_matrix/client/r0/account/whoami'; +export const MATRIX_LOGIN_URL = '/_matrix/client/r0/login'; +export const MATRIX_ROOM_DIRECTORY_URL = (roomName: string) => `/_matrix/client/r0/directory/room/${q(roomName)}`; +export const MATRIX_JOINED_MEMBERS_URL = (roomId : string) => `/_matrix/client/r0/rooms/${q(roomId)}/joined_members`; +export const MATRIX_REGISTER_URL = (kind: MatrixRegisterKind | undefined) => `/_matrix/client/r0/register${kind ? `?kind=${q(kind)}`: ''}`; +export const MATRIX_ROOM_EVENT_STATE_FETCH_URL = (roomId : string, eventType : string, stateKey : string) => `/_matrix/client/r0/rooms/${q(roomId)}/state/${q(eventType)}/${q(stateKey)}`; +export const MATRIX_ROOM_EVENT_STATE_UPDATE_URL = (roomId : string, eventType : string, stateKey : string) => `/_matrix/client/r0/rooms/${q(roomId)}/state/${q(eventType)}/${q(stateKey)}`; +export const MATRIX_ROOM_FORGET_URL = (roomId : string) => `/_matrix/client/r0/rooms/${q(roomId)}/forget`; +export const MATRIX_ROOM_LEAVE_URL = (roomId : string) => `/_matrix/client/r0/rooms/${q(roomId)}/leave`; +export const MATRIX_ROOM_INVITE_URL = (roomId : string) => `/_matrix/client/r0/rooms/${q(roomId)}/invite`; +export const MATRIX_ROOM_SEND_EVENT_URL = (roomId : string, eventName: MatrixType) => `/_matrix/client/r0/rooms/${q(roomId)}/send/${q(eventName)}`; +export const MATRIX_CREATE_ROOM_URL = `/_matrix/client/r0/createRoom`; +export const MATRIX_JOIN_ROOM_URL = (roomId : string) => `/_matrix/client/r0/rooms/${q(roomId)}/join`; +export const MATRIX_SYNC_URL = (queryParams: MatrixSyncQueryParams) => `/_matrix/client/r0/sync?${qParams(queryParams)}`; + +function qParams (queryParams: MatrixSyncQueryParams) : string { + return join( + map( + keys(queryParams), + (key : string) : string => { + // @ts-ignore + const value : string = queryParams[key]; + return `${q(key)}=${q(value)}`; + } + ), + '&' + ); +} + +function q (value: string) : string { + return encodeURIComponent(value); +} diff --git a/matrix/jest.config.js b/matrix/jest.config.js new file mode 100644 index 0000000..4ebbfc3 --- /dev/null +++ b/matrix/jest.config.js @@ -0,0 +1,10 @@ +// See also https://github.com/heusalagroup/test or project specific test folder +/** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + // testTimeout: 30000, + globals: { + window: {} + } +}; diff --git a/matrix/types/MatrixRoomVersion.ts b/matrix/types/MatrixRoomVersion.ts new file mode 100644 index 0000000..f2f41d9 --- /dev/null +++ b/matrix/types/MatrixRoomVersion.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +export enum MatrixRoomVersion { + V1 = "1", + V2 = "2", + V3 = "3", + V4 = "4", + V5 = "5", + V6 = "6", + V7 = "7", + V8 = "8", + V9 = "9" +} + +export function isMatrixRoomVersion (value: any): value is MatrixRoomVersion { + switch (value) { + case MatrixRoomVersion.V1: + case MatrixRoomVersion.V2: + case MatrixRoomVersion.V3: + case MatrixRoomVersion.V4: + case MatrixRoomVersion.V5: + case MatrixRoomVersion.V6: + case MatrixRoomVersion.V7: + case MatrixRoomVersion.V8: + case MatrixRoomVersion.V9: + return true; + + default: + return false; + } +} + +export function explainMatrixRoomVersion (value : any) : string { + return isMatrixRoomVersion(value) ? explainOk() : explainNot("MatrixRoomVersion"); +} + +export function isMatrixRoomVersionOrUndefined (value: any): value is MatrixRoomVersion | undefined { + return value === undefined || isMatrixRoomVersion(value); +} + +export function explainMatrixRoomVersionOrUndefined (value : any) : string { + return isMatrixRoomVersionOrUndefined(value) ? explainOk() : explainNot(explainOr(["MatrixRoomVersion", "undefined"])); +} + +export function stringifyMatrixRoomVersion (value: MatrixRoomVersion): string { + switch (value) { + case MatrixRoomVersion.V1 : return '1'; + case MatrixRoomVersion.V2 : return '2'; + case MatrixRoomVersion.V3 : return '3'; + case MatrixRoomVersion.V4 : return '4'; + case MatrixRoomVersion.V5 : return '5'; + case MatrixRoomVersion.V6 : return '6'; + case MatrixRoomVersion.V7 : return '7'; + case MatrixRoomVersion.V8 : return '8'; + case MatrixRoomVersion.V9 : return '9'; + } + throw new TypeError(`Unsupported MatrixRoomVersion value: ${value}`); +} + +export function parseMatrixRoomVersion (value: any): MatrixRoomVersion | undefined { + switch (`${value}`.toUpperCase()) { + + case "1": + case "V1" : return MatrixRoomVersion.V1; + + case "2": + case "V2" : return MatrixRoomVersion.V2; + + case "3": + case "V3" : return MatrixRoomVersion.V3; + + case "4": + case "V4" : return MatrixRoomVersion.V4; + + case "5": + case "V5" : return MatrixRoomVersion.V5; + + case "6": + case "V6" : return MatrixRoomVersion.V6; + + case "7": + case "V7" : return MatrixRoomVersion.V7; + + case "8": + case 'V8' : return MatrixRoomVersion.V8; + + case "9": + case 'V9' : return MatrixRoomVersion.V9; + + default : return undefined; + + } +} diff --git a/matrix/types/SimpleMatrixClientState.ts b/matrix/types/SimpleMatrixClientState.ts new file mode 100644 index 0000000..f70fc2a --- /dev/null +++ b/matrix/types/SimpleMatrixClientState.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum SimpleMatrixClientState { + + /** + * Client has been initialized but does not have authenticated session + */ + UNAUTHENTICATED, + + /** + * Client is in the middle of authenticating and has not started long polling + */ + AUTHENTICATING, + + /** + * Client has authenticated session but has not started sync (eg. long polling events) + */ + AUTHENTICATED, + + /** + * Client has authenticated session and is in the middle of initial sync request from the + * backend + */ + AUTHENTICATED_AND_STARTING, + + /** + * Client has authenticated session but the initial sync resulted in an error and client is + * in the middle of timeout until next try is done + */ + AUTHENTICATED_AND_RESTARTING, + + /** + * Client has authenticated session and has finished initial sync and is in the middle of + * the sync retry timeout (eg. client side sync delay timer is active only) + */ + AUTHENTICATED_AND_STARTED, + + /** + * Client has authenticated session and is in the middle of a long polling sync request from + * the backend + */ + AUTHENTICATED_AND_SYNCING + +} + +export function isSimpleMatrixClientState (value: any): value is SimpleMatrixClientState { + switch (value) { + case SimpleMatrixClientState.UNAUTHENTICATED: + case SimpleMatrixClientState.AUTHENTICATING: + case SimpleMatrixClientState.AUTHENTICATED: + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING: + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTED: + case SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING: + return true; + + default: + return false; + + } +} + +export function stringifySimpleMatrixClientState (value: SimpleMatrixClientState): string { + switch (value) { + case SimpleMatrixClientState.UNAUTHENTICATED : return 'UNAUTHENTICATED'; + case SimpleMatrixClientState.AUTHENTICATING : return 'AUTHENTICATING'; + case SimpleMatrixClientState.AUTHENTICATED : return 'AUTHENTICATED'; + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTING : return 'AUTHENTICATED_AND_STARTING'; + case SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING : return 'AUTHENTICATED_AND_RESTARTING'; + case SimpleMatrixClientState.AUTHENTICATED_AND_STARTED : return 'AUTHENTICATED_AND_STARTED'; + case SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING : return 'AUTHENTICATED_AND_SYNCING'; + } + throw new TypeError(`Unsupported SimpleMatrixClientState value: ${value}`); +} + +export function parseSimpleMatrixClientState (value: any): SimpleMatrixClientState | undefined { + + switch ( `${value}`.toUpperCase() ) { + + case 'UNAUTHENTICATED' : return SimpleMatrixClientState.UNAUTHENTICATED; + case 'AUTHENTICATING' : return SimpleMatrixClientState.AUTHENTICATING; + case 'AUTHENTICATED' : return SimpleMatrixClientState.AUTHENTICATED; + case 'AUTHENTICATED_AND_STARTING' : return SimpleMatrixClientState.AUTHENTICATED_AND_STARTING; + case 'AUTHENTICATED_AND_RESTARTING' : return SimpleMatrixClientState.AUTHENTICATED_AND_RESTARTING; + case 'AUTHENTICATED_AND_STARTED' : return SimpleMatrixClientState.AUTHENTICATED_AND_STARTED; + case 'AUTHENTICATED_AND_SYNCING' : return SimpleMatrixClientState.AUTHENTICATED_AND_SYNCING; + + default : return undefined; + + } + +} + + diff --git a/matrix/types/core/MatrixEventContentDTO.ts b/matrix/types/core/MatrixEventContentDTO.ts new file mode 100644 index 0000000..cdca18a --- /dev/null +++ b/matrix/types/core/MatrixEventContentDTO.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface MatrixEventContentDTO { + readonly body: string; + readonly msgtype: string; +} + +export function isMatrixEventContentDTO (value: any): value is MatrixEventContentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'body', + 'msgtype' + ]) + && isString(value?.body) + && isString(value?.msgtype) + ); +} + +export function stringifyMatrixEventContentDTO (value: MatrixEventContentDTO): string { + return `MatrixEventContentDTO(${value})`; +} + +export function parseMatrixEventContentDTO (value: any): MatrixEventContentDTO | undefined { + if ( isMatrixEventContentDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/core/MatrixEventDTO.ts b/matrix/types/core/MatrixEventDTO.ts new file mode 100644 index 0000000..fc3544c --- /dev/null +++ b/matrix/types/core/MatrixEventDTO.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isMatrixEventContentDTO, MatrixEventContentDTO } from "./MatrixEventContentDTO"; +import { isString } from "../../../types/String"; +import { isNumber } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface MatrixEventDTO { + + readonly content : MatrixEventContentDTO; + readonly room_id : string; + readonly event_id : string; + readonly origin_server_ts : number; + readonly sender : string; + readonly type : string; + +} + +export function isMatrixEventDTO (value: any): value is MatrixEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'content', + 'room_id', + 'event_id', + 'origin_server_ts', + 'sender', + 'type' + ]) + && isMatrixEventContentDTO(value?.content) + && isString(value?.room_id) + && isString(value?.event_id) + && isNumber(value?.origin_server_ts) + && isString(value?.sender) + && isString(value?.type) + ); +} + +export function stringifyMatrixEventDTO (value: MatrixEventDTO): string { + return `MatrixEventDTO(${value})`; +} + +export function parseMatrixEventDTO (value: any): MatrixEventDTO | undefined { + if ( isMatrixEventDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/core/MatrixRoomAlias.ts b/matrix/types/core/MatrixRoomAlias.ts new file mode 100644 index 0000000..562f463 --- /dev/null +++ b/matrix/types/core/MatrixRoomAlias.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../types/String"; + +export type MatrixRoomAlias = string; + +export function isMatrixRoomAlias (value: any): value is MatrixRoomAlias { + return ( + isString(value) + && !!value + && value[0] === '#' + ); +} + +export function stringifyMatrixRoomAlias (value: MatrixRoomAlias): string { + return `MatrixRoomAlias(${value})`; +} + +export function parseMatrixRoomAlias (value: any): MatrixRoomAlias | undefined { + if ( isMatrixRoomAlias(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/core/MatrixRoomId.ts b/matrix/types/core/MatrixRoomId.ts new file mode 100644 index 0000000..5fdf604 --- /dev/null +++ b/matrix/types/core/MatrixRoomId.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../types/String"; + +/** + * Size must not exceed 255 bytes. + */ +export type MatrixRoomId = string; + +export function isMatrixRoomId (value: any): value is MatrixRoomId { + return ( + isString(value) + && !!value + && value[0] === '!' + ); +} + +export function assertMatrixRoomId (value: any): void { + + if (!( isString(value) )) { + throw new TypeError(`value was not string: "${value}"`); + } + + if (!( !!value )) { + throw new TypeError(`value was empty: "${value}"`); + } + + if (!( value[0] === '!' )) { + throw new TypeError(`value did not start with !: "${value}"`); + } + +} + +export function explainMatrixRoomId (value : any) : string { + try { + assertMatrixRoomId(value); + return 'No errors detected'; + } catch (err: any) { + return err?.message; + } +} + + +export function stringifyMatrixRoomId (value: MatrixRoomId): string { + return `MatrixRoomId(${value})`; +} + +export function parseMatrixRoomId (value: any): MatrixRoomId | undefined { + if ( isMatrixRoomId(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/core/MatrixStateEvent.ts b/matrix/types/core/MatrixStateEvent.ts new file mode 100644 index 0000000..77d3570 --- /dev/null +++ b/matrix/types/core/MatrixStateEvent.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { + explainJsonObject, + isReadonlyJsonObject, + ReadonlyJsonObject +} from "../../../Json"; +import { MatrixStateEventOf } from "./MatrixStateEventOf"; +import { MatrixType } from "./MatrixType"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export type MatrixStateEvent = MatrixStateEventOf; + +export function createMatrixStateEvent ( + type : MatrixType | string, + state_key : string, + content : ReadonlyJsonObject +) : MatrixStateEvent { + return { + type, + state_key, + content + }; +} + +export function isMatrixStateEvent (value: any): value is MatrixStateEvent { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'type', + 'state_key', + 'content' + ]) + && isString(value?.type) + && isStringOrUndefined(value?.state_key) + && isReadonlyJsonObject(value?.content) + ); +} + +export function explainMatrixStateEvent (value : any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'type', + 'state_key', + 'content' + ]), + explainProperty("type", explainString(value?.type)), + explainProperty("state_key", explainStringOrUndefined(value?.state_key)), + explainProperty("content", explainJsonObject(value?.content)) + ] + ) +} + +export function stringifyMatrixStateEvent (value: MatrixStateEvent): string { + return `MatrixStateEvent(${value})`; +} + +export function parseMatrixStateEvent (value: any): MatrixStateEvent | undefined { + if ( isMatrixStateEvent(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/core/MatrixStateEventOf.ts b/matrix/types/core/MatrixStateEventOf.ts new file mode 100644 index 0000000..c6dafba --- /dev/null +++ b/matrix/types/core/MatrixStateEventOf.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isMatrixType, MatrixType } from "./MatrixType"; +import { ReadonlyJsonObject } from "../../../Json"; +import { TestCallbackNonStandardOf } from "../../../types/TestCallback"; +import { isStringOrUndefined } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface MatrixStateEventOf { + readonly type : MatrixType | string; + readonly state_key ?: string; + readonly content : T; +} + +export function isMatrixStateEventOf ( + value : any, + isContent : TestCallbackNonStandardOf +): value is MatrixStateEventOf { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'type', + 'state_key', + 'content' + ]) + && isMatrixType(value?.type) + && isStringOrUndefined(value?.state_key) + && isContent(value?.content) + ); +} + diff --git a/matrix/types/core/MatrixType.ts b/matrix/types/core/MatrixType.ts new file mode 100644 index 0000000..ae78fe3 --- /dev/null +++ b/matrix/types/core/MatrixType.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../types/String"; + +/** + * Key size must not exceed 255 bytes. + */ +export enum MatrixType { + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/17 + */ + M_TEXT = 'm.text', + + /** + * Not an event, part of the well known DTO response + */ + M_HOMESERVER = 'm.homeserver', + + /** + * Not an event, part of the well known DTO response + */ + M_IDENTITY_SERVER = 'm.identity_server', + + /** + * Part of the MatrixSyncResponseRoomSummaryDTO + */ + M_HEROES = 'm.heroes', + + /** + * Part of the MatrixSyncResponseRoomSummaryDTO + */ + M_JOINED_MEMBER_COUNT = 'm.joined_member_count', + + /** + * Part of the MatrixSyncResponseRoomSummaryDTO + */ + M_INVITED_MEMBER_COUNT = 'm.invited_member_count', + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/18 + */ + M_ROOM_MESSAGE = 'm.room.message', + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/19 + */ + M_ROOM_POWER_LEVELS = 'm.room.power_levels', + + /** + * Event + * + * @see https://github.com/heusalagroup/hghs/issues/20 + */ + M_ROOM_JOIN_RULES = 'm.room.join_rules', + + /** + * Used as part of the MatrixType.M_ROOM_JOIN_RULES event. + * + * This may be written in wrong syntax + * + * You should probably use RoomMembershipType.M_ROOM_MEMBERSHIP + * + * @see https://github.com/heusalagroup/hghs/issues/20 + * @deprecated + */ + M_ROOM_MEMBERSHIP = 'm.room.membership', + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/21 + */ + M_ROOM_HISTORY_VISIBILITY = 'm.room.history_visibility', + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/22 + */ + M_ROOM_GUEST_ACCESS = 'm.room.guest_access', + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/23 + */ + M_ROOM_CREATE = 'm.room.create', + + /** + * Part of room create event. + * + * See also MatrixRoomCreateEventDTO. + */ + M_FEDERATE = 'm.federate', + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/24 + */ + M_ROOM_MEMBER = 'm.room.member', + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/25 + */ + M_PUSH_RULES = 'm.push_rules', + + /** + * Event + * @see https://github.com/heusalagroup/hghs/issues/26 + */ + M_PRESENCE = 'm.presence', + + /** + * Room type + */ + M_SPACE = 'm.space', + + /** + * Use `MatrixLoginType.M_LOGIN_PASSWORD` instead. + * + * @deprecated + */ + M_LOGIN_PASSWORD = 'm.login.password', + + /** + * Part of the login end point. + */ + M_LOGIN_TOKEN = 'm.login.token', + + /** + * Part of the login end point. See also MatrixIdentifierDTO. + */ + M_ID_USER = 'm.id.user', + + FI_NOR_DELETED = 'fi.nor.deleted', + FI_NOR_FORM_DTO = 'fi.nor.form_dto', + FI_NOR_FORM_VALUE_DTO = 'fi.nor.form_value_dto', + FI_NOR_PIPELINE_DTO = 'fi.nor.dto.pipeline', + FI_NOR_PIPELINE_RUN_DTO = 'fi.nor.dto.pipeline.run', + FI_NOR_AGENT_DTO = 'fi.nor.dto.agent', + FI_NOR_PIPELINE = 'fi.nor.pipeline', + FI_NOR_PIPELINE_STATE = 'fi.nor.pipeline.state' + +} + +export function isMatrixType (value : any) : value is MatrixType { + return isString(value); +} + + diff --git a/matrix/types/core/MatrixUserId.ts b/matrix/types/core/MatrixUserId.ts new file mode 100644 index 0000000..67d2295 --- /dev/null +++ b/matrix/types/core/MatrixUserId.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { explainNot, explainOk } from "../../../types/explain"; +import { isString } from "../../../types/String"; + +/** + * Size must not exceed 255 bytes. + */ +export type MatrixUserId = string; + +export function isMatrixUserId (value: any): value is MatrixUserId { + return ( + isString(value) + && !!value + && value[0] === '@' + ); +} + +export function explainMatrixUserId (value: any) : string { + return isMatrixUserId(value) ? explainOk() : explainNot('MatrixUserId'); +} + +export function stringifyMatrixUserId (value: MatrixUserId): string { + return `MatrixUserId(${value})`; +} + +export function parseMatrixUserId (value: any): MatrixUserId | undefined { + if ( isMatrixUserId(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/event/roomCreate/MatrixRoomCreateEventDTO.ts b/matrix/types/event/roomCreate/MatrixRoomCreateEventDTO.ts new file mode 100644 index 0000000..218be63 --- /dev/null +++ b/matrix/types/event/roomCreate/MatrixRoomCreateEventDTO.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixPreviousRoomDTO, isMatrixPreviousRoomDTO } from "./types/MatrixPreviousRoomDTO"; +import { MatrixType } from "../../core/MatrixType"; +import { isUndefined } from "../../../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../../../types/explain"; +import { isBooleanOrUndefined } from "../../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixRoomCreateEventDTO { + readonly type ?: MatrixType; + readonly creator : string; + readonly [MatrixType.M_FEDERATE] ?: boolean; + readonly room_version ?: string; + readonly predecessor ?: MatrixPreviousRoomDTO; +} + +export function isMatrixCreationContentDTO (value: any): value is MatrixRoomCreateEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'creator', + MatrixType.M_FEDERATE, + 'room_version', + 'predecessor' + ]) + && isString(value?.creator) + && isBooleanOrUndefined(value[MatrixType.M_FEDERATE]) + && isStringOrUndefined(value?.room_version) + && ( isUndefined(value?.predecessor) || isMatrixPreviousRoomDTO(value?.predecessor) ) + ); +} + +export function isPartialMatrixCreationContentDTO (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'creator', + MatrixType.M_FEDERATE, + 'room_version', + 'predecessor' + ]) + && isStringOrUndefined(value?.creator) + && isBooleanOrUndefined(value[MatrixType.M_FEDERATE]) + && isStringOrUndefined(value?.room_version) + && ( isUndefined(value?.predecessor) || isMatrixPreviousRoomDTO(value?.predecessor) ) + ); +} + + +export function isPartialMatrixCreationContentDTOOrUndefined (value: any) : value is Partial | undefined { + return value === undefined || isPartialMatrixCreationContentDTO(value); +} + +export function explainPartialMatrixCreationContentDTOOrUndefined (value: any) : string { + return isPartialMatrixCreationContentDTOOrUndefined(value) ? explainOk() : explainNot( explainOr(['Partial', "undefined"]) ); +} + +export function stringifyMatrixCreationContentDTO (value: MatrixRoomCreateEventDTO): string { + return `MatrixCreationContentDTO(${value})`; +} + +export function parseMatrixCreationContentDTO (value: any): MatrixRoomCreateEventDTO | undefined { + if ( isMatrixCreationContentDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/event/roomCreate/types/MatrixPreviousRoomDTO.ts b/matrix/types/event/roomCreate/types/MatrixPreviousRoomDTO.ts new file mode 100644 index 0000000..f54483b --- /dev/null +++ b/matrix/types/event/roomCreate/types/MatrixPreviousRoomDTO.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../../../types/String"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixPreviousRoomDTO { + readonly room_id : string; + readonly event_id : string; +} + +export function isMatrixPreviousRoomDTO (value: any): value is MatrixPreviousRoomDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, ['room_id', 'event_id']) + && isString(value?.room_id) + && isString(value?.event_id) + ); +} + +export function stringifyMatrixPreviousRoomDTO (value: MatrixPreviousRoomDTO): string { + return `MatrixPreviousRoomDTO(${value})`; +} + +export function parseMatrixPreviousRoomDTO (value: any): MatrixPreviousRoomDTO | undefined { + if ( isMatrixPreviousRoomDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/event/roomGuestAccess/MatrixGuestAccess.ts b/matrix/types/event/roomGuestAccess/MatrixGuestAccess.ts new file mode 100644 index 0000000..b0b13fa --- /dev/null +++ b/matrix/types/event/roomGuestAccess/MatrixGuestAccess.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum MatrixGuestAccess { + CAN_JOIN = "can_join", + FORBIDDEN = "forbidden" +} + +export function isMatrixGuestAccess (value: any): value is MatrixGuestAccess { + switch (value) { + case MatrixGuestAccess.CAN_JOIN: + case MatrixGuestAccess.FORBIDDEN: + return true; + + default: + return false; + + } +} + +export function stringifyMatrixGuestAccess (value: MatrixGuestAccess): string { + switch (value) { + case MatrixGuestAccess.CAN_JOIN : return 'can_join'; + case MatrixGuestAccess.FORBIDDEN : return 'forbidden'; + } + throw new TypeError(`Unsupported MatrixGuestAccess value: ${value}`); +} + +export function parseMatrixGuestAccess (value: any): MatrixGuestAccess | undefined { + + switch (`${value}`.toUpperCase()) { + + case 'CAN_JOIN' : return MatrixGuestAccess.CAN_JOIN; + case 'FORBIDDEN' : return MatrixGuestAccess.FORBIDDEN; + + default : + return undefined; + + } + +} + + diff --git a/matrix/types/event/roomGuestAccess/RoomGuestAccessContentDTO.ts b/matrix/types/event/roomGuestAccess/RoomGuestAccessContentDTO.ts new file mode 100644 index 0000000..2f526ca --- /dev/null +++ b/matrix/types/event/roomGuestAccess/RoomGuestAccessContentDTO.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isMatrixGuestAccess, MatrixGuestAccess } from "./MatrixGuestAccess"; +import { ReadonlyJsonObject } from "../../../../Json"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface RoomGuestAccessContentDTO extends ReadonlyJsonObject { + readonly guest_access : MatrixGuestAccess; +} + +export function createRoomGuestAccessContentDTO ( + guest_access: MatrixGuestAccess +): RoomGuestAccessContentDTO { + return { + guest_access + }; +} + +export function isRoomGuestAccessContentDTO (value: any): value is RoomGuestAccessContentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'guest_access' + ]) + && isMatrixGuestAccess(value?.guest_access) + ); +} + +export function stringifyRoomGuestAccessContentDTO (value: RoomGuestAccessContentDTO): string { + return `RoomGuestAccessContentDTO(${value})`; +} + +export function parseRoomGuestAccessContentDTO (value: any): RoomGuestAccessContentDTO | undefined { + if ( isRoomGuestAccessContentDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomGuestAccess/RoomGuestAccessStateEventDTO.ts b/matrix/types/event/roomGuestAccess/RoomGuestAccessStateEventDTO.ts new file mode 100644 index 0000000..475319a --- /dev/null +++ b/matrix/types/event/roomGuestAccess/RoomGuestAccessStateEventDTO.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { + isRoomGuestAccessContentDTO, + RoomGuestAccessContentDTO +} from "./RoomGuestAccessContentDTO"; +import { MatrixStateEventOf } from "../../core/MatrixStateEventOf"; +import { MatrixType } from "../../core/MatrixType"; +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface RoomGuestAccessStateEventDTO extends MatrixStateEventOf { + readonly type : MatrixType.M_ROOM_GUEST_ACCESS; + readonly state_key : ""; + readonly content : RoomGuestAccessContentDTO; +} + +export function createRoomGuestAccessStateEventDTO ( + content: RoomGuestAccessContentDTO +): RoomGuestAccessStateEventDTO { + return { + type: MatrixType.M_ROOM_GUEST_ACCESS, + state_key: '', + content + }; +} + +export function isRoomGuestAccessStateEventDTO (value: any): value is RoomGuestAccessStateEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'type', + 'state_key', + 'content' + ]) + && value?.type === MatrixType.M_ROOM_GUEST_ACCESS + && isString(value?.state_key) + && isRoomGuestAccessContentDTO(value?.content) + ); +} + +export function stringifyRoomGuestAccessStateEventDTO (value: RoomGuestAccessStateEventDTO): string { + return `RoomGuestAccessStateEventDTO(${value})`; +} + +export function parseRoomGuestAccessStateEventDTO (value: any): RoomGuestAccessStateEventDTO | undefined { + if ( isRoomGuestAccessStateEventDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomHistoryVisibility/MatrixHistoryVisibility.ts b/matrix/types/event/roomHistoryVisibility/MatrixHistoryVisibility.ts new file mode 100644 index 0000000..53ae6b0 --- /dev/null +++ b/matrix/types/event/roomHistoryVisibility/MatrixHistoryVisibility.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum MatrixHistoryVisibility { + INVITED = "invited", + JOINED = "joined", + SHARED = "shared", + WORLD_READABLE = "world_readable", +} + +export function isMatrixHistoryVisibility (value: any): value is MatrixHistoryVisibility { + switch (value) { + + case MatrixHistoryVisibility.INVITED: + case MatrixHistoryVisibility.JOINED: + case MatrixHistoryVisibility.SHARED: + case MatrixHistoryVisibility.WORLD_READABLE: + return true; + + default: + return false; + + } +} + +export function stringifyMatrixHistoryVisibility (value: MatrixHistoryVisibility): string { + switch (value) { + case MatrixHistoryVisibility.INVITED : return 'invited'; + case MatrixHistoryVisibility.JOINED : return 'joined'; + case MatrixHistoryVisibility.SHARED : return 'shared'; + case MatrixHistoryVisibility.WORLD_READABLE : return 'world_readable'; + } + throw new TypeError(`Unsupported MatrixHistoryVisibility value: ${value}`); +} + +export function parseMatrixHistoryVisibility (value: any): MatrixHistoryVisibility | undefined { + if (value === undefined) return undefined; + switch (`${value}`.toUpperCase()) { + case 'INVITED' : return MatrixHistoryVisibility.INVITED; + case 'JOINED' : return MatrixHistoryVisibility.JOINED; + case 'SHARED' : return MatrixHistoryVisibility.SHARED; + case 'WORLD_READABLE' : return MatrixHistoryVisibility.WORLD_READABLE; + default : return undefined; + } +} + + diff --git a/matrix/types/event/roomHistoryVisibility/RoomHistoryVisibilityStateContentDTO.ts b/matrix/types/event/roomHistoryVisibility/RoomHistoryVisibilityStateContentDTO.ts new file mode 100644 index 0000000..e1eb6e4 --- /dev/null +++ b/matrix/types/event/roomHistoryVisibility/RoomHistoryVisibilityStateContentDTO.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isMatrixHistoryVisibility, MatrixHistoryVisibility } from "./MatrixHistoryVisibility"; +import { ReadonlyJsonObject } from "../../../../Json"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface RoomHistoryVisibilityStateContentDTO extends ReadonlyJsonObject { + readonly history_visibility : MatrixHistoryVisibility; +} + +export function createRoomHistoryVisibilityStateContentDTO ( + history_visibility : MatrixHistoryVisibility +): RoomHistoryVisibilityStateContentDTO { + return { + history_visibility + }; +} + +export function isRoomHistoryVisibilityStateContentDTO (value: any): value is RoomHistoryVisibilityStateContentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'history_visibility' + ]) + && isMatrixHistoryVisibility(value?.history_visibility) + ); +} + +export function stringifyRoomHistoryVisibilityStateContentDTO (value: RoomHistoryVisibilityStateContentDTO): string { + return `RoomHistoryVisibilityStateContentDTO(${value})`; +} + +export function parseRoomHistoryVisibilityStateContentDTO (value: any): RoomHistoryVisibilityStateContentDTO | undefined { + if ( isRoomHistoryVisibilityStateContentDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomHistoryVisibility/RoomHistoryVisibilityStateEventDTO.ts b/matrix/types/event/roomHistoryVisibility/RoomHistoryVisibilityStateEventDTO.ts new file mode 100644 index 0000000..bde5b61 --- /dev/null +++ b/matrix/types/event/roomHistoryVisibility/RoomHistoryVisibilityStateEventDTO.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isRoomHistoryVisibilityStateContentDTO, RoomHistoryVisibilityStateContentDTO } from "./RoomHistoryVisibilityStateContentDTO"; +import { MatrixStateEventOf } from "../../core/MatrixStateEventOf"; +import { MatrixType } from "../../core/MatrixType"; +import { isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface RoomHistoryVisibilityStateEventDTO extends MatrixStateEventOf { + readonly type : MatrixType.M_ROOM_HISTORY_VISIBILITY; + readonly state_key : string; + readonly content : RoomHistoryVisibilityStateContentDTO; +} + +export function createRoomHistoryVisibilityStateEventDTO ( + content : RoomHistoryVisibilityStateContentDTO +): RoomHistoryVisibilityStateEventDTO { + return { + type: MatrixType.M_ROOM_HISTORY_VISIBILITY, + state_key: '', + content + }; +} + +export function isRoomHistoryVisibilityStateEventDTO (value: any): value is RoomHistoryVisibilityStateEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'type', + 'state_key', + 'content' + ]) + && value?.type === MatrixType.M_ROOM_HISTORY_VISIBILITY + && isStringOrUndefined(value?.state_key) + && isRoomHistoryVisibilityStateContentDTO(value?.content) + ); +} + +export function stringifyRoomHistoryVisibilityStateEventDTO (value: RoomHistoryVisibilityStateEventDTO): string { + return `RoomHistoryVisibilityStateEventDTO(${value})`; +} + +export function parseRoomHistoryVisibilityStateEventDTO (value: any): RoomHistoryVisibilityStateEventDTO | undefined { + if ( isRoomHistoryVisibilityStateEventDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomJoinRules/MatrixJoinRule.ts b/matrix/types/event/roomJoinRules/MatrixJoinRule.ts new file mode 100644 index 0000000..4434e92 --- /dev/null +++ b/matrix/types/event/roomJoinRules/MatrixJoinRule.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +/** + * @see https://spec.matrix.org/v1.2/client-server-api/#mroomjoin_rules + * @see https://github.com/heusalagroup/hghs/issues/20 + */ +export enum MatrixJoinRule { + + PUBLIC = "public", + INVITE = "invite", + KNOCK = "knock", + + /** + * Room v8 feature. + * See https://github.com/matrix-org/matrix-doc/blob/master/proposals/3083-restricted-rooms.md + */ + RESTRICTED = "restricted", + + PRIVATE = "private" + +} + +export function isMatrixJoinRule (value: any): value is MatrixJoinRule { + switch (value) { + case MatrixJoinRule.PUBLIC : + case MatrixJoinRule.INVITE : + case MatrixJoinRule.KNOCK : + case MatrixJoinRule.RESTRICTED : + case MatrixJoinRule.PRIVATE : + return true; + default: + return false; + } +} + +export function stringifyMatrixJoinRule (value: MatrixJoinRule): string { + switch (value) { + case MatrixJoinRule.PUBLIC : return 'PUBLIC'; + case MatrixJoinRule.KNOCK : return 'KNOCK'; + case MatrixJoinRule.INVITE : return 'INVITE'; + case MatrixJoinRule.PRIVATE : return 'PRIVATE'; + case MatrixJoinRule.RESTRICTED : return 'RESTRICTED'; + } + throw new TypeError(`Unsupported MatrixJoinRule value: ${value}`); +} + +export function parseMatrixJoinRule (value: any): MatrixJoinRule | undefined { + if (value === undefined) return undefined; + switch (`${value}`.toUpperCase()) { + case 'PUBLIC' : return MatrixJoinRule.PUBLIC; + case 'KNOCK' : return MatrixJoinRule.KNOCK; + case 'INVITE' : return MatrixJoinRule.INVITE; + case 'PRIVATE' : return MatrixJoinRule.PRIVATE; + case 'RESTRICTED' : return MatrixJoinRule.RESTRICTED; + default : return undefined; + } +} diff --git a/matrix/types/event/roomJoinRules/RoomJoinRulesAllowConditionDTO.ts b/matrix/types/event/roomJoinRules/RoomJoinRulesAllowConditionDTO.ts new file mode 100644 index 0000000..dfe59c9 --- /dev/null +++ b/matrix/types/event/roomJoinRules/RoomJoinRulesAllowConditionDTO.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isRoomMembershipType, RoomMembershipType } from "./RoomMembershipType"; +import { isMatrixRoomId, MatrixRoomId } from "../../core/MatrixRoomId"; +import { ReadonlyJsonObject } from "../../../../Json"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface RoomJoinRulesAllowConditionDTO extends ReadonlyJsonObject { + readonly type : RoomMembershipType; + readonly room_id : MatrixRoomId; +} + +export function createRoomJoinRulesAllowConditionDTO ( + type : RoomMembershipType, + room_id : MatrixRoomId +): RoomJoinRulesAllowConditionDTO { + return { + type, + room_id + }; +} + +export function isRoomJoinRulesAllowConditionDTO (value: any): value is RoomJoinRulesAllowConditionDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'type', + 'room_id' + ]) + && isRoomMembershipType(value?.type) + && isMatrixRoomId(value?.room_id) + ); +} + +export function stringifyRoomJoinRulesAllowConditionDTO (value: RoomJoinRulesAllowConditionDTO): string { + return `RoomJoinRulesAllowConditionDTO(${value})`; +} + +export function parseRoomJoinRulesAllowConditionDTO (value: any): RoomJoinRulesAllowConditionDTO | undefined { + if ( isRoomJoinRulesAllowConditionDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomJoinRules/RoomJoinRulesStateContentDTO.ts b/matrix/types/event/roomJoinRules/RoomJoinRulesStateContentDTO.ts new file mode 100644 index 0000000..f5c5e88 --- /dev/null +++ b/matrix/types/event/roomJoinRules/RoomJoinRulesStateContentDTO.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isMatrixJoinRule, MatrixJoinRule } from "./MatrixJoinRule"; +import { isRoomJoinRulesAllowConditionDTO, RoomJoinRulesAllowConditionDTO } from "./RoomJoinRulesAllowConditionDTO"; +import { ReadonlyJsonObject } from "../../../../Json"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; +import { isArrayOf } from "../../../../types/Array"; + +/** + * @see https://spec.matrix.org/v1.2/client-server-api/#mroomjoin_rules + * @see https://github.com/heusalagroup/hghs/issues/20 + */ +export interface RoomJoinRulesStateContentDTO extends ReadonlyJsonObject { + readonly allow : readonly RoomJoinRulesAllowConditionDTO[]; + readonly join_rule : MatrixJoinRule; +} + +export function createRoomJoinRulesStateContentDTO ( + join_rule : MatrixJoinRule, + allow : readonly RoomJoinRulesAllowConditionDTO[] +): RoomJoinRulesStateContentDTO { + return { + join_rule, + allow + }; +} + +export function isRoomJoinRulesStateContentDTO (value: any): value is RoomJoinRulesStateContentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'allow', + 'join_rule' + ]) + && isArrayOf(value?.allow, isRoomJoinRulesAllowConditionDTO) + && isMatrixJoinRule(value?.join_rule) + ); +} + +export function stringifyRoomJoinRulesStateContentDTO (value: RoomJoinRulesStateContentDTO): string { + return `RoomJoinRulesStateContentDTO(${value})`; +} + +export function parseRoomJoinRulesStateContentDTO (value: any): RoomJoinRulesStateContentDTO | undefined { + if ( isRoomJoinRulesStateContentDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomJoinRules/RoomJoinRulesStateEventDTO.ts b/matrix/types/event/roomJoinRules/RoomJoinRulesStateEventDTO.ts new file mode 100644 index 0000000..abd9a58 --- /dev/null +++ b/matrix/types/event/roomJoinRules/RoomJoinRulesStateEventDTO.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isRoomJoinRulesStateContentDTO, RoomJoinRulesStateContentDTO } from "./RoomJoinRulesStateContentDTO"; +import { MatrixStateEventOf } from "../../core/MatrixStateEventOf"; +import { MatrixType } from "../../core/MatrixType"; +import { isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +/** + * @see https://github.com/heusalagroup/hghs/issues/20 + */ +export interface RoomJoinRulesStateEventDTO extends MatrixStateEventOf { + readonly type : MatrixType.M_ROOM_JOIN_RULES; + readonly state_key : string; + readonly content : RoomJoinRulesStateContentDTO; +} + +export function createRoomJoinRulesStateEventDTO ( + content : RoomJoinRulesStateContentDTO +): RoomJoinRulesStateEventDTO { + return { + type: MatrixType.M_ROOM_JOIN_RULES, + state_key: '', + content + }; +} + +export function isRoomJoinRulesStateEventDTO (value: any): value is RoomJoinRulesStateEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'type', + 'state_key', + 'content' + ]) + && value?.type === MatrixType.M_ROOM_HISTORY_VISIBILITY + && isStringOrUndefined(value?.state_key) + && isRoomJoinRulesStateContentDTO(value?.content) + ); +} + +export function stringifyRoomJoinRulesStateEventDTO (value: RoomJoinRulesStateEventDTO): string { + return `RoomJoinRulesStateEventDTO(${value})`; +} + +export function parseRoomJoinRulesStateEventDTO (value: any): RoomJoinRulesStateEventDTO | undefined { + if ( isRoomJoinRulesStateEventDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomJoinRules/RoomMembershipType.ts b/matrix/types/event/roomJoinRules/RoomMembershipType.ts new file mode 100644 index 0000000..19840f0 --- /dev/null +++ b/matrix/types/event/roomJoinRules/RoomMembershipType.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export enum RoomMembershipType { + + /** + * There is also MatrixType.M_ROOM_MEMBERSHIP but that should not be used. + */ + M_ROOM_MEMBERSHIP = "m.room_membership" + +} + +export function isRoomMembershipType (value: any): value is RoomMembershipType { + switch (value) { + case RoomMembershipType.M_ROOM_MEMBERSHIP: + return true; + + default: + return false; + + } +} + +export function stringifyRoomMembershipType (value: RoomMembershipType): string { + switch (value) { + case RoomMembershipType.M_ROOM_MEMBERSHIP : return 'm.room_membership'; + } + throw new TypeError(`Unsupported RoomMembershipType value: ${value}`); +} + +export function parseRoomMembershipType (value: any): RoomMembershipType | undefined { + switch (`${value}`.toLowerCase()) { + + case 'm.room_membership': + case 'room_membership' : return RoomMembershipType.M_ROOM_MEMBERSHIP; + + default : return undefined; + } +} diff --git a/matrix/types/event/roomMember/RoomMemberContent3rdPartyInviteDTO.ts b/matrix/types/event/roomMember/RoomMemberContent3rdPartyInviteDTO.ts new file mode 100644 index 0000000..a1c7fee --- /dev/null +++ b/matrix/types/event/roomMember/RoomMemberContent3rdPartyInviteDTO.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isRoomMemberStateSignedDTO, RoomMemberStateSignedDTO } from "./RoomMemberStateSignedDTO"; +import { ReadonlyJsonObject } from "../../../../Json"; +import { isUndefined } from "../../../../types/undefined"; +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface RoomMemberContent3rdPartyInviteDTO extends ReadonlyJsonObject { + readonly display_name : string; + readonly signed : RoomMemberStateSignedDTO; +} + +export function createRoomMemberContent3rdPartyInviteDTO ( + display_name: string, + signed: RoomMemberStateSignedDTO +): RoomMemberContent3rdPartyInviteDTO { + return { + display_name, + signed + }; +} + +export function isRoomMemberContent3rdPartyInviteDTO (value: any): value is RoomMemberContent3rdPartyInviteDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'display_name', + 'signed' + ]) + && isString(value?.display_name) + && isRoomMemberStateSignedDTO(value?.signed) + ); +} + +export function isRoomMemberContent3rdPartyInviteDTOOrUndefined (value: any): value is RoomMemberContent3rdPartyInviteDTO | undefined { + return isUndefined(value) || isRoomMemberContent3rdPartyInviteDTO(value); +} + +export function stringifyRoomMemberContent3rdPartyInviteDTO (value: RoomMemberContent3rdPartyInviteDTO): string { + return `RoomMemberStateInviteDTO(${value})`; +} + +export function parseRoomMemberContent3rdPartyInviteDTO (value: any): RoomMemberContent3rdPartyInviteDTO | undefined { + if ( isRoomMemberContent3rdPartyInviteDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomMember/RoomMemberContentDTO.ts b/matrix/types/event/roomMember/RoomMemberContentDTO.ts new file mode 100644 index 0000000..aa489d2 --- /dev/null +++ b/matrix/types/event/roomMember/RoomMemberContentDTO.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isNull } from "../../../../types/Null"; +import { ReadonlyJsonObject } from "../../../../Json"; +import { isRoomMembershipState, RoomMembershipState } from "./RoomMembershipState"; +import { isRoomMemberContent3rdPartyInviteDTOOrUndefined, RoomMemberContent3rdPartyInviteDTO } from "./RoomMemberContent3rdPartyInviteDTO"; +import { isBooleanOrUndefined } from "../../../../types/Boolean"; +import { isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface RoomMemberContentDTO extends ReadonlyJsonObject { + readonly membership : RoomMembershipState; + readonly avatar_url ?: string; + readonly displayname ?: string | null; + readonly is_direct ?: boolean; + readonly join_authorised_via_users_server ?: string; + readonly reason ?: string; + readonly third_party_invite ?: RoomMemberContent3rdPartyInviteDTO; +} + +export function createRoomMemberContentDTO ( + membership : RoomMembershipState, + reason ?: string | undefined, + avatar_url ?: string | undefined, + displayname ?: string | null | undefined, + is_direct ?: boolean | undefined, + join_authorised_via_users_server ?: string | undefined, + third_party_invite ?: RoomMemberContent3rdPartyInviteDTO +): RoomMemberContentDTO { + return { + avatar_url, + displayname, + is_direct, + join_authorised_via_users_server, + membership, + reason, + third_party_invite + }; +} + +export function isRoomMemberContentDTO (value: any): value is RoomMemberContentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'membership', + 'avatar_url', + 'displayname', + 'is_direct', + 'join_authorised_via_users_server', + 'reason', + 'third_party_invite' + ]) + && isRoomMembershipState(value?.membership) + && isStringOrUndefined(value?.avatar_url) + && (isStringOrUndefined(value?.displayname) || isNull(value?.displayname)) + && isBooleanOrUndefined(value?.is_direct) + && isStringOrUndefined(value?.join_authorised_via_users_server) + && isStringOrUndefined(value?.reason) + && isRoomMemberContent3rdPartyInviteDTOOrUndefined(value?.third_party_invite) + ); +} + +export function stringifyRoomMemberContentDTO (value: RoomMemberContentDTO): string { + return `RoomMemberContentDTO(${value})`; +} + +export function parseRoomMemberContentDTO (value: any): RoomMemberContentDTO | undefined { + if ( isRoomMemberContentDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomMember/RoomMemberStateEventDTO.ts b/matrix/types/event/roomMember/RoomMemberStateEventDTO.ts new file mode 100644 index 0000000..b6372f8 --- /dev/null +++ b/matrix/types/event/roomMember/RoomMemberStateEventDTO.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { MatrixStateEventOf } from "../../core/MatrixStateEventOf"; +import { isRoomMemberContentDTO, RoomMemberContentDTO } from "./RoomMemberContentDTO"; +import { MatrixType } from "../../core/MatrixType"; +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface RoomMemberStateEventDTO extends MatrixStateEventOf { + readonly type : MatrixType.M_ROOM_MEMBER; + readonly state_key : string; + readonly content : RoomMemberContentDTO; +} + +export function createRoomMemberStateEventDTO ( + state_key: string, + content: RoomMemberContentDTO +): RoomMemberStateEventDTO { + return { + type: MatrixType.M_ROOM_MEMBER, + state_key, + content + }; +} + +export function isRoomMemberStateEventDTO (value: any): value is RoomMemberStateEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'type', + 'state_key', + 'content' + ]) + && value?.type === MatrixType.M_ROOM_MEMBER + && isString(value?.state_key) + && isRoomMemberContentDTO(value?.content) + ); +} + +export function stringifyRoomMemberStateEventDTO (value: RoomMemberStateEventDTO): string { + return `RoomMemberStateEventDTO(${value})`; +} + +export function parseRoomMemberStateEventDTO (value: any): RoomMemberStateEventDTO | undefined { + if ( isRoomMemberStateEventDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomMember/RoomMemberStateSignedDTO.ts b/matrix/types/event/roomMember/RoomMemberStateSignedDTO.ts new file mode 100644 index 0000000..f5d8999 --- /dev/null +++ b/matrix/types/event/roomMember/RoomMemberStateSignedDTO.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isReadonlyJsonObject, ReadonlyJsonObject } from "../../../../Json"; +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface RoomMemberStateSignedDTO extends ReadonlyJsonObject { + readonly mxid : string; + readonly signatures : ReadonlyJsonObject; + readonly token : string; +} + +export function createRoomMemberStateSignedDTO ( + mxid: string, + signatures: ReadonlyJsonObject, + token : string +): RoomMemberStateSignedDTO { + return { + mxid, + signatures, + token + }; +} + +export function isRoomMemberStateSignedDTO (value: any): value is RoomMemberStateSignedDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'mxid', + 'signatures', + 'token' + ]) + && isString(value?.mxid) + && isReadonlyJsonObject(value?.signatures) + && isString(value?.token) + ); +} + +export function stringifyRoomMemberStateSignedDTO (value: RoomMemberStateSignedDTO): string { + return `RoomMemberStateSignedDTO(${value})`; +} + +export function parseRoomMemberStateSignedDTO (value: any): RoomMemberStateSignedDTO | undefined { + if ( isRoomMemberStateSignedDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/event/roomMember/RoomMembershipState.ts b/matrix/types/event/roomMember/RoomMembershipState.ts new file mode 100644 index 0000000..626c7e7 --- /dev/null +++ b/matrix/types/event/roomMember/RoomMembershipState.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export enum RoomMembershipState { + INVITE = "INVITE", + JOIN = "JOIN", + KNOCK = "KNOCK", + LEAVE = "LEAVE", + BAN = "BAN" +} + +export function isRoomMembershipState (value: any): value is RoomMembershipState { + switch (value) { + case RoomMembershipState.INVITE: + case RoomMembershipState.JOIN: + case RoomMembershipState.KNOCK: + case RoomMembershipState.LEAVE: + case RoomMembershipState.BAN: + return true; + default: + return false; + } +} + +export function stringifyRoomMembershipState (value: RoomMembershipState): string { + switch (value) { + case RoomMembershipState.INVITE : return 'INVITE'; + case RoomMembershipState.JOIN : return 'JOIN'; + case RoomMembershipState.KNOCK : return 'KNOCK'; + case RoomMembershipState.LEAVE : return 'LEAVE'; + case RoomMembershipState.BAN : return 'BAN'; + } + throw new TypeError(`Unsupported RoomMembershipState value: ${value}`); +} + +export function parseRoomMembershipState (value: any): RoomMembershipState | undefined { + if (value === undefined) return undefined; + switch (`${value}`.toUpperCase()) { + case 'INVITE' : return RoomMembershipState.INVITE; + case 'JOIN' : return RoomMembershipState.JOIN; + case 'KNOCK' : return RoomMembershipState.KNOCK; + case 'LEAVE' : return RoomMembershipState.LEAVE; + case 'BAN' : return RoomMembershipState.BAN; + default : return undefined; + } +} diff --git a/matrix/types/message/textMessage/MatrixTextMessageDTO.ts b/matrix/types/message/textMessage/MatrixTextMessageDTO.ts new file mode 100644 index 0000000..df2ab72 --- /dev/null +++ b/matrix/types/message/textMessage/MatrixTextMessageDTO.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isMatrixType, MatrixType } from "../../core/MatrixType"; +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +/** + * @see https://github.com/heusalagroup/hghs/issues/17 + */ +export interface MatrixTextMessageDTO { + readonly msgtype : MatrixType; + readonly body : string; +} + +export function createMatrixTextMessageDTO ( + body: string +): MatrixTextMessageDTO { + return { + msgtype: MatrixType.M_TEXT, + body + }; +} + +export function isMatrixTextMessageDTO (value: any): value is MatrixTextMessageDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'msgtype', + 'body' + ]) + && isMatrixType(value?.msgtype) + && isString(value?.body) + ); +} + +export function stringifyMatrixTextMessageDTO (value: MatrixTextMessageDTO): string { + return `MatrixTextMessageDTO(${value})`; +} + +export function parseMatrixTextMessageDTO (value: any): MatrixTextMessageDTO | undefined { + if ( isMatrixTextMessageDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/request/createRoom/MatrixCreateRoomDTO.ts b/matrix/types/request/createRoom/MatrixCreateRoomDTO.ts new file mode 100644 index 0000000..63e0b23 --- /dev/null +++ b/matrix/types/request/createRoom/MatrixCreateRoomDTO.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixVisibility, isMatrixVisibilityOrUndefined, explainMatrixVisibilityOrUndefined } from "./types/MatrixVisibility"; +import { explainMatrixInvite3PidDTO, isMatrixInvite3PidDTO } from "./types/MatrixInvite3PidDTO"; +import { MatrixRoomCreateEventDTO, isPartialMatrixCreationContentDTOOrUndefined, explainPartialMatrixCreationContentDTOOrUndefined } from "../../event/roomCreate/MatrixRoomCreateEventDTO"; +import { MatrixStateEvent, isMatrixStateEvent, explainMatrixStateEvent } from "../../core/MatrixStateEvent"; +import { MatrixCreateRoomPreset, isMatrixCreateRoomPresetOrUndefined, explainMatrixCreateRoomPresetOrUndefined } from "./types/MatrixCreateRoomPreset"; +import { MatrixRoomPowerLevelsEventDTO, isMatrixPowerLevelEventContentDTOOrUndefined, explainMatrixPowerLevelEventContentDTOOrUndefined } from "./types/MatrixRoomPowerLevelsEventDTO"; +import { MatrixInvite3PidDTO } from "./types/MatrixInvite3PidDTO"; +import { MatrixUserId, isMatrixUserId, explainMatrixUserId } from "../../core/MatrixUserId"; +import { explainMatrixRoomVersionOrUndefined, isMatrixRoomVersionOrUndefined, MatrixRoomVersion } from "../../MatrixRoomVersion"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../../../types/Boolean"; +import { explainStringOrUndefined, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; +import { explainReadonlyArrayOfOrUndefined, isReadonlyArrayOfOrUndefined } from "../../../../types/Array"; + +export interface MatrixCreateRoomDTO { + + readonly visibility ?: MatrixVisibility; + readonly room_alias_name ?: string; + + /** + * User friendly room name. This must be unique. + */ + readonly name ?: string; + + readonly topic ?: string; + readonly invite ?: readonly MatrixUserId[]; + readonly invite_3pid ?: readonly MatrixInvite3PidDTO[]; + readonly room_version ?: MatrixRoomVersion; + readonly creation_content ?: Partial; + readonly initial_state ?: readonly MatrixStateEvent[]; + readonly preset ?: MatrixCreateRoomPreset; + readonly is_direct ?: boolean; + readonly power_level_content_override ?: MatrixRoomPowerLevelsEventDTO; + +} + +export function isMatrixCreateRoomDTO (value: any): value is MatrixCreateRoomDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'visibility', + 'room_alias_name', + 'name', + 'topic', + 'invite', + 'invite_3pid', + 'room_version', + 'creation_content', + 'initial_state', + 'preset', + 'is_direct', + 'power_level_content_override' + ]) + && isMatrixVisibilityOrUndefined(value?.visibility) + && isStringOrUndefined(value?.room_alias_name) + && isStringOrUndefined(value?.name) + && isStringOrUndefined(value?.topic) + && isReadonlyArrayOfOrUndefined(value?.invite, isMatrixUserId) + && isReadonlyArrayOfOrUndefined(value?.invite_3pid, isMatrixInvite3PidDTO) + && isMatrixRoomVersionOrUndefined(value?.room_version) + && isPartialMatrixCreationContentDTOOrUndefined(value?.creation_content) + && isReadonlyArrayOfOrUndefined(value?.initial_state, isMatrixStateEvent) + && isMatrixCreateRoomPresetOrUndefined(value?.preset) + && isBooleanOrUndefined(value?.is_direct) + && isMatrixPowerLevelEventContentDTOOrUndefined(value?.power_level_content_override) + ); +} + +export function explainMatrixCreateRoomDTO (value: any): string { + return explain([ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'visibility', + 'room_alias_name', + 'name', + 'topic', + 'invite', + 'invite_3pid', + 'room_version', + 'creation_content', + 'initial_state', + 'preset', + 'is_direct', + 'power_level_content_override' + ]), + explainProperty('visibility', explainMatrixVisibilityOrUndefined(value?.visibility)), + explainProperty('room_alias_name', explainStringOrUndefined(value?.room_alias_name)), + explainProperty('name', explainStringOrUndefined(value?.name)), + explainProperty('topic', explainStringOrUndefined(value?.topic)), + explainProperty('invite', explainReadonlyArrayOfOrUndefined("MatrixUserId", explainMatrixUserId, value?.invite, isMatrixUserId)), + explainProperty('invite_3pid', explainReadonlyArrayOfOrUndefined("MatrixInvite3PidDTO", explainMatrixInvite3PidDTO, value?.invite_3pid, isMatrixInvite3PidDTO)), + explainProperty('room_version', explainMatrixRoomVersionOrUndefined(value?.room_version)), + explainProperty('creation_content', explainPartialMatrixCreationContentDTOOrUndefined(value?.creation_content)), + explainProperty('initial_state', explainReadonlyArrayOfOrUndefined("MatrixStateEvent", explainMatrixStateEvent, value?.initial_state, isMatrixStateEvent)), + explainProperty('preset', explainMatrixCreateRoomPresetOrUndefined(value?.preset)), + explainProperty('explain_direct', explainBooleanOrUndefined(value?.explain_direct)), + explainProperty('power_level_content_override', explainMatrixPowerLevelEventContentDTOOrUndefined(value?.power_level_content_override)) + ]); +} + +export function stringifyMatrixCreateRoomDTO (value: MatrixCreateRoomDTO): string { + return `MatrixCreateRoomDTO(${value})`; +} + +export function parseMatrixCreateRoomDTO (value: any): MatrixCreateRoomDTO | undefined { + if ( isMatrixCreateRoomDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/createRoom/types/MatrixCreateRoomPreset.ts b/matrix/types/request/createRoom/types/MatrixCreateRoomPreset.ts new file mode 100644 index 0000000..c9a1a2f --- /dev/null +++ b/matrix/types/request/createRoom/types/MatrixCreateRoomPreset.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../../../../types/explain"; + +export enum MatrixCreateRoomPreset { + PRIVATE_CHAT = "private_chat", + PUBLIC_CHAT = "public_chat", + TRUSTED_PRIVATE_CHAT = "trusted_private_chat" +} + +export function isMatrixCreateRoomPreset (value: any): value is MatrixCreateRoomPreset { + switch (value) { + case MatrixCreateRoomPreset.PRIVATE_CHAT: + case MatrixCreateRoomPreset.PUBLIC_CHAT: + case MatrixCreateRoomPreset.TRUSTED_PRIVATE_CHAT: + return true; + default: + return false; + } +} + +export function explainMatrixCreateRoomPreset (value: any): string { + return isMatrixCreateRoomPreset(value) ? explainOk() : explainNot("MatrixCreateRoomPreset"); +} + +export function isMatrixCreateRoomPresetOrUndefined (value: any): value is MatrixCreateRoomPreset | undefined { + return value === undefined || isMatrixCreateRoomPreset(value); +} + +export function explainMatrixCreateRoomPresetOrUndefined (value: any): string { + return isMatrixCreateRoomPresetOrUndefined(value) ? explainOk() : explainNot( explainOr(["MatrixCreateRoomPreset", "undefined"])); +} + +export function stringifyMatrixCreateRoomPreset (value: MatrixCreateRoomPreset): string { + switch (value) { + case MatrixCreateRoomPreset.PRIVATE_CHAT : return 'private_chat'; + case MatrixCreateRoomPreset.PUBLIC_CHAT : return 'public_chat'; + case MatrixCreateRoomPreset.TRUSTED_PRIVATE_CHAT : return 'trusted_private_chat'; + } + throw new TypeError(`Unsupported MatrixCreateRoomPreset value: ${value}`); +} + +export function parseMatrixCreateRoomPreset (value: any): MatrixCreateRoomPreset | undefined { + + switch (value) { + + case MatrixCreateRoomPreset.PRIVATE_CHAT : return MatrixCreateRoomPreset.PRIVATE_CHAT; + case MatrixCreateRoomPreset.PUBLIC_CHAT : return MatrixCreateRoomPreset.PUBLIC_CHAT; + case MatrixCreateRoomPreset.TRUSTED_PRIVATE_CHAT : return MatrixCreateRoomPreset.TRUSTED_PRIVATE_CHAT; + default : + return undefined; + + } + +} + + diff --git a/matrix/types/request/createRoom/types/MatrixEventPowerLevelsDTO.ts b/matrix/types/request/createRoom/types/MatrixEventPowerLevelsDTO.ts new file mode 100644 index 0000000..c16c3b8 --- /dev/null +++ b/matrix/types/request/createRoom/types/MatrixEventPowerLevelsDTO.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isMatrixType, MatrixType } from "../../../core/MatrixType"; +import { isUndefined } from "../../../../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../../../../types/explain"; +import { isInteger } from "../../../../../types/Number"; +import { isRegularObjectOf } from "../../../../../types/RegularObject"; + +export type MatrixEventPowerLevelsDTO = { + [K in MatrixType | string ]: number; +} + +export function isMatrixEventPowerLevelsDTO (value: any): value is MatrixEventPowerLevelsDTO { + return isRegularObjectOf(value, isMatrixType, isInteger); +} + +export function explainMatrixEventPowerLevelsDTO (value: any): string { + return isMatrixEventPowerLevelsDTO(value) ? explainOk() : explainNot("MatrixEventPowerLevelsDTO"); +} + +export function isMatrixEventPowerLevelsDTOOrUndefined (value: any): value is MatrixEventPowerLevelsDTO | undefined { + return isUndefined(value) || isMatrixEventPowerLevelsDTO(value); +} + +export function explainMatrixEventPowerLevelsDTOOrUndefined (value: any): string { + return isMatrixEventPowerLevelsDTO(value) ? explainOk() : explainNot(explainOr(["MatrixEventPowerLevelsDTO", "undefined"])); +} + +export function stringifyMatrixEventPowerLevelsDTO (value: MatrixEventPowerLevelsDTO): string { + return `MatrixEventPowerLevelsDTO(${value})`; +} + +export function parseMatrixEventPowerLevelsDTO (value: any): MatrixEventPowerLevelsDTO | undefined { + if ( isMatrixEventPowerLevelsDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/createRoom/types/MatrixInvite3PidDTO.ts b/matrix/types/request/createRoom/types/MatrixInvite3PidDTO.ts new file mode 100644 index 0000000..50c53d8 --- /dev/null +++ b/matrix/types/request/createRoom/types/MatrixInvite3PidDTO.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { explain, explainProperty } from "../../../../../types/explain"; +import { explainString, isString } from "../../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixInvite3PidDTO { + readonly id_server : string; + readonly id_access_token : string; + readonly medium : string; + readonly address : string; +} + +export function isMatrixInvite3PidDTO (value: any): value is MatrixInvite3PidDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'id_server', + 'id_access_token', + 'medium', + 'address' + ]) + && isString(value?.id_server) + && isString(value?.id_access_token) + && isString(value?.medium) + && isString(value?.address) + ); +} + +export function explainMatrixInvite3PidDTO (value : any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'id_server', + 'id_access_token', + 'medium', + 'address' + ]), + explainProperty("id_server", explainString(value?.id_server)), + explainProperty("id_access_token", explainString(value?.id_access_token)), + explainProperty("medium", explainString(value?.medium)), + explainProperty("address", explainString(value?.address)) + ] + ); +} + +export function stringifyMatrixInvite3PidDTO (value: MatrixInvite3PidDTO): string { + return `MatrixInvite3PidDTO(${value})`; +} + +export function parseMatrixInvite3PidDTO (value: any): MatrixInvite3PidDTO | undefined { + if ( isMatrixInvite3PidDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/createRoom/types/MatrixNotificationPowerLevelsDTO.ts b/matrix/types/request/createRoom/types/MatrixNotificationPowerLevelsDTO.ts new file mode 100644 index 0000000..576dd41 --- /dev/null +++ b/matrix/types/request/createRoom/types/MatrixNotificationPowerLevelsDTO.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isUndefined } from "../../../../../types/undefined"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../../../types/explain"; +import { explainNumberOrUndefined, isNumberOrUndefined } from "../../../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixNotificationPowerLevelsDTO { + readonly room: number; +} + +export function isMatrixNotificationPowerLevelsDTO (value: any): value is MatrixNotificationPowerLevelsDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'room' + ]) + && isNumberOrUndefined(value?.room) + ); +} + +export function explainMatrixNotificationPowerLevelsDTO (value: any): string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'room' + ]), + explainProperty("room", explainNumberOrUndefined(value?.room)) + ] + ); +} + +export function isMatrixNotificationPowerLevelsDTOOrUndefined (value: any): value is MatrixNotificationPowerLevelsDTO | undefined { + return isUndefined(value) || isMatrixNotificationPowerLevelsDTO(value); +} + +export function explainMatrixNotificationPowerLevelsDTOOrUndefined (value: any): string { + return isMatrixNotificationPowerLevelsDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(["MatrixNotificationPowerLevelsDTO", "undefined"])); +} + +export function stringifyMatrixNotificationPowerLevelsDTO (value: MatrixNotificationPowerLevelsDTO): string { + return `MatrixNotificationPowerLevelsDTO(${value})`; +} + +export function parseMatrixNotificationPowerLevelsDTO (value: any): MatrixNotificationPowerLevelsDTO | undefined { + if ( isMatrixNotificationPowerLevelsDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/createRoom/types/MatrixRoomPowerLevelsEventDTO.ts b/matrix/types/request/createRoom/types/MatrixRoomPowerLevelsEventDTO.ts new file mode 100644 index 0000000..7136a75 --- /dev/null +++ b/matrix/types/request/createRoom/types/MatrixRoomPowerLevelsEventDTO.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixEventPowerLevelsDTO, isMatrixEventPowerLevelsDTOOrUndefined, explainMatrixEventPowerLevelsDTOOrUndefined } from "./MatrixEventPowerLevelsDTO"; +import { MatrixUserPowerLevelsDTO, isMatrixUserPowerLevelsDTOOrUndefined, explainMatrixUserPowerLevelsDTOOrUndefined } from "./MatrixUserPowerLevelsDTO"; +import { MatrixNotificationPowerLevelsDTO, isMatrixNotificationPowerLevelsDTOOrUndefined, explainMatrixNotificationPowerLevelsDTOOrUndefined } from "./MatrixNotificationPowerLevelsDTO"; +import { isUndefined } from "../../../../../types/undefined"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../../../types/explain"; +import { explainIntegerOrUndefined, isIntegerOrUndefined } from "../../../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixRoomPowerLevelsEventDTO { + readonly ban ?: number; + readonly events ?: MatrixEventPowerLevelsDTO; + readonly events_default ?: number; + readonly invite ?: number; + readonly kick ?: number; + readonly redact ?: number; + readonly state_default ?: number; + readonly users ?: MatrixUserPowerLevelsDTO; + readonly users_default ?: number; + readonly notifications ?: MatrixNotificationPowerLevelsDTO; +} + +export function isMatrixPowerLevelEventContentDTO (value: any): value is MatrixRoomPowerLevelsEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ban', + 'events', + 'events_default', + 'invite', + 'kick', + 'redact', + 'state_default', + 'users', + 'users_default', + 'notifications' + ]) + && isIntegerOrUndefined(value?.ban) + && isMatrixEventPowerLevelsDTOOrUndefined(value?.events) + && isIntegerOrUndefined(value?.events_default) + && isIntegerOrUndefined(value?.invite) + && isIntegerOrUndefined(value?.kick) + && isIntegerOrUndefined(value?.redact) + && isIntegerOrUndefined(value?.state_default) + && isMatrixUserPowerLevelsDTOOrUndefined(value?.users) + && isIntegerOrUndefined(value?.users_default) + && isMatrixNotificationPowerLevelsDTOOrUndefined(value?.notifications) + ); +} + +export function explainMatrixPowerLevelEventContentDTO (value : any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'ban', + 'events', + 'events_default', + 'invite', + 'kick', + 'redact', + 'state_default', + 'users', + 'users_default', + 'notifications' + ]), + explainProperty("ban", explainIntegerOrUndefined(value?.ban)), + explainProperty("events", explainMatrixEventPowerLevelsDTOOrUndefined(value?.events)), + explainProperty("events_default", explainIntegerOrUndefined(value?.events_default)), + explainProperty("invite", explainIntegerOrUndefined(value?.invite)), + explainProperty("kick", explainIntegerOrUndefined(value?.kick)), + explainProperty("redact", explainIntegerOrUndefined(value?.redact)), + explainProperty("state_default", explainIntegerOrUndefined(value?.state_default)), + explainProperty("users", explainMatrixUserPowerLevelsDTOOrUndefined(value?.users)), + explainProperty("users_default", explainIntegerOrUndefined(value?.users_default)), + explainProperty("notifications", explainMatrixNotificationPowerLevelsDTOOrUndefined(value?.notifications)) + ] + ); +} + +export function isMatrixPowerLevelEventContentDTOOrUndefined (value: any): value is MatrixRoomPowerLevelsEventDTO | undefined { + return isUndefined(value) || isMatrixPowerLevelEventContentDTO(value); +} + +export function explainMatrixPowerLevelEventContentDTOOrUndefined (value: any): string { + return isMatrixPowerLevelEventContentDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(["MatrixPowerLevelEventContentDTO", "undefined"])); +} + +export function stringifyMatrixPowerLevelEventContentDTO (value: MatrixRoomPowerLevelsEventDTO): string { + return `MatrixPowerLevelEventContentDTO(${value})`; +} + +export function parseMatrixPowerLevelEventContentDTO (value: any): MatrixRoomPowerLevelsEventDTO | undefined { + if ( isMatrixPowerLevelEventContentDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/createRoom/types/MatrixUserPowerLevelsDTO.ts b/matrix/types/request/createRoom/types/MatrixUserPowerLevelsDTO.ts new file mode 100644 index 0000000..f01a155 --- /dev/null +++ b/matrix/types/request/createRoom/types/MatrixUserPowerLevelsDTO.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixUserId, isMatrixUserId } from "../../../core/MatrixUserId"; +import { isUndefined } from "../../../../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../../../../types/explain"; +import { isInteger } from "../../../../../types/Number"; +import { isRegularObjectOf } from "../../../../../types/RegularObject"; + +export type MatrixUserPowerLevelsDTO = { + [K in MatrixUserId]: number +} + +export function isMatrixUserPowerLevelsDTO (value: any): value is MatrixUserPowerLevelsDTO { + return isRegularObjectOf(value, isMatrixUserId, isInteger); +} + +export function explainMatrixUserPowerLevelsDTO (value: any): string { + return isMatrixUserPowerLevelsDTO(value) ? explainOk() : explainNot("MatrixUserPowerLevelsDTO"); +} + +export function isMatrixUserPowerLevelsDTOOrUndefined (value: any): value is MatrixUserPowerLevelsDTO | undefined { + return isUndefined(value) || isMatrixUserPowerLevelsDTO(value); +} + +export function explainMatrixUserPowerLevelsDTOOrUndefined (value: any): string { + return isMatrixUserPowerLevelsDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(["MatrixUserPowerLevelsDTO", "undefined"])); +} + +export function stringifyMatrixUserPowerLevelsDTO (value: MatrixUserPowerLevelsDTO): string { + return `MatrixUserPowerLevelsDTO(${value})`; +} + +export function parseMatrixUserPowerLevelsDTO (value: any): MatrixUserPowerLevelsDTO | undefined { + if ( isMatrixUserPowerLevelsDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/createRoom/types/MatrixVisibility.ts b/matrix/types/request/createRoom/types/MatrixVisibility.ts new file mode 100644 index 0000000..576c396 --- /dev/null +++ b/matrix/types/request/createRoom/types/MatrixVisibility.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../../../../types/explain"; + +export enum MatrixVisibility { + PUBLIC = "public", + PRIVATE = "private" +} + +export function isMatrixVisibility (value: any): value is MatrixVisibility { + switch (value) { + case MatrixVisibility.PUBLIC: + case MatrixVisibility.PRIVATE: + return true; + default: + return false; + } +} + +export function explainMatrixVisibility (value: any): string { + return isMatrixVisibility(value) ? explainOk() : explainNot('MatrixVisibility'); +} + +export function isMatrixVisibilityOrUndefined (value: any): value is MatrixVisibility | undefined { + if (value === undefined) return true; + switch (value) { + case MatrixVisibility.PUBLIC: + case MatrixVisibility.PRIVATE: + return true; + default: + return false; + } +} + +export function explainMatrixVisibilityOrUndefined (value: any): string { + return isMatrixVisibilityOrUndefined(value) ? explainOk() : explainNot(explainOr(['MatrixVisibility', 'undefined'])); +} + +export function stringifyMatrixVisibility (value: MatrixVisibility): string { + switch (value) { + case MatrixVisibility.PUBLIC : + return 'public'; + case MatrixVisibility.PRIVATE : + return 'private'; + } + throw new TypeError(`Unsupported MatrixVisibility value: ${value}`); +} + +export function parseMatrixVisibility (value: any): MatrixVisibility | undefined { + if (value === undefined) return undefined; + switch (`${value}`.toUpperCase()) { + case 'PRIVATE' : return MatrixVisibility.PRIVATE; + case 'PUBLIC' : return MatrixVisibility.PUBLIC; + default : return undefined; + } +} + + diff --git a/matrix/types/request/inviteToRoom/MatrixInviteToRoomRequestDTO.ts b/matrix/types/request/inviteToRoom/MatrixInviteToRoomRequestDTO.ts new file mode 100644 index 0000000..e7aa098 --- /dev/null +++ b/matrix/types/request/inviteToRoom/MatrixInviteToRoomRequestDTO.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isMatrixUserId, MatrixUserId } from "../../core/MatrixUserId"; +import { isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixInviteToRoomRequestDTO { + readonly user_id : MatrixUserId; + readonly reason ?: string; +} + +export function createMatrixInviteToRoomRequestDTO ( + user_id : MatrixUserId, + reason : string | undefined +): MatrixInviteToRoomRequestDTO { + return { + reason, + user_id + }; +} + +export function isMatrixInviteToRoomRequestDTO (value: any): value is MatrixInviteToRoomRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'reason', + 'user_id' + ]) + && isMatrixUserId(value?.user_id) + && isStringOrUndefined(value?.reason) + ); +} + +export function stringifyMatrixInviteToRoomRequestDTO (value: MatrixInviteToRoomRequestDTO): string { + return `MatrixInviteToRoomRequestDTO(${value})`; +} + +export function parseMatrixInviteToRoomRequestDTO (value: any): MatrixInviteToRoomRequestDTO | undefined { + if ( isMatrixInviteToRoomRequestDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/request/joinRoom/MatrixJoinRoomRequestDTO.ts b/matrix/types/request/joinRoom/MatrixJoinRoomRequestDTO.ts new file mode 100644 index 0000000..c1ff4b8 --- /dev/null +++ b/matrix/types/request/joinRoom/MatrixJoinRoomRequestDTO.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixJoinRoomThirdPartySignedDTO, isMatrixJoinRoomThirdPartySignedDTO } from "./types/MatrixJoinRoomThirdPartySignedDTO"; +import { isUndefined } from "../../../../types/undefined"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixJoinRoomRequestDTO { + + readonly third_party_signed ?: MatrixJoinRoomThirdPartySignedDTO; + +} + +export function isMatrixJoinRoomRequestDTO (value: any): value is MatrixJoinRoomRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'third_party_signed' + ]) + && ( isUndefined(value?.third_party_signed) || isMatrixJoinRoomThirdPartySignedDTO(value?.third_party_signed) ) + ); +} + +export function stringifyMatrixJoinRoomRequestDTO (value: MatrixJoinRoomRequestDTO): string { + return `MatrixJoinRoomRequestDTO(${value})`; +} + +export function parseMatrixJoinRoomRequestDTO (value: any): MatrixJoinRoomRequestDTO | undefined { + if ( isMatrixJoinRoomRequestDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/joinRoom/types/MatrixJoinRoomThirdPartySignedDTO.ts b/matrix/types/request/joinRoom/types/MatrixJoinRoomThirdPartySignedDTO.ts new file mode 100644 index 0000000..24a0815 --- /dev/null +++ b/matrix/types/request/joinRoom/types/MatrixJoinRoomThirdPartySignedDTO.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixUserId, isMatrixUserId } from "../../../core/MatrixUserId"; +import { isJsonObject, JsonObject } from "../../../../../Json"; +import { isString } from "../../../../../types/String"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixJoinRoomThirdPartySignedDTO { + + readonly sender : MatrixUserId; + readonly mxid : MatrixUserId; + readonly token : string; + + // TODO: define MatrixSignaturesDTO + readonly signatures : JsonObject; + +} + +export function isMatrixJoinRoomThirdPartySignedDTO (value: any): value is MatrixJoinRoomThirdPartySignedDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'sender', + 'mxid', + 'token', + 'signatures' + ]) + && isMatrixUserId(value?.sender) + && isMatrixUserId(value?.mxid) + && isString(value?.token) + && isJsonObject(value?.signatures) + ); +} + +export function stringifyMatrixJoinRoomThirdPartySignedDTO (value: MatrixJoinRoomThirdPartySignedDTO): string { + return `MatrixJoinRoomThirdPartySignedDTO(${value})`; +} + +export function parseMatrixJoinRoomThirdPartySignedDTO (value: any): MatrixJoinRoomThirdPartySignedDTO | undefined { + if ( isMatrixJoinRoomThirdPartySignedDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/leaveRoom/MatrixLeaveRoomRequestDTO.ts b/matrix/types/request/leaveRoom/MatrixLeaveRoomRequestDTO.ts new file mode 100644 index 0000000..7fef122 --- /dev/null +++ b/matrix/types/request/leaveRoom/MatrixLeaveRoomRequestDTO.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixLeaveRoomRequestDTO { + readonly reason ?: string; +} + +export function createMatrixLeaveRoomRequestDTO ( + reason : string | undefined +): MatrixLeaveRoomRequestDTO { + return { + reason + }; +} + +export function isMatrixLeaveRoomRequestDTO (value: any): value is MatrixLeaveRoomRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'reason' + ]) + && isStringOrUndefined(value?.reason) + ); +} + +export function stringifyMatrixLeaveRoomRequestDTO (value: MatrixLeaveRoomRequestDTO): string { + return `MatrixLeaveRoomRequestDTO(${value})`; +} + +export function parseMatrixLeaveRoomRequestDTO (value: any): MatrixLeaveRoomRequestDTO | undefined { + if ( isMatrixLeaveRoomRequestDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/request/login/MatrixLoginRequestDTO.ts b/matrix/types/request/login/MatrixLoginRequestDTO.ts new file mode 100644 index 0000000..f21ac6d --- /dev/null +++ b/matrix/types/request/login/MatrixLoginRequestDTO.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isMatrixIdentifierDTO, MatrixIdentifierDTO } from "./types/MatrixIdentifierDTO"; +import { isMatrixLoginType, MatrixLoginType } from "./MatrixLoginType"; +import { MatrixUserId } from "../../core/MatrixUserId"; +import { isUndefined } from "../../../../types/undefined"; +import { isString, isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixLoginRequestDTO { + + readonly type : MatrixLoginType; + readonly identifier ?: MatrixIdentifierDTO; + + /** + * Required when type is MatrixLoginType.M_LOGIN_PASSWORD + */ + readonly password ?: string; + + readonly device_id ?: string; + + /** + * Use `identifier` + * @deprecated + */ + readonly address ?: string; + + readonly initial_device_display_name ?: string; + + /** + * Use `identifier` + * @deprecated + */ + readonly medium ?: string; + + /** + * Required when type is MatrixLoginType.M_LOGIN_TOKEN + */ + readonly token ?: string; + + /** + * Use `identifier` + * @deprecated + */ + readonly user ?: MatrixUserId | string; + +} + +export function isMatrixLoginRequestDTO (value: any): value is MatrixLoginRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'type', + 'identifier', + 'password', + 'device_id', + 'address', + 'initial_device_display_name', + 'medium', + 'token', + 'user' + ]) + && isMatrixLoginType(value?.type) + && (isUndefined(value?.identifier) || isMatrixIdentifierDTO(value?.identifier)) + && ( value?.type === MatrixLoginType.M_LOGIN_PASSWORD ? isString(value?.password) : isStringOrUndefined(value?.password) ) + && isStringOrUndefined(value?.device_id) + && isStringOrUndefined(value?.address) + && isStringOrUndefined(value?.initial_device_display_name) + && isStringOrUndefined(value?.medium) + && ( value?.type === MatrixLoginType.M_LOGIN_TOKEN ? isString(value?.token) : isStringOrUndefined(value?.token) ) + && isStringOrUndefined(value?.user) + ); +} + +export function stringifyMatrixLoginRequestDTO (value: MatrixLoginRequestDTO): string { + return `MatrixLoginRequestDTO(${value})`; +} + +export function parseMatrixLoginRequestDTO (value: any): MatrixLoginRequestDTO | undefined { + if ( isMatrixLoginRequestDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/request/login/MatrixLoginType.ts b/matrix/types/request/login/MatrixLoginType.ts new file mode 100644 index 0000000..9680fd2 --- /dev/null +++ b/matrix/types/request/login/MatrixLoginType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +/** + * Part of the login end point request `MatrixPasswordLoginDTO`. + * + * @see https://github.com/heusalagroup/hghs/issues/3 + */ +export enum MatrixLoginType { + M_LOGIN_PASSWORD = 'm.login.password', + M_LOGIN_TOKEN = 'm.login.token' +} + +export function isMatrixLoginType (value: any): value is MatrixLoginType { + switch (value) { + case MatrixLoginType.M_LOGIN_PASSWORD: + case MatrixLoginType.M_LOGIN_TOKEN: + return true; + + default: + return false; + + } +} + +export function stringifyMatrixLoginType (value: MatrixLoginType): string { + switch (value) { + case MatrixLoginType.M_LOGIN_PASSWORD : return 'm.login.password'; + case MatrixLoginType.M_LOGIN_TOKEN : return 'm.login.token'; + } + throw new TypeError(`Unsupported MatrixLoginType value: ${value}`); +} + +export function parseMatrixLoginType (value: any): MatrixLoginType | undefined { + + switch (`${value}`.toLowerCase()) { + + case 'm.login.password': + case 'm_login_password' : return MatrixLoginType.M_LOGIN_PASSWORD; + + case 'm.login.token': + case 'm_login_token' : return MatrixLoginType.M_LOGIN_TOKEN; + + default : return undefined; + + } + +} diff --git a/matrix/types/request/login/types/MatrixIdentifierDTO.ts b/matrix/types/request/login/types/MatrixIdentifierDTO.ts new file mode 100644 index 0000000..96499fe --- /dev/null +++ b/matrix/types/request/login/types/MatrixIdentifierDTO.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixType } from "../../../core/MatrixType"; +import { MatrixUserId } from "../../../core/MatrixUserId"; +import { isString } from "../../../../../types/String"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixIdentifierDTO { + readonly type: MatrixType.M_ID_USER; + readonly user: string | MatrixUserId; +} + +export function createMatrixIdentifierDTO ( + user: string | MatrixUserId +): MatrixIdentifierDTO { + return { + type: MatrixType.M_ID_USER, + user + }; +} + +export function isMatrixIdentifierDTO (value: any): value is MatrixIdentifierDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'user', + 'type' + ]) + && value?.type === MatrixType.M_ID_USER + && isString(value?.user) + ); +} + +export function stringifyMatrixIdentifierDTO (value: MatrixIdentifierDTO): string { + return `MatrixIdentifierDTO(${value})`; +} + +export function parseMatrixIdentifierDTO (value: any): MatrixIdentifierDTO | undefined { + if ( isMatrixIdentifierDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/request/passwordLogin/MatrixPasswordLoginRequestDTO.ts b/matrix/types/request/passwordLogin/MatrixPasswordLoginRequestDTO.ts new file mode 100644 index 0000000..7a8828c --- /dev/null +++ b/matrix/types/request/passwordLogin/MatrixPasswordLoginRequestDTO.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixIdentifierDTO } from "../login/types/MatrixIdentifierDTO"; +import { MatrixLoginType } from "../login/MatrixLoginType"; +import { isMatrixLoginRequestDTO, MatrixLoginRequestDTO } from "../login/MatrixLoginRequestDTO"; + +export interface MatrixPasswordLoginRequestDTO extends MatrixLoginRequestDTO { + readonly type : MatrixLoginType.M_LOGIN_PASSWORD; + readonly identifier : MatrixIdentifierDTO; + readonly password : string; +} + +export function createMatrixPasswordLoginRequestDTO ( + identifier : MatrixIdentifierDTO, + password : string +): MatrixPasswordLoginRequestDTO { + return { + type: MatrixLoginType.M_LOGIN_PASSWORD, + identifier, + password + }; +} + +export function isMatrixPasswordLoginRequestDTO (value: any): value is MatrixPasswordLoginRequestDTO { + return ( + isMatrixLoginRequestDTO(value) + && value?.type === MatrixLoginType.M_LOGIN_PASSWORD + ); +} + +export function stringifyMatrixPasswordLoginRequestDTO (value: MatrixPasswordLoginRequestDTO): string { + return `MatrixPasswordLoginDTO(${value})`; +} + +export function parseMatrixPasswordLoginRequestDTO (value: any): MatrixPasswordLoginRequestDTO | undefined { + if ( isMatrixPasswordLoginRequestDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/request/register/MatrixRegisterRequestDTO.ts b/matrix/types/request/register/MatrixRegisterRequestDTO.ts new file mode 100644 index 0000000..06bd787 --- /dev/null +++ b/matrix/types/request/register/MatrixRegisterRequestDTO.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixRegisterAuthenticationData, isMatrixRegisterAuthenticationData } from "./types/MatrixRegisterAuthenticationData"; +import { isUndefined } from "../../../../types/undefined"; +import { isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixRegisterRequestDTO { + readonly auth ?: MatrixRegisterAuthenticationData; + readonly username ?: string; + readonly password ?: string; + readonly device_id ?: string; + readonly initial_device_display_name ?: string; + readonly inhibit_login ?: boolean; +} + +export function createMatrixRegisterRequestDTO ( + auth ?: MatrixRegisterAuthenticationData, + username ?: string, + password ?: string, + device_id ?: string, + initial_device_display_name ?: string, + inhibit_login ?: boolean +) : MatrixRegisterRequestDTO { + return { + auth, + username, + password, + device_id, + initial_device_display_name, + inhibit_login + }; +} + +export function isMatrixMatrixRegisterRequestDTO (value: any): value is MatrixRegisterRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'auth', + 'username', + 'password', + 'device_id', + 'initial_device_display_name', + 'inhibit_login' + ]) + && ( isUndefined(value?.auth) || isMatrixRegisterAuthenticationData(value?.auth) ) + && isStringOrUndefined(value?.username) + && isStringOrUndefined(value?.password) + && isStringOrUndefined(value?.device_id) + && isStringOrUndefined(value?.initial_device_display_name) + && isStringOrUndefined(value?.inhibit_login) + ); +} + +export function stringifyMatrixMatrixRegisterRequestDTO (value: MatrixRegisterRequestDTO): string { + return `MatrixMatrixRegisterDTO(${value})`; +} + +export function parseMatrixMatrixRegisterRequestDTO (value: any): MatrixRegisterRequestDTO | undefined { + if ( isMatrixMatrixRegisterRequestDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/register/types/MatrixRegisterAuthenticationData.ts b/matrix/types/request/register/types/MatrixRegisterAuthenticationData.ts new file mode 100644 index 0000000..0d8d565 --- /dev/null +++ b/matrix/types/request/register/types/MatrixRegisterAuthenticationData.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString, isStringOrUndefined } from "../../../../../types/String"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixRegisterAuthenticationData { + readonly type : string; + readonly session ?: string; +} + +export function isMatrixRegisterAuthenticationData (value: any): value is MatrixRegisterAuthenticationData { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'type', + 'session' + ]) + && isString(value?.type) + && isStringOrUndefined(value?.session) + ); +} + +export function stringifyMatrixRegisterAuthenticationData (value: MatrixRegisterAuthenticationData): string { + return `MatrixRegisterAuthenticationData(${value})`; +} + +export function parseMatrixRegisterAuthenticationData (value: any): MatrixRegisterAuthenticationData | undefined { + if ( isMatrixRegisterAuthenticationData(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/request/register/types/MatrixRegisterKind.ts b/matrix/types/request/register/types/MatrixRegisterKind.ts new file mode 100644 index 0000000..fc5e374 --- /dev/null +++ b/matrix/types/request/register/types/MatrixRegisterKind.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum MatrixRegisterKind { + GUEST = "guest", + USER = "user" +} + +export function isMatrixRegisterKind (value: any): value is MatrixRegisterKind { + switch (value) { + case MatrixRegisterKind.GUEST: + case MatrixRegisterKind.USER: + return true; + + default: + return false; + + } +} + +export function stringifyMatrixRegisterKind (value: MatrixRegisterKind): string { + switch (value) { + case MatrixRegisterKind.GUEST : return 'guest'; + case MatrixRegisterKind.USER : return 'user'; + } + throw new TypeError(`Unsupported MatrixRegisterKind value: ${value}`); +} + +export function parseMatrixRegisterKind (value: any): MatrixRegisterKind | undefined { + + if (value === undefined) return undefined; + + switch (`${value}`.toLowerCase()) { + + case 'guest' : return MatrixRegisterKind.GUEST; + case 'user' : return MatrixRegisterKind.USER; + default : return undefined; + + } + +} + + diff --git a/matrix/types/request/setRoomStateByType/SetRoomStateByTypeRequestDTO.ts b/matrix/types/request/setRoomStateByType/SetRoomStateByTypeRequestDTO.ts new file mode 100644 index 0000000..18bb418 --- /dev/null +++ b/matrix/types/request/setRoomStateByType/SetRoomStateByTypeRequestDTO.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isReadonlyJsonObject, ReadonlyJsonObject } from "../../../../Json"; +import { isUndefined } from "../../../../types/undefined"; +import { isBooleanOrUndefined } from "../../../../types/Boolean"; +import { isStringOrUndefined } from "../../../../types/String"; +import { isNumberOrUndefined } from "../../../../types/Number"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface SetRoomStateByTypeRequestDTO { + readonly avatar_url ?: string; + readonly displayname ?: string; + readonly membership ?: string; + readonly version ?: number; + readonly data ?: ReadonlyJsonObject; + readonly deleted ?: boolean; +} + +export function createSetRoomStateByTypeRequestDTO ( + avatar_url ?: string, + displayname ?: string, + membership ?: string, + version ?: number, + data ?: ReadonlyJsonObject, + deleted ?: boolean +): SetRoomStateByTypeRequestDTO { + return { + avatar_url, + displayname, + membership, + version, + data, + deleted + }; +} + +export function isSetRoomStateByTypeRequestDTO (value: any): value is SetRoomStateByTypeRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'avatar_url', + 'displayname', + 'membership', + 'version', + 'data', + 'deleted' + ]) + && isStringOrUndefined(value?.avatar_url) + && isStringOrUndefined(value?.displayname) + && isStringOrUndefined(value?.membership) + && isNumberOrUndefined(value?.version) + && (isUndefined(value?.data) || isReadonlyJsonObject(value?.data)) + && isBooleanOrUndefined(value?.deleted) + ); +} + +export function stringifySetRoomStateByTypeRequestDTO (value: SetRoomStateByTypeRequestDTO): string { + return `SetRoomStateByTypeRequestDTO(${value})`; +} + +export function parseSetRoomStateByTypeRequestDTO (value: any): SetRoomStateByTypeRequestDTO | undefined { + if ( isSetRoomStateByTypeRequestDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/request/sync/types/MatrixSyncPresence.ts b/matrix/types/request/sync/types/MatrixSyncPresence.ts new file mode 100644 index 0000000..82dca89 --- /dev/null +++ b/matrix/types/request/sync/types/MatrixSyncPresence.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum MatrixSyncPresence { + OFFLINE = "offline", + ONLINE = "online", + UNAVAILABLE = "unavailable" +} + +export function isMatrixSyncPresence (value: any): value is MatrixSyncPresence { + switch (value) { + case MatrixSyncPresence.OFFLINE: + case MatrixSyncPresence.ONLINE: + case MatrixSyncPresence.UNAVAILABLE: + return true; + + default: + return false; + + } +} + +export function stringifyMatrixSyncPresence (value: MatrixSyncPresence): string { + switch (value) { + case MatrixSyncPresence.OFFLINE : return 'offline'; + case MatrixSyncPresence.ONLINE : return 'online'; + case MatrixSyncPresence.UNAVAILABLE : return 'unavailable'; + } + throw new TypeError(`Unsupported MatrixSyncPresence value: ${value}`); +} + +export function parseMatrixSyncPresence (value: any): MatrixSyncPresence | undefined { + + switch (value) { + + case 'OFFLINE' : return MatrixSyncPresence.OFFLINE; + case 'ONLINE' : return MatrixSyncPresence.ONLINE; + case 'UNAVAILABLE' : return MatrixSyncPresence.UNAVAILABLE; + + default : + return undefined; + + } + +} + + diff --git a/matrix/types/request/tokenLogin/MatrixTokenLoginDTO.ts b/matrix/types/request/tokenLogin/MatrixTokenLoginDTO.ts new file mode 100644 index 0000000..86269df --- /dev/null +++ b/matrix/types/request/tokenLogin/MatrixTokenLoginDTO.ts @@ -0,0 +1,8 @@ +import { MatrixType } from "../../core/MatrixType"; + +export interface MatrixTokenLoginDTO { + + readonly type: MatrixType.M_LOGIN_TOKEN; + readonly token: string; + +} diff --git a/matrix/types/response/createRoom/MatrixCreateRoomResponseDTO.ts b/matrix/types/response/createRoom/MatrixCreateRoomResponseDTO.ts new file mode 100644 index 0000000..013e55e --- /dev/null +++ b/matrix/types/response/createRoom/MatrixCreateRoomResponseDTO.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixRoomId, isMatrixRoomId } from "../../core/MatrixRoomId"; +import { MatrixRoomAlias, isMatrixRoomAlias } from "../../core/MatrixRoomAlias"; +import { isUndefined } from "../../../../types/undefined"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixCreateRoomResponseDTO { + readonly room_id : MatrixRoomId; + readonly room_alias ?: MatrixRoomAlias; +} + +export function createMatrixCreateRoomResponseDTO ( + room_id : MatrixRoomId, + room_alias ?: MatrixRoomAlias +) { + return { + room_id, + room_alias + }; +} + +export function isMatrixCreateRoomResponseDTO (value: any): value is MatrixCreateRoomResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'room_id', + 'room_alias' + ]) + && isMatrixRoomId(value?.room_id) + && (isUndefined(value?.room_alias) || isMatrixRoomAlias(value?.room_alias)) + ); +} + +export function stringifyMatrixCreateRoomResponseDTO (value: MatrixCreateRoomResponseDTO): string { + return `MatrixCreateRoomResponseDTO(${value})`; +} + +export function parseMatrixCreateRoomResponseDTO (value: any): MatrixCreateRoomResponseDTO | undefined { + if ( isMatrixCreateRoomResponseDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/directoryRoomAlias/GetDirectoryRoomAliasResponseDTO.ts b/matrix/types/response/directoryRoomAlias/GetDirectoryRoomAliasResponseDTO.ts new file mode 100644 index 0000000..20bcdb7 --- /dev/null +++ b/matrix/types/response/directoryRoomAlias/GetDirectoryRoomAliasResponseDTO.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../../types/String"; +import { isStringArray } from "../../../../types/StringArray"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface GetDirectoryRoomAliasResponseDTO { + readonly room_id : string; + readonly servers : readonly string[]; +} + +export function createGetDirectoryRoomAliasResponseDTO ( + room_id : string, + servers : readonly string[] +) : GetDirectoryRoomAliasResponseDTO { + return { + room_id, + servers + }; +} + +export function isGetDirectoryRoomAliasResponseDTO (value: any): value is GetDirectoryRoomAliasResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'room_id', + 'servers' + ]) + && isString(value?.room_id) + && isStringArray(value?.servers) + ); +} + +export function stringifyGetDirectoryRoomAliasResponseDTO (value: GetDirectoryRoomAliasResponseDTO): string { + return `GetDirectoryRoomAliasResponseDTO(${value})`; +} + +export function parseGetDirectoryRoomAliasResponseDTO (value: any): GetDirectoryRoomAliasResponseDTO | undefined { + if ( isGetDirectoryRoomAliasResponseDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/error/MatrixErrorDTO.ts b/matrix/types/response/error/MatrixErrorDTO.ts new file mode 100644 index 0000000..21b4faf --- /dev/null +++ b/matrix/types/response/error/MatrixErrorDTO.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixErrorCode, isMatrixErrorCode } from "./types/MatrixErrorCode"; +import { isString } from "../../../../types/String"; +import { isNumberOrUndefined } from "../../../../types/Number"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixErrorDTO { + readonly errcode : MatrixErrorCode; + readonly error : string; + readonly retry_after_ms ?: number; +} + +export function createMatrixErrorDTO ( + errcode : MatrixErrorCode, + error : string, + retry_after_ms ?: number +) : MatrixErrorDTO { + return { + errcode, + error, + retry_after_ms + }; +} + +export function isMatrixErrorDTO (value: any): value is MatrixErrorDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'errcode', + 'error', + 'retry_after_ms' + ]) + && isMatrixErrorCode(value?.errcode) + && isString(value?.error) + && isNumberOrUndefined(value?.retry_after_ms) + ); +} + +export function stringifyMatrixErrorDTO (value: MatrixErrorDTO): string { + return `MatrixErrorDTO(${value})`; +} + +export function parseMatrixErrorDTO (value: any): MatrixErrorDTO | undefined { + if ( isMatrixErrorDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/error/types/MatrixErrorCode.ts b/matrix/types/response/error/types/MatrixErrorCode.ts new file mode 100644 index 0000000..0c6e053 --- /dev/null +++ b/matrix/types/response/error/types/MatrixErrorCode.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum MatrixErrorCode { + M_USER_IN_USE = "M_USER_IN_USE", + M_UNKNOWN = "M_UNKNOWN", + M_UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN", + M_INVALID_USERNAME = "M_INVALID_USERNAME", + M_EXCLUSIVE = "M_EXCLUSIVE", + M_FORBIDDEN = "M_FORBIDDEN", + M_LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED" +} + +export function isMatrixErrorCode (value: any): value is MatrixErrorCode { + switch (value) { + case MatrixErrorCode.M_UNKNOWN: + case MatrixErrorCode.M_UNKNOWN_TOKEN: + case MatrixErrorCode.M_USER_IN_USE: + case MatrixErrorCode.M_INVALID_USERNAME: + case MatrixErrorCode.M_EXCLUSIVE: + case MatrixErrorCode.M_FORBIDDEN: + case MatrixErrorCode.M_LIMIT_EXCEEDED: + return true; + default: + return false; + } +} + +export function stringifyMatrixErrorCode (value: MatrixErrorCode): string { + switch (value) { + case MatrixErrorCode.M_UNKNOWN : return 'M_UNKNOWN'; + case MatrixErrorCode.M_UNKNOWN_TOKEN : return 'M_UNKNOWN_TOKEN'; + case MatrixErrorCode.M_USER_IN_USE : return 'M_USER_IN_USE'; + case MatrixErrorCode.M_INVALID_USERNAME : return 'M_INVALID_USERNAME'; + case MatrixErrorCode.M_EXCLUSIVE : return 'M_EXCLUSIVE'; + case MatrixErrorCode.M_FORBIDDEN : return 'M_FORBIDDEN'; + case MatrixErrorCode.M_LIMIT_EXCEEDED : return 'M_LIMIT_EXCEEDED'; + } + throw new TypeError(`Unsupported MatrixErrorCode value: ${value}`); +} + +export function parseMatrixErrorCode (value: any): MatrixErrorCode | undefined { + switch (value) { + case 'M_UNKNOWN' : return MatrixErrorCode.M_UNKNOWN; + case 'M_UNKNOWN_TOKEN' : return MatrixErrorCode.M_UNKNOWN_TOKEN; + case 'M_USER_IN_USE' : return MatrixErrorCode.M_USER_IN_USE; + case 'M_INVALID_USERNAME' : return MatrixErrorCode.M_INVALID_USERNAME; + case 'M_EXCLUSIVE' : return MatrixErrorCode.M_EXCLUSIVE; + case 'M_FORBIDDEN' : return MatrixErrorCode.M_FORBIDDEN; + case 'M_LIMIT_EXCEEDED' : return MatrixErrorCode.M_LIMIT_EXCEEDED; + default : return undefined; + } +} diff --git a/matrix/types/response/getRoomStateByType/GetRoomStateByTypeResponseDTO.ts b/matrix/types/response/getRoomStateByType/GetRoomStateByTypeResponseDTO.ts new file mode 100644 index 0000000..9335703 --- /dev/null +++ b/matrix/types/response/getRoomStateByType/GetRoomStateByTypeResponseDTO.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isReadonlyJsonObject, ReadonlyJsonObject } from "../../../../Json"; +import { isUndefined } from "../../../../types/undefined"; +import { isStringOrUndefined } from "../../../../types/String"; +import { isNumberOrUndefined } from "../../../../types/Number"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface GetRoomStateByTypeResponseDTO { + readonly name ?: string; + readonly version ?: number; + readonly data ?: ReadonlyJsonObject; +} + +export function createGetRoomStateByTypeResponseDTO ( + name ?: string, + version ?: number, + data ?: ReadonlyJsonObject +): GetRoomStateByTypeResponseDTO { + return { + name, + version, + data + }; +} + +export function isGetRoomStateByTypeResponseDTO (value: any): value is GetRoomStateByTypeResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'name', + 'version', + 'data' + ]) + && isStringOrUndefined(value?.name) + && isNumberOrUndefined(value?.version) + && (isUndefined(value?.data) || isReadonlyJsonObject(value?.data)) + ); +} + +export function stringifyGetRoomStateByTypeResponseDTO (value: GetRoomStateByTypeResponseDTO): string { + return `GetRoomStateByTypeResponseDTO(${value})`; +} + +export function parseGetRoomStateByTypeResponseDTO (value: any): GetRoomStateByTypeResponseDTO | undefined { + if ( isGetRoomStateByTypeResponseDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/response/inviteToRoom/MatrixInviteToRoomResponseDTO.ts b/matrix/types/response/inviteToRoom/MatrixInviteToRoomResponseDTO.ts new file mode 100644 index 0000000..423f926 --- /dev/null +++ b/matrix/types/response/inviteToRoom/MatrixInviteToRoomResponseDTO.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixInviteToRoomResponseDTO { +} + +export function createMatrixInviteToRoomResponseDTO (): MatrixInviteToRoomResponseDTO { + return {}; +} + +export function isMatrixInviteToRoomResponseDTO (value: any): value is MatrixInviteToRoomResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, []) + ); +} + +export function stringifyMatrixInviteToRoomResponseDTO (value: MatrixInviteToRoomResponseDTO): string { + return `MatrixInviteToRoomResponseDTO(${value})`; +} + +export function parseMatrixInviteToRoomResponseDTO (value: any): MatrixInviteToRoomResponseDTO | undefined { + if ( isMatrixInviteToRoomResponseDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/response/joinRoom/MatrixJoinRoomResponseDTO.ts b/matrix/types/response/joinRoom/MatrixJoinRoomResponseDTO.ts new file mode 100644 index 0000000..254ddc9 --- /dev/null +++ b/matrix/types/response/joinRoom/MatrixJoinRoomResponseDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixRoomId, isMatrixRoomId } from "../../core/MatrixRoomId"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixJoinRoomResponseDTO { + readonly room_id: MatrixRoomId; +} + +export function createMatrixJoinRoomResponseDTO ( + room_id: MatrixRoomId +) : MatrixJoinRoomResponseDTO { + return { + room_id + }; +} + +export function isMatrixJoinRoomResponseDTO (value: any): value is MatrixJoinRoomResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'room_id' + ]) + && isMatrixRoomId(value?.room_id) + ); +} + +export function stringifyMatrixJoinRoomResponseDTO (value: MatrixJoinRoomResponseDTO): string { + return `MatrixJoinRoomResponseDTO(${value})`; +} + +export function parseMatrixJoinRoomResponseDTO (value: any): MatrixJoinRoomResponseDTO | undefined { + if ( isMatrixJoinRoomResponseDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/leaveRoom/MatrixLeaveRoomResponseDTO.ts b/matrix/types/response/leaveRoom/MatrixLeaveRoomResponseDTO.ts new file mode 100644 index 0000000..f81f4c1 --- /dev/null +++ b/matrix/types/response/leaveRoom/MatrixLeaveRoomResponseDTO.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixLeaveRoomResponseDTO { +} + +export function createMatrixLeaveRoomResponseDTO (): MatrixLeaveRoomResponseDTO { + return {}; +} + +export function isMatrixLeaveRoomResponseDTO (value: any): value is MatrixLeaveRoomResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, []) + ); +} + +export function stringifyMatrixLeaveRoomResponseDTO (value: MatrixLeaveRoomResponseDTO): string { + return `MatrixLeaveRoomResponseDTO(${value})`; +} + +export function parseMatrixLeaveRoomResponseDTO (value: any): MatrixLeaveRoomResponseDTO | undefined { + if ( isMatrixLeaveRoomResponseDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/response/login/MatrixLoginResponseDTO.ts b/matrix/types/response/login/MatrixLoginResponseDTO.ts new file mode 100644 index 0000000..3b7825c --- /dev/null +++ b/matrix/types/response/login/MatrixLoginResponseDTO.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { + MatrixDiscoveryInformationDTO, + isMatrixDiscoveryInformationDTO +} from "./types/MatrixDiscoveryInformationDTO"; +import { MatrixUserId } from "../../core/MatrixUserId"; +import { isUndefined } from "../../../../types/undefined"; +import { isString, isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixLoginResponseDTO { + + readonly user_id : MatrixUserId | string; + readonly access_token : string; + + /** + * Clients should extract the server_name from user_id + * @deprecated + */ + readonly home_server ?: string; + + readonly device_id : string; + readonly well_known ?: MatrixDiscoveryInformationDTO; + +} + +export function createMatrixLoginResponseDTO ( + user_id : MatrixUserId | string, + access_token : string, + home_server : string | undefined, + device_id : string, + well_known : MatrixDiscoveryInformationDTO | undefined +) : MatrixLoginResponseDTO { + return { + user_id, + access_token, + home_server, + device_id, + well_known + }; +} + +export function isMatrixLoginResponseDTO (value: any): value is MatrixLoginResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, ['user_id', 'access_token', 'home_server', 'device_id', 'well_known']) + && isString(value?.user_id) + && isString(value?.access_token) + && isStringOrUndefined(value?.home_server) + && isStringOrUndefined(value?.device_id) + && ( isUndefined(value?.MatrixWellKnownDTO) || isMatrixDiscoveryInformationDTO(value)) + ); +} + +export function stringifyMatrixLoginResponseDTO (value: MatrixLoginResponseDTO): string { + if ( !isMatrixLoginResponseDTO(value) ) throw new TypeError(`Not MatrixLoginResponseDTO: ${value}`); + return `MatrixLoginResponseDTO(${value})`; +} + +export function parseMatrixLoginResponseDTO (value: any): MatrixLoginResponseDTO | undefined { + if ( isMatrixLoginResponseDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/login/types/MatrixDiscoveryInformationDTO.ts b/matrix/types/response/login/types/MatrixDiscoveryInformationDTO.ts new file mode 100644 index 0000000..ef9b136 --- /dev/null +++ b/matrix/types/response/login/types/MatrixDiscoveryInformationDTO.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { + isMatrixIdentityServerInformationDTO, + MatrixIdentityServerInformationDTO +} from "./MatrixIdentityServerInformationDTO"; +import { isMatrixHomeServerDTO, MatrixHomeServerDTO } from "./MatrixHomeServerDTO"; +import { MatrixType } from "../../../core/MatrixType"; +import { isUndefined } from "../../../../../types/undefined"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixDiscoveryInformationDTO { + readonly [MatrixType.M_HOMESERVER]: MatrixHomeServerDTO; + readonly [MatrixType.M_IDENTITY_SERVER]: MatrixIdentityServerInformationDTO; +} + +export function createMatrixDiscoveryInformationDTO ( + homeserverDto: MatrixHomeServerDTO, + identityServerDto: MatrixIdentityServerInformationDTO +) : MatrixDiscoveryInformationDTO { + return { + [MatrixType.M_HOMESERVER]: homeserverDto, + [MatrixType.M_IDENTITY_SERVER]: identityServerDto + }; +} + +export function isMatrixDiscoveryInformationDTO (value: any): value is MatrixDiscoveryInformationDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [MatrixType.M_HOMESERVER, MatrixType.M_IDENTITY_SERVER]) + && isMatrixHomeServerDTO(value[MatrixType.M_HOMESERVER]) + && ( isUndefined(value[MatrixType.M_IDENTITY_SERVER]) || isMatrixIdentityServerInformationDTO(value[MatrixType.M_IDENTITY_SERVER]) ) + ); +} + +export function stringifyMatrixDiscoveryInformationDTO (value: MatrixDiscoveryInformationDTO): string { + if ( !isMatrixDiscoveryInformationDTO(value) ) throw new TypeError(`Not MatrixWellKnownDTO: ${value}`); + return `MatrixWellKnownDTO(${value})`; +} + +export function parseMatrixDiscoveryInformationDTO (value: any): MatrixDiscoveryInformationDTO | undefined { + if ( isMatrixDiscoveryInformationDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/login/types/MatrixHomeServerDTO.ts b/matrix/types/response/login/types/MatrixHomeServerDTO.ts new file mode 100644 index 0000000..0b7f888 --- /dev/null +++ b/matrix/types/response/login/types/MatrixHomeServerDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../../../types/String"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixHomeServerDTO { + readonly base_url: string; +} + +export function createMatrixHomeServerDTO ( + base_url: string +) : MatrixHomeServerDTO { + return { + base_url + }; +} + +export function isMatrixHomeServerDTO (value: any): value is MatrixHomeServerDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, ['base_url']) + && isString(value?.base_url) + ); +} + +export function stringifyMatrixHomeServerDTO (value: MatrixHomeServerDTO): string { + if ( !isMatrixHomeServerDTO(value) ) throw new TypeError(`Not MatrixHomeServerDTO: ${value}`); + return `MatrixHomeServerDTO(${value})`; +} + +export function parseMatrixHomeServerDTO (value: any): MatrixHomeServerDTO | undefined { + if ( isMatrixHomeServerDTO(value) ) return value; + return undefined; +} + + + diff --git a/matrix/types/response/login/types/MatrixIdentityServerInformationDTO.ts b/matrix/types/response/login/types/MatrixIdentityServerInformationDTO.ts new file mode 100644 index 0000000..1b4d8b7 --- /dev/null +++ b/matrix/types/response/login/types/MatrixIdentityServerInformationDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../../../types/String"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixIdentityServerInformationDTO { + readonly base_url: string; +} + +export function createMatrixIdentityServerInformationDTO ( + base_url: string +) : MatrixIdentityServerInformationDTO { + return { + base_url + }; +} + +export function isMatrixIdentityServerInformationDTO (value: any): value is MatrixIdentityServerInformationDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, ['base_url']) + && isString(value?.base_url) + ); +} + +export function stringifyMatrixIdentityServerInformationDTO (value: MatrixIdentityServerInformationDTO): string { + if ( !isMatrixIdentityServerInformationDTO(value) ) throw new TypeError(`Not MatrixIdentityServerInformationDTO: ${value}`); + return `MatrixIdentityServerInformationDTO(${value})`; +} + +export function parseMatrixIdentityServerInformationDTO (value: any): MatrixIdentityServerInformationDTO | undefined { + if ( isMatrixIdentityServerInformationDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/register/MatrixRegisterResponseDTO.ts b/matrix/types/response/register/MatrixRegisterResponseDTO.ts new file mode 100644 index 0000000..ab0d216 --- /dev/null +++ b/matrix/types/response/register/MatrixRegisterResponseDTO.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString, isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixRegisterResponseDTO { + + readonly user_id : string; + readonly access_token ?: string; + + /** + * @deprecated Clients should extract the server_name from user_id + */ + readonly home_server ?: string; + + readonly device_id ?: string; + +} + +export function createMatrixRegisterResponseDTO ( + user_id : string, + access_token ?: string, + home_server ?: string, + device_id ?: string, +) : MatrixRegisterResponseDTO { + return { + user_id, + access_token, + home_server, + device_id + }; +} + +export function isMatrixRegisterResponseDTO (value: any): value is MatrixRegisterResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'user_id', + 'access_token', + 'home_server', + 'device_id' + ]) + && isString(value?.user_id) + && isStringOrUndefined(value?.access_token) + && isStringOrUndefined(value?.home_server) + && isStringOrUndefined(value?.device_id) + ); +} + +export function stringifyMatrixRegisterResponseDTO (value: MatrixRegisterResponseDTO): string { + return `MatrixRegisterResponseDTO(${value})`; +} + +export function parseMatrixRegisterResponseDTO (value: any): MatrixRegisterResponseDTO | undefined { + if ( isMatrixRegisterResponseDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/roomJoinedMembers/MatrixRoomJoinedMembersDTO.ts b/matrix/types/response/roomJoinedMembers/MatrixRoomJoinedMembersDTO.ts new file mode 100644 index 0000000..be04114 --- /dev/null +++ b/matrix/types/response/roomJoinedMembers/MatrixRoomJoinedMembersDTO.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixUserId, isMatrixUserId } from "../../core/MatrixUserId"; +import { MatrixRoomJoinedMembersRoomMemberDTO, isMatrixRoomJoinedMembersRoomMemberDTO } from "./types/MatrixRoomJoinedMembersRoomMemberDTO"; +import { isRegularObject, isRegularObjectOf } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixRoomJoinedMembersDTO { + readonly joined: {[P in MatrixUserId]: MatrixRoomJoinedMembersRoomMemberDTO} +} + +export function createMatrixRoomJoinedMembersDTO ( + joined: {[P in MatrixUserId]: MatrixRoomJoinedMembersRoomMemberDTO} +) : MatrixRoomJoinedMembersDTO{ + return { + joined + }; +} + +export function isMatrixRoomJoinedMembersDTO (value: any): value is MatrixRoomJoinedMembersDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'joined' + ]) + && isRegularObjectOf(value?.joined, isMatrixUserId, isMatrixRoomJoinedMembersRoomMemberDTO) + ); +} + +export function stringifyMatrixRoomJoinedMembersDTO (value: MatrixRoomJoinedMembersDTO): string { + return `MatrixRoomJoinedMembersDTO(${value})`; +} + +export function parseMatrixRoomJoinedMembersDTO (value: any): MatrixRoomJoinedMembersDTO | undefined { + if ( isMatrixRoomJoinedMembersDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/roomJoinedMembers/types/MatrixRoomJoinedMembersRoomMemberDTO.ts b/matrix/types/response/roomJoinedMembers/types/MatrixRoomJoinedMembersRoomMemberDTO.ts new file mode 100644 index 0000000..c8dbcdb --- /dev/null +++ b/matrix/types/response/roomJoinedMembers/types/MatrixRoomJoinedMembersRoomMemberDTO.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isNull } from "../../../../../types/Null"; +import { isString } from "../../../../../types/String"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixRoomJoinedMembersRoomMemberDTO { + readonly display_name : string; + readonly avatar_url : string | null; +} + +export function createMatrixRoomJoinedMembersRoomMemberDTO ( + display_name : string, + avatar_url : string | null +) : MatrixRoomJoinedMembersRoomMemberDTO { + return { + display_name, + avatar_url + }; +} + +export function isMatrixRoomJoinedMembersRoomMemberDTO (value: any): value is MatrixRoomJoinedMembersRoomMemberDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'display_name', + 'avatar_url' + ]) + && isString(value?.display_name) + && (isString(value?.avatar_url) || isNull(value?.avatar_url)) + ); +} + +export function stringifyMatrixRoomJoinedMembersRoomMemberDTO (value: MatrixRoomJoinedMembersRoomMemberDTO): string { + return `MatrixRoomJoinedMembersRoomMemberDTO(${value})`; +} + +export function parseMatrixRoomJoinedMembersRoomMemberDTO (value: any): MatrixRoomJoinedMembersRoomMemberDTO | undefined { + if ( isMatrixRoomJoinedMembersRoomMemberDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sendEventToRoomWithTnxId/SendEventToRoomWithTnxIdResponseDTO.ts b/matrix/types/response/sendEventToRoomWithTnxId/SendEventToRoomWithTnxIdResponseDTO.ts new file mode 100644 index 0000000..53be558 --- /dev/null +++ b/matrix/types/response/sendEventToRoomWithTnxId/SendEventToRoomWithTnxIdResponseDTO.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface SendEventToRoomWithTnxIdResponseDTO { + readonly event_id : string; +} + +export function createSendEventToRoomWithTnxIdResponseDTO ( + event_id : string +): SendEventToRoomWithTnxIdResponseDTO { + return { + event_id + }; +} + +export function isSendEventToRoomWithTnxIdResponseDTO (value: any): value is SendEventToRoomWithTnxIdResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'event_id' + ]) + && isString(value?.event_id) + ); +} + +export function stringifySendEventToRoomWithTnxIdResponseDTO (value: SendEventToRoomWithTnxIdResponseDTO): string { + return `SendEventToRoomWithTnxIdResponseDTO(${value})`; +} + +export function parseSendEventToRoomWithTnxIdResponseDTO (value: any): SendEventToRoomWithTnxIdResponseDTO | undefined { + if ( isSendEventToRoomWithTnxIdResponseDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/response/setRoomStateByType/PutRoomStateWithEventTypeResponseDTO.ts b/matrix/types/response/setRoomStateByType/PutRoomStateWithEventTypeResponseDTO.ts new file mode 100644 index 0000000..e580e00 --- /dev/null +++ b/matrix/types/response/setRoomStateByType/PutRoomStateWithEventTypeResponseDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface PutRoomStateWithEventTypeResponseDTO { + readonly event_id: string; +} + +export function createPutRoomStateWithEventTypeResponseDTO ( + event_id: string +) : PutRoomStateWithEventTypeResponseDTO { + return { + event_id + }; +} + +export function isPutRoomStateWithEventTypeResponseDTO (value: any): value is PutRoomStateWithEventTypeResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'event_id' + ]) + && isString(value?.event_id) + ); +} + +export function stringifyPutRoomStateWithEventTypeResponseDTO (value: PutRoomStateWithEventTypeResponseDTO): string { + return `PutRoomStateWithEventTypeDTO(${value})`; +} + +export function parsePutRoomStateWithEventTypeResponseDTO (value: any): PutRoomStateWithEventTypeResponseDTO | undefined { + if ( isPutRoomStateWithEventTypeResponseDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/MatrixSyncResponseDTO.ts b/matrix/types/response/sync/MatrixSyncResponseDTO.ts new file mode 100644 index 0000000..13e34ec --- /dev/null +++ b/matrix/types/response/sync/MatrixSyncResponseDTO.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { concat } from "../../../../functions/concat"; +import { + MatrixSyncResponseRoomsDTO, + explainMatrixSyncResponseRoomsDTO, + getEventsFromMatrixSyncResponseRoomsDTO, + isMatrixSyncResponseRoomsDTO +} from "./types/MatrixSyncResponseRoomsDTO"; +import { + MatrixSyncResponseAccountDataDTO, + getEventsFromMatrixSyncResponseAccountDataDTO, + isMatrixSyncResponseAccountDataDTO +} from "./types/MatrixSyncResponseAccountDataDTO"; +import { + MatrixSyncResponsePresenceDTO, + getEventsFromMatrixSyncResponsePresenceDTO, + isMatrixSyncResponsePresenceDTO +} from "./types/MatrixSyncResponsePresenceDTO"; +import { + MatrixSyncResponseToDeviceDTO, + getEventsFromMatrixSyncResponseToDeviceDTO, + isMatrixSyncResponseToDeviceDTO +} from "./types/MatrixSyncResponseToDeviceDTO"; +import { + MatrixSyncResponseDeviceListsDTO, + explainMatrixSyncResponseDeviceListsDTO, + isMatrixSyncResponseDeviceListsDTO +} from "./types/MatrixSyncResponseDeviceListsDTO"; +import { + MatrixSyncResponseDeviceOneTimeKeysCountDTO, + isMatrixSyncResponseDeviceOneTimeKeysCountDTO +} from "./types/MatrixSyncResponseDeviceOneTimeKeysCountDTO"; +import { MatrixSyncResponseAnyEventDTO } from "./types/MatrixSyncResponseAnyEventDTO"; +import { isUndefined } from "../../../../types/undefined"; +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; +import { keys } from "../../../../functions/keys"; + +export interface MatrixSyncResponseDTO { + readonly next_batch : string; + readonly rooms ?: MatrixSyncResponseRoomsDTO; + readonly presence ?: MatrixSyncResponsePresenceDTO; + readonly account_data ?: MatrixSyncResponseAccountDataDTO; + readonly to_device ?: MatrixSyncResponseToDeviceDTO; + readonly device_lists ?: MatrixSyncResponseDeviceListsDTO; + readonly device_one_time_keys_count ?: MatrixSyncResponseDeviceOneTimeKeysCountDTO; +} + +export function createMatrixSyncResponseDTO( + next_batch : string, + rooms ?: MatrixSyncResponseRoomsDTO, + presence ?: MatrixSyncResponsePresenceDTO, + account_data ?: MatrixSyncResponseAccountDataDTO, + to_device ?: MatrixSyncResponseToDeviceDTO, + device_lists ?: MatrixSyncResponseDeviceListsDTO, + device_one_time_keys_count ?: MatrixSyncResponseDeviceOneTimeKeysCountDTO, +) : MatrixSyncResponseDTO { + return { + next_batch, + rooms, + presence, + account_data, + to_device, + device_lists, + device_one_time_keys_count + }; +} + +export function getEventsFromMatrixSyncResponseDTO ( + value: MatrixSyncResponseDTO +): readonly MatrixSyncResponseAnyEventDTO[] { + return concat( + value?.rooms ? getEventsFromMatrixSyncResponseRoomsDTO(value?.rooms) : [], + value?.presence ? getEventsFromMatrixSyncResponsePresenceDTO(value?.presence) : [], + value?.account_data ? getEventsFromMatrixSyncResponseAccountDataDTO( + value?.account_data) : [], + value?.to_device ? getEventsFromMatrixSyncResponseToDeviceDTO(value?.to_device) : [] + ); +} + +export function isMatrixSyncResponseDTO (value: any): value is MatrixSyncResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'next_batch', + 'rooms', + 'presence', + 'account_data', + 'to_device', + 'device_lists', + 'device_unused_fallback_key_types', + 'device_one_time_keys_count', + 'org.matrix.msc2732.device_unused_fallback_key_types' + ]) + && isString(value?.next_batch) + && (isUndefined(value?.rooms) || isMatrixSyncResponseRoomsDTO(value?.rooms)) + && (isUndefined(value?.presence) || isMatrixSyncResponsePresenceDTO(value?.presence)) + && (isUndefined(value?.account_data) || isMatrixSyncResponseAccountDataDTO( + value?.account_data)) + && (isUndefined(value?.to_device) || isMatrixSyncResponseToDeviceDTO(value?.to_device)) + && (isUndefined(value?.device_lists) || isMatrixSyncResponseDeviceListsDTO(value?.device_lists)) + && ( + isUndefined(value?.device_one_time_keys_count) + || isMatrixSyncResponseDeviceOneTimeKeysCountDTO(value?.device_one_time_keys_count) + ) + ); +} + +export function assertMatrixSyncResponseDTO (value: any): void { + + if ( !isRegularObject(value) ) { + throw new TypeError(`value not RegularObject`); + } + + if ( !hasNoOtherKeysInDevelopment(value, [ + 'next_batch', + 'rooms', + 'presence', + 'account_data', + 'to_device', + 'device_lists', + 'device_one_time_keys_count', + 'device_unused_fallback_key_types', + 'org.matrix.msc2732.device_unused_fallback_key_types' + ]) ) { + throw new TypeError(`value has additional keys: ${keys(value)}`); + } + + if ( !isString(value?.next_batch) ) { + throw new TypeError('Property "next_batch" was not string'); + } + + if ( !(isUndefined(value?.rooms) || isMatrixSyncResponseRoomsDTO(value?.rooms)) ) { + throw new TypeError( + `Property "rooms" was invalid: ${explainMatrixSyncResponseRoomsDTO(value?.rooms)}`); + } + + if ( !(isUndefined(value?.presence) || isMatrixSyncResponsePresenceDTO(value?.presence)) ) { + throw new TypeError('Property "presence" was invalid'); + } + + if ( !(isUndefined(value?.account_data) || isMatrixSyncResponseAccountDataDTO( + value?.account_data)) ) { + throw new TypeError('Property "account_data" was invalid'); + } + + if ( !(isUndefined(value?.to_device) || isMatrixSyncResponseToDeviceDTO(value?.to_device)) ) { + throw new TypeError('Property "to_device" was invalid'); + } + + if ( !(isUndefined(value?.device_lists) || isMatrixSyncResponseDeviceListsDTO( + value?.device_lists)) ) { + throw new TypeError( + `Property "device_lists" was invalid: ${explainMatrixSyncResponseDeviceListsDTO( + value?.device_lists)}`); + } + + if ( !( + isUndefined(value?.device_one_time_keys_count) + || isMatrixSyncResponseDeviceOneTimeKeysCountDTO(value?.device_one_time_keys_count)) ) { + throw new TypeError('Property "device_one_time_keys_count" was invalid'); + } + +} + +export function explainMatrixSyncResponseDTO (value: any): string { + try { + assertMatrixSyncResponseDTO(value); + return 'No errors detected'; + } catch (err: any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseDTO (value: MatrixSyncResponseDTO): string { + return `MatrixSyncResponseDTO(${value})`; +} + +export function parseMatrixSyncResponseDTO (value: any): MatrixSyncResponseDTO | undefined { + if ( isMatrixSyncResponseDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/MatrixSyncResponseUtils.ts b/matrix/types/response/sync/MatrixSyncResponseUtils.ts new file mode 100644 index 0000000..93cf46f --- /dev/null +++ b/matrix/types/response/sync/MatrixSyncResponseUtils.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +export class MatrixSyncResponseUtils { + + +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseAccountDataDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseAccountDataDTO.ts new file mode 100644 index 0000000..eecc38f --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseAccountDataDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { concat } from "../../../../../functions/concat"; +import { MatrixSyncResponseEventDTO, isMatrixSyncResponseEventDTO } from "./MatrixSyncResponseEventDTO"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { isArrayOfOrUndefined } from "../../../../../types/Array"; + +export interface MatrixSyncResponseAccountDataDTO { + readonly events ?: readonly MatrixSyncResponseEventDTO[]; +} + +export function getEventsFromMatrixSyncResponseAccountDataDTO ( + value: MatrixSyncResponseAccountDataDTO +) : readonly MatrixSyncResponseEventDTO[] { + return concat([], value?.events ?? []); +} + +export function isMatrixSyncResponseAccountDataDTO (value: any): value is MatrixSyncResponseAccountDataDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'events' + ]) + && isArrayOfOrUndefined(value?.events, isMatrixSyncResponseEventDTO) + ); +} + +export function stringifyMatrixSyncResponseAccountDataDTO (value: MatrixSyncResponseAccountDataDTO): string { + return `MatrixSyncResponseAccountDataDTO(${value})`; +} + +export function parseMatrixSyncResponseAccountDataDTO (value: any): MatrixSyncResponseAccountDataDTO | undefined { + if ( isMatrixSyncResponseAccountDataDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseAnyEventDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseAnyEventDTO.ts new file mode 100644 index 0000000..3e48194 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseAnyEventDTO.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseEventDTO, isMatrixSyncResponseEventDTO } from "./MatrixSyncResponseEventDTO"; +import { MatrixSyncResponseRoomEventDTO, isMatrixSyncResponseRoomEventDTO } from "./MatrixSyncResponseRoomEventDTO"; +import { MatrixSyncResponseStateEventDTO, isMatrixSyncResponseStateEventDTO } from "./MatrixSyncResponseStateEventDTO"; +import { MatrixSyncResponseStrippedStateDTO, isMatrixSyncResponseStrippedStateDTO } from "./MatrixSyncResponseStrippedStateDTO"; + +export type MatrixSyncResponseAnyEventDTO = ( + MatrixSyncResponseEventDTO + | MatrixSyncResponseRoomEventDTO + | MatrixSyncResponseStateEventDTO + | MatrixSyncResponseStrippedStateDTO +); + +export function isMatrixSyncResponseAnyEventDTO (value: any): value is MatrixSyncResponseAnyEventDTO { + return ( + isMatrixSyncResponseEventDTO(value) + || isMatrixSyncResponseRoomEventDTO(value) + || isMatrixSyncResponseStateEventDTO(value) + || isMatrixSyncResponseStrippedStateDTO(value) + ); +} + +export function stringifyMatrixSyncResponseAnyEventDTO (value: MatrixSyncResponseAnyEventDTO): string { + return `MatrixSyncResponseAnyEventDTO(${value})`; +} + +export function parseMatrixSyncResponseAnyEventDTO (value: any): MatrixSyncResponseAnyEventDTO | undefined { + if ( isMatrixSyncResponseAnyEventDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseDeviceListsDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseDeviceListsDTO.ts new file mode 100644 index 0000000..c85f025 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseDeviceListsDTO.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixUserId, isMatrixUserId } from "../../../core/MatrixUserId"; +import { isUndefined } from "../../../../../types/undefined"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; +import { isArrayOf, isArrayOfOrUndefined } from "../../../../../types/Array"; + +export interface MatrixSyncResponseDeviceListsDTO { + readonly changed ?: readonly MatrixUserId[]; + readonly left ?: readonly MatrixUserId[] | undefined; +} + +export function isMatrixSyncResponseDeviceListsDTO (value: any): value is MatrixSyncResponseDeviceListsDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'changed', + 'left' + ]) + && isArrayOfOrUndefined(value?.changed, isMatrixUserId) + && ( isUndefined(value?.left) || isArrayOf(value?.left, isMatrixUserId) ) + ); +} + +export function assertMatrixSyncResponseDeviceListsDTO (value: any) : void { + + if (! isRegularObject(value) ) { + throw new TypeError(`Value not regular object: ${value}`); + } + + if (! hasNoOtherKeysInDevelopment(value, [ + 'changed', + 'left' + ])) { + throw new TypeError(`Value properties not right: ${keys(value)}`); + } + + if (! isArrayOf(value?.changed, isMatrixUserId)) { + throw new TypeError(`Property "changed" not valid: ${value?.changed}`); + } + + if (! (isUndefined(value?.left) || isArrayOf(value?.left, isMatrixUserId))) { + throw new TypeError(`Property "left" not valid: ${value?.left}`); + } + +} + +export function explainMatrixSyncResponseDeviceListsDTO (value : any) : string { + try { + assertMatrixSyncResponseDeviceListsDTO(value); + return 'No errors detected'; + } catch (err: any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseDeviceListsDTO (value: MatrixSyncResponseDeviceListsDTO): string { + return `MatrixSyncResponseDeviceListsDTO(${value})`; +} + +export function parseMatrixSyncResponseDeviceListsDTO (value: any): MatrixSyncResponseDeviceListsDTO | undefined { + if ( isMatrixSyncResponseDeviceListsDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseDeviceOneTimeKeysCountDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseDeviceOneTimeKeysCountDTO.ts new file mode 100644 index 0000000..b6b907a --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseDeviceOneTimeKeysCountDTO.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../../../types/String"; +import { isInteger } from "../../../../../types/Number"; +import { isRegularObjectOf } from "../../../../../types/RegularObject"; + +export interface MatrixSyncResponseDeviceOneTimeKeysCountDTO { + readonly [key : string] : number; +} + +export function isMatrixSyncResponseDeviceOneTimeKeysCountDTO (value: any): value is MatrixSyncResponseDeviceOneTimeKeysCountDTO { + return ( + isRegularObjectOf(value, isString, isInteger) + ); +} + +export function stringifyMatrixSyncResponseDeviceOneTimeKeysCountDTO (value: MatrixSyncResponseDeviceOneTimeKeysCountDTO): string { + return `MatrixSyncResponseDeviceOneTimeKeysCountDTO(${value})`; +} + +export function parseMatrixSyncResponseDeviceOneTimeKeysCountDTO (value: any): MatrixSyncResponseDeviceOneTimeKeysCountDTO | undefined { + if ( isMatrixSyncResponseDeviceOneTimeKeysCountDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseEphemeralDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseEphemeralDTO.ts new file mode 100644 index 0000000..b5a0d1e --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseEphemeralDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { concat } from "../../../../../functions/concat"; +import { MatrixSyncResponseEventDTO, isMatrixSyncResponseEventDTO } from "./MatrixSyncResponseEventDTO"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { isArrayOf } from "../../../../../types/Array"; + +export interface MatrixSyncResponseEphemeralDTO { + readonly events : readonly MatrixSyncResponseEventDTO[]; +} + +export function getEventsFromMatrixSyncResponseEphemeralDTO ( + value: MatrixSyncResponseEphemeralDTO +) : readonly MatrixSyncResponseEventDTO[] { + return concat([], value?.events ?? []); +} + +export function isMatrixSyncResponseEphemeralDTO (value: any): value is MatrixSyncResponseEphemeralDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'events' + ]) + && isArrayOf(value?.events, isMatrixSyncResponseEventDTO) + ); +} + +export function stringifyMatrixSyncResponseEphemeralDTO (value: MatrixSyncResponseEphemeralDTO): string { + return `MatrixSyncResponseEphemeralDTO(${value})`; +} + +export function parseMatrixSyncResponseEphemeralDTO (value: any): MatrixSyncResponseEphemeralDTO | undefined { + if ( isMatrixSyncResponseEphemeralDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseEventDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseEventDTO.ts new file mode 100644 index 0000000..ab30b9e --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseEventDTO.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isJsonObject, JsonObject } from "../../../../../Json"; +import { MatrixType, isMatrixType } from "../../../core/MatrixType"; +import { MatrixUserId, isMatrixUserId } from "../../../core/MatrixUserId"; +import { isUndefined } from "../../../../../types/undefined"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixSyncResponseEventDTO { + readonly content : JsonObject; + readonly type : MatrixType; + readonly sender ?: MatrixUserId; +} + +export function isMatrixSyncResponseEventDTO (value: any): value is MatrixSyncResponseEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'content', + 'type', + 'sender' + ]) + && isJsonObject(value?.content) + && isMatrixType(value?.type) + && (isUndefined(value?.sender) || isMatrixUserId(value?.sender)) + ); +} + +export function stringifyMatrixSyncResponseEventDTO (value: MatrixSyncResponseEventDTO): string { + return `MatrixSyncResponseEventDTO(${value})`; +} + +export function parseMatrixSyncResponseEventDTO (value: any): MatrixSyncResponseEventDTO | undefined { + if ( isMatrixSyncResponseEventDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseInviteStateDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseInviteStateDTO.ts new file mode 100644 index 0000000..20e5c59 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseInviteStateDTO.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseStrippedStateDTO, + explainMatrixSyncResponseStrippedStateDTO, + isMatrixSyncResponseStrippedStateDTO +} from "./MatrixSyncResponseStrippedStateDTO"; +import { find } from "../../../../../functions/find"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; +import { isArrayOf } from "../../../../../types/Array"; + +export interface MatrixSyncResponseInviteStateDTO { + readonly events: readonly MatrixSyncResponseStrippedStateDTO[]; +} + +export function getEventsFromMatrixSyncResponseInviteStateDTO ( + value: MatrixSyncResponseInviteStateDTO +) : readonly MatrixSyncResponseStrippedStateDTO[] { + return value?.events ?? []; +} + +export function isMatrixSyncResponseInviteStateDTO (value: any): value is MatrixSyncResponseInviteStateDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'events' + ]) + && isArrayOf(value?.events, isMatrixSyncResponseStrippedStateDTO) + ); +} + +export function assertMatrixSyncResponseInviteStateDTO (value: any): void { + if(!( isRegularObject(value) )) { + throw new TypeError(`value invalid: ${value}`); + } + if(!( hasNoOtherKeysInDevelopment(value, [ + 'events' + ]) )) { + throw new TypeError(`value has extra keys: all keys: ${keys(value)}`); + } + if(!( isArrayOf(value?.events, isMatrixSyncResponseStrippedStateDTO) )) { + const item = find(value?.events, event => !isMatrixSyncResponseStrippedStateDTO(event)); + throw new TypeError(`Property "events" had invalid item: ${explainMatrixSyncResponseStrippedStateDTO(item)}`); + } +} + +export function explainMatrixSyncResponseInviteStateDTO (value : any) : string { + try { + assertMatrixSyncResponseInviteStateDTO(value); + return 'No errors detected'; + } catch (err : any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseInviteStateDTO (value: MatrixSyncResponseInviteStateDTO): string { + return `MatrixSyncResponseInviteStateDTO(${value})`; +} + +export function parseMatrixSyncResponseInviteStateDTO (value: any): MatrixSyncResponseInviteStateDTO | undefined { + if ( isMatrixSyncResponseInviteStateDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseInvitedRoomDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseInvitedRoomDTO.ts new file mode 100644 index 0000000..3da67f9 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseInvitedRoomDTO.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseInviteStateDTO, + explainMatrixSyncResponseInviteStateDTO, + getEventsFromMatrixSyncResponseInviteStateDTO, + isMatrixSyncResponseInviteStateDTO +} from "./MatrixSyncResponseInviteStateDTO"; +import { MatrixSyncResponseStrippedStateDTO } from "./MatrixSyncResponseStrippedStateDTO"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; + +export interface MatrixSyncResponseInvitedRoomDTO { + readonly invite_state : MatrixSyncResponseInviteStateDTO; +} + +export function getEventsFromMatrixSyncResponseInvitedRoomDTO ( + value: MatrixSyncResponseInvitedRoomDTO +) : readonly MatrixSyncResponseStrippedStateDTO[] { + return getEventsFromMatrixSyncResponseInviteStateDTO(value.invite_state); +} + +export function isMatrixSyncResponseInvitedRoomDTO (value: any): value is MatrixSyncResponseInvitedRoomDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'invite_state' + ]) + && isMatrixSyncResponseInviteStateDTO(value?.invite_state) + ); +} + +export function assertMatrixSyncResponseInvitedRoomDTO (value: any): void { + + if(!( isRegularObject(value) )) { + throw new TypeError(`Value not object: ${value}`); + } + + if(!( hasNoOtherKeysInDevelopment(value, [ + 'invite_state' + ]) )) { + throw new TypeError(`Object has extra keys: all keys: ${keys(value)}`); + } + + if(!( isMatrixSyncResponseInviteStateDTO(value?.invite_state) )) { + throw new TypeError(`Property "invite_state" invalid: ${explainMatrixSyncResponseInviteStateDTO(value?.invite_state)}`); + } + +} + +export function explainMatrixSyncResponseInvitedRoomDTO (value : any) : string { + try { + assertMatrixSyncResponseInvitedRoomDTO(value); + return 'No errors detected'; + } catch (err: any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseInvitedRoomDTO (value: MatrixSyncResponseInvitedRoomDTO): string { + return `MatrixSyncResponseInvitedRoomDTO(${value})`; +} + +export function parseMatrixSyncResponseInvitedRoomDTO (value: any): MatrixSyncResponseInvitedRoomDTO | undefined { + if ( isMatrixSyncResponseInvitedRoomDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseJoinedRoomDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseJoinedRoomDTO.ts new file mode 100644 index 0000000..2824049 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseJoinedRoomDTO.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { + MatrixSyncResponseRoomSummaryDTO, + isMatrixSyncResponseRoomSummaryDTO +} from "./MatrixSyncResponseRoomSummaryDTO"; +import { + MatrixSyncResponseStateDTO, + explainMatrixSyncResponseStateDTO, + getEventsFromMatrixSyncResponseStateDTO, + isMatrixSyncResponseStateDTO +} from "./MatrixSyncResponseStateDTO"; +import { + MatrixSyncResponseTimelineDTO, + explainMatrixSyncResponseTimelineDTO, + getEventsFromMatrixSyncResponseTimelineDTO, + isMatrixSyncResponseTimelineDTO +} from "./MatrixSyncResponseTimelineDTO"; +import { + MatrixSyncResponseEphemeralDTO, + getEventsFromMatrixSyncResponseEphemeralDTO, + isMatrixSyncResponseEphemeralDTO +} from "./MatrixSyncResponseEphemeralDTO"; +import { + MatrixSyncResponseAccountDataDTO, + getEventsFromMatrixSyncResponseAccountDataDTO, + isMatrixSyncResponseAccountDataDTO +} from "./MatrixSyncResponseAccountDataDTO"; +import { + MatrixSyncResponseUnreadNotificationCountsDTO, + isMatrixSyncResponseUnreadNotificationCountsDTO +} from "./MatrixSyncResponseUnreadNotificationCountsDTO"; +import { concat } from "../../../../../functions/concat"; +import { MatrixSyncResponseEventDTO } from "./MatrixSyncResponseEventDTO"; +import { MatrixSyncResponseRoomEventDTO } from "./MatrixSyncResponseRoomEventDTO"; +import { isUndefined } from "../../../../../types/undefined"; +import { isNumberOrUndefined } from "../../../../../types/Number"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixSyncResponseJoinedRoomDTO { + readonly summary?: MatrixSyncResponseRoomSummaryDTO; + readonly state?: MatrixSyncResponseStateDTO; + readonly timeline?: MatrixSyncResponseTimelineDTO; + readonly ephemeral?: MatrixSyncResponseEphemeralDTO; + readonly account_data?: MatrixSyncResponseAccountDataDTO; + readonly unread_notifications?: MatrixSyncResponseUnreadNotificationCountsDTO; + readonly "org.matrix.msc2654.unread_count"?: number; +} + +export function getEventsFromMatrixSyncResponseJoinedRoomDTO ( + value: MatrixSyncResponseJoinedRoomDTO +): readonly (MatrixSyncResponseRoomEventDTO | MatrixSyncResponseEventDTO)[] { + + return concat( + [] as readonly (MatrixSyncResponseRoomEventDTO | MatrixSyncResponseEventDTO)[], + value?.state ? getEventsFromMatrixSyncResponseStateDTO(value?.state) : [], + value?.timeline ? getEventsFromMatrixSyncResponseTimelineDTO(value?.timeline) : [], + value?.ephemeral ? getEventsFromMatrixSyncResponseEphemeralDTO(value?.ephemeral) : [], + value?.account_data ? getEventsFromMatrixSyncResponseAccountDataDTO( + value?.account_data) : [] + ); + +} + +export function isMatrixSyncResponseJoinedRoomDTO (value: any): value is MatrixSyncResponseJoinedRoomDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'summary', + 'state', + 'timeline', + 'ephemeral', + 'account_data', + 'unread_notifications', + 'org.matrix.msc2654.unread_count' + ]) + && (isUndefined(value?.summary) || isMatrixSyncResponseRoomSummaryDTO(value?.summary)) + && (isUndefined(value?.state) || isMatrixSyncResponseStateDTO(value?.state)) + && (isUndefined(value?.timeline) || isMatrixSyncResponseTimelineDTO(value?.timeline)) + && (isUndefined(value?.ephemeral) || isMatrixSyncResponseEphemeralDTO(value?.ephemeral)) + && (isUndefined(value?.account_data) || isMatrixSyncResponseAccountDataDTO( + value?.account_data)) + && (isUndefined( + value?.unread_notifications) || isMatrixSyncResponseUnreadNotificationCountsDTO( + value?.unread_notifications)) + && (isNumberOrUndefined(value['org.matrix.msc2654.unread_count'])) + ); +} + +export function assertMatrixSyncResponseJoinedRoomDTO (value: any): void { + + if ( !(isRegularObject(value)) ) { + throw new TypeError(`value was not object: ${value}`); + } + + if ( !(hasNoOtherKeysInDevelopment(value, [ + 'summary', + 'state', + 'timeline', + 'ephemeral', + 'account_data', + 'unread_notifications', + 'org.matrix.msc2654.unread_count' + ])) ) { + throw new TypeError(`value had extra keys: ${value}`); + } + + if ( !((isUndefined(value?.summary) || isMatrixSyncResponseRoomSummaryDTO(value?.summary))) ) { + throw new TypeError(`Property "summary" was invalid: ${value}`); + } + + if ( !((isUndefined(value?.state) || isMatrixSyncResponseStateDTO(value?.state))) ) { + throw new TypeError( + `Property "state" was invalid: ${explainMatrixSyncResponseStateDTO(value?.state)}`); + } + + if ( !((isUndefined(value?.timeline) || isMatrixSyncResponseTimelineDTO(value?.timeline))) ) { + throw new TypeError( + `Property "timeline" was invalid: ${explainMatrixSyncResponseTimelineDTO(value?.timeline)}`); + } + + if ( !((isUndefined(value?.ephemeral) || isMatrixSyncResponseEphemeralDTO( + value?.ephemeral))) ) { + throw new TypeError(`Property "ephemeral" was invalid: ${value}`); + } + + if ( !((isUndefined(value?.account_data) || isMatrixSyncResponseAccountDataDTO( + value?.account_data))) ) { + throw new TypeError(`Property "account_data" was invalid: ${value}`); + } + + if ( !((isUndefined( + value?.unread_notifications) || isMatrixSyncResponseUnreadNotificationCountsDTO( + value?.unread_notifications))) ) { + throw new TypeError(`Property "unread_notifications" was invalid: ${value}`); + } + + if ( !((isNumberOrUndefined(value['org.matrix.msc2654.unread_count']))) ) { + throw new TypeError(`Property "org.matrix.msc2654.unread_count" was invalid: ${value}`); + } + +} + +export function explainMatrixSyncResponseJoinedRoomDTO (value: any): string { + try { + assertMatrixSyncResponseJoinedRoomDTO(value); + return 'No errors detected'; + } catch (err: any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseJoinedRoomDTO (value: MatrixSyncResponseJoinedRoomDTO): string { + return `MatrixSyncResponseJoinedRoomDTO(${value})`; +} + +export function parseMatrixSyncResponseJoinedRoomDTO (value: any): MatrixSyncResponseJoinedRoomDTO | undefined { + if ( isMatrixSyncResponseJoinedRoomDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseLeftRoomDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseLeftRoomDTO.ts new file mode 100644 index 0000000..ffda7b8 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseLeftRoomDTO.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseStateDTO, + getEventsFromMatrixSyncResponseStateDTO, + isMatrixSyncResponseStateDTO +} from "./MatrixSyncResponseStateDTO"; +import { MatrixSyncResponseTimelineDTO, + getEventsFromMatrixSyncResponseTimelineDTO, + isMatrixSyncResponseTimelineDTO +} from "./MatrixSyncResponseTimelineDTO"; +import { MatrixSyncResponseAccountDataDTO, + getEventsFromMatrixSyncResponseAccountDataDTO, + isMatrixSyncResponseAccountDataDTO +} from "./MatrixSyncResponseAccountDataDTO"; +import { concat } from "../../../../../functions/concat"; +import { MatrixSyncResponseEventDTO } from "./MatrixSyncResponseEventDTO"; +import { MatrixSyncResponseRoomEventDTO } from "./MatrixSyncResponseRoomEventDTO"; +import { MatrixSyncResponseStateEventDTO } from "./MatrixSyncResponseStateEventDTO"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixSyncResponseLeftRoomDTO { + readonly state : MatrixSyncResponseStateDTO; + readonly timeline : MatrixSyncResponseTimelineDTO; + readonly account_data : MatrixSyncResponseAccountDataDTO; +} + +export function getEventsFromMatrixSyncResponseLeftRoomDTO ( + value: MatrixSyncResponseLeftRoomDTO +) : readonly (MatrixSyncResponseStateEventDTO|MatrixSyncResponseRoomEventDTO|MatrixSyncResponseEventDTO)[] { + return concat( + [] as readonly (MatrixSyncResponseStateEventDTO|MatrixSyncResponseRoomEventDTO|MatrixSyncResponseEventDTO)[], + getEventsFromMatrixSyncResponseStateDTO(value?.state), + getEventsFromMatrixSyncResponseTimelineDTO(value?.timeline), + getEventsFromMatrixSyncResponseAccountDataDTO(value?.account_data) + ); +} + +export function isMatrixSyncResponseLeftRoomDTO (value: any): value is MatrixSyncResponseLeftRoomDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'state', + 'timeline', + 'account_data' + ]) + && isMatrixSyncResponseStateDTO(value?.state) + && isMatrixSyncResponseTimelineDTO(value?.timeline) + && isMatrixSyncResponseAccountDataDTO(value?.account_data) + ); +} + +export function stringifyMatrixSyncResponseLeftRoomDTO (value: MatrixSyncResponseLeftRoomDTO): string { + return `MatrixSyncResponseLeftRoomDTO(${value})`; +} + +export function parseMatrixSyncResponseLeftRoomDTO (value: any): MatrixSyncResponseLeftRoomDTO | undefined { + if ( isMatrixSyncResponseLeftRoomDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponsePresenceDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponsePresenceDTO.ts new file mode 100644 index 0000000..05be82e --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponsePresenceDTO.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseEventDTO, isMatrixSyncResponseEventDTO } from "./MatrixSyncResponseEventDTO"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { isArrayOfOrUndefined } from "../../../../../types/Array"; + +export interface MatrixSyncResponsePresenceDTO { + readonly events ?: readonly MatrixSyncResponseEventDTO[]; +} + +export function getEventsFromMatrixSyncResponsePresenceDTO ( + value: MatrixSyncResponsePresenceDTO +) : readonly MatrixSyncResponseEventDTO[] { + return value?.events ?? []; +} + +export function isMatrixSyncResponsePresenceDTO (value: any): value is MatrixSyncResponsePresenceDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'events' + ]) + && isArrayOfOrUndefined(value?.events, isMatrixSyncResponseEventDTO) + ); +} + +export function stringifyMatrixSyncResponsePresenceDTO (value: MatrixSyncResponsePresenceDTO): string { + return `MatrixSyncResponsePresenceDTO(${value})`; +} + +export function parseMatrixSyncResponsePresenceDTO (value: any): MatrixSyncResponsePresenceDTO | undefined { + if ( isMatrixSyncResponsePresenceDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseRoomEventDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseRoomEventDTO.ts new file mode 100644 index 0000000..c130bff --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseRoomEventDTO.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isJsonObject, JsonObject } from "../../../../../Json"; +import { MatrixSyncResponseUnsignedDataDTO, + explainMatrixSyncResponseUnsignedDataDTO, + isMatrixSyncResponseUnsignedDataDTO +} from "./MatrixSyncResponseUnsignedDataDTO"; +import { MatrixUserId, isMatrixUserId } from "../../../core/MatrixUserId"; +import { isUndefined } from "../../../../../types/undefined"; +import { isString, isStringOrUndefined } from "../../../../../types/String"; +import { isInteger } from "../../../../../types/Number"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; + +export interface MatrixSyncResponseRoomEventDTO { + readonly content : JsonObject; + readonly type : string; + readonly event_id : string; + readonly sender : MatrixUserId; + readonly origin_server_ts : number; + readonly unsigned ?: MatrixSyncResponseUnsignedDataDTO; + readonly state_key ?: string; +} + +export function isMatrixSyncResponseRoomEventDTO (value: any): value is MatrixSyncResponseRoomEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'content', + 'type', + 'event_id', + 'sender', + 'origin_server_ts', + 'unsigned', + 'state_key' + ]) + && isJsonObject(value?.content) + && isString(value?.type) + && isString(value?.event_id) + && isMatrixUserId(value?.sender) + && isInteger(value?.origin_server_ts) + && (isUndefined(value?.unsigned) || isMatrixSyncResponseUnsignedDataDTO(value?.unsigned)) + && isStringOrUndefined(value?.state_key) + ); +} + +export function assertMatrixSyncResponseRoomEventDTO (value: any): void { + + if (!( isRegularObject(value) )) { + throw new TypeError(`value was not regular object`); + } + + if (!( hasNoOtherKeysInDevelopment(value, [ + 'content', + 'type', + 'event_id', + 'sender', + 'origin_server_ts', + 'unsigned', + 'state_key' + ]))) { + throw new TypeError(`Had extra properties: All keys: ${keys(value)}`); + } + + if (!( isJsonObject(value?.content) )) { + throw new TypeError(`Property "content" was not correct: ${value?.content}`); + } + + if (!( isString(value?.type))) { + throw new TypeError(`Property "type" was not correct: ${value?.type}`); + } + + if (!( isString(value?.event_id))) { + throw new TypeError(`Property "event_id" was not correct: ${value?.event_id}`); + } + + if (!( isMatrixUserId(value?.sender))) { + throw new TypeError(`Property "sender" was not correct: ${value?.sender}`); + } + + if (!( isInteger(value?.origin_server_ts))) { + throw new TypeError(`Property "origin_server_ts" was not correct: ${value?.origin_server_ts}`); + } + + if (!( (isUndefined(value?.unsigned) || isMatrixSyncResponseUnsignedDataDTO(value?.unsigned)))) { + throw new TypeError(`Property "unsigned" was not correct: ${explainMatrixSyncResponseUnsignedDataDTO(value?.unsigned)}`); + } + + if (!( isStringOrUndefined(value?.state_key) )) { + throw new TypeError(`Property "state_key" was not correct: ${value?.state_key}`); + } + +} + +export function explainMatrixSyncResponseRoomEventDTO (value : any) : string { + try { + assertMatrixSyncResponseRoomEventDTO(value); + return 'No errors detected'; + } catch (err : any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseRoomEventDTO (value: MatrixSyncResponseRoomEventDTO): string { + return `MatrixSyncResponseRoomEventDTO(${value})`; +} + +export function parseMatrixSyncResponseRoomEventDTO (value: any): MatrixSyncResponseRoomEventDTO | undefined { + if ( isMatrixSyncResponseRoomEventDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseRoomSummaryDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseRoomSummaryDTO.ts new file mode 100644 index 0000000..5ad50b8 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseRoomSummaryDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixType } from "../../../core/MatrixType"; +import { isIntegerOrUndefined } from "../../../../../types/Number"; +import { isStringArrayOrUndefined } from "../../../../../types/StringArray"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixSyncResponseRoomSummaryDTO { + readonly [MatrixType.M_HEROES] ?: readonly string[]; + readonly [MatrixType.M_JOINED_MEMBER_COUNT] ?: number; + readonly [MatrixType.M_INVITED_MEMBER_COUNT] ?: number; +} + +export function isMatrixSyncResponseRoomSummaryDTO (value: any): value is MatrixSyncResponseRoomSummaryDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + MatrixType.M_HEROES, + MatrixType.M_JOINED_MEMBER_COUNT, + MatrixType.M_INVITED_MEMBER_COUNT + ]) + && isStringArrayOrUndefined(value[MatrixType.M_HEROES]) + && isIntegerOrUndefined(value[MatrixType.M_JOINED_MEMBER_COUNT]) + && isIntegerOrUndefined(value[MatrixType.M_INVITED_MEMBER_COUNT]) + ); +} + +export function stringifyMatrixSyncResponseRoomSummaryDTO (value: MatrixSyncResponseRoomSummaryDTO): string { + return `MatrixSyncResponseRoomSummaryDTO(${value})`; +} + +export function parseMatrixSyncResponseRoomSummaryDTO (value: any): MatrixSyncResponseRoomSummaryDTO | undefined { + if ( isMatrixSyncResponseRoomSummaryDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseRoomsDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseRoomsDTO.ts new file mode 100644 index 0000000..883dda0 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseRoomsDTO.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixRoomId, explainMatrixRoomId, isMatrixRoomId } from "../../../core/MatrixRoomId"; +import { MatrixSyncResponseJoinedRoomDTO, + explainMatrixSyncResponseJoinedRoomDTO, + getEventsFromMatrixSyncResponseJoinedRoomDTO, + isMatrixSyncResponseJoinedRoomDTO +} from "./MatrixSyncResponseJoinedRoomDTO"; +import { MatrixSyncResponseInvitedRoomDTO, + explainMatrixSyncResponseInvitedRoomDTO, + getEventsFromMatrixSyncResponseInvitedRoomDTO, + isMatrixSyncResponseInvitedRoomDTO +} from "./MatrixSyncResponseInvitedRoomDTO"; +import { MatrixSyncResponseLeftRoomDTO, + getEventsFromMatrixSyncResponseLeftRoomDTO, + isMatrixSyncResponseLeftRoomDTO +} from "./MatrixSyncResponseLeftRoomDTO"; +import { concat } from "../../../../../functions/concat"; +import { reduce } from "../../../../../functions/reduce"; +import { MatrixSyncResponseAnyEventDTO } from "./MatrixSyncResponseAnyEventDTO"; +import { isUndefined } from "../../../../../types/undefined"; +import { explainRegularObjectOf, isRegularObject, isRegularObjectOf } from "../../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; + +export interface MatrixSyncResponseRoomsDTO { + readonly join ?: {[K in MatrixRoomId]: MatrixSyncResponseJoinedRoomDTO}; + readonly invite ?: {[K in MatrixRoomId]: MatrixSyncResponseInvitedRoomDTO}; + readonly leave ?: {[K in MatrixRoomId]: MatrixSyncResponseLeftRoomDTO}; +} + +interface getEventsCallback { + (value: T) : readonly MatrixSyncResponseAnyEventDTO[]; +} + +function getEventsFromObject ( + value : {[K in MatrixRoomId]: T}, + callback : getEventsCallback +) : readonly MatrixSyncResponseAnyEventDTO[] { + const propertyKeys : readonly string[] = keys(value); + return reduce( + propertyKeys, + (arr : readonly MatrixSyncResponseAnyEventDTO[], key: string) : readonly MatrixSyncResponseAnyEventDTO[] => { + return concat(arr, callback(value[key])); + }, + [] + ); +} + +export function getEventsFromMatrixSyncResponseRoomsDTO ( + value: MatrixSyncResponseRoomsDTO +) : readonly MatrixSyncResponseAnyEventDTO[] { + return concat( + [] as readonly MatrixSyncResponseAnyEventDTO[], + getEventsFromObject(value?.join ?? {}, getEventsFromMatrixSyncResponseJoinedRoomDTO), + getEventsFromObject(value?.invite ?? {}, getEventsFromMatrixSyncResponseInvitedRoomDTO), + getEventsFromObject(value?.leave ?? {}, getEventsFromMatrixSyncResponseLeftRoomDTO), + ); +} + +export function isMatrixSyncResponseRoomsDTO (value: any): value is MatrixSyncResponseRoomsDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'join', + 'invite', + 'peek', + 'leave' + ]) + && ( isUndefined(value?.join) || isRegularObjectOf( value?.join, isMatrixRoomId, isMatrixSyncResponseJoinedRoomDTO) ) + && ( isUndefined(value?.invite) || isRegularObjectOf(value?.invite, isMatrixRoomId, isMatrixSyncResponseInvitedRoomDTO) ) + && ( isUndefined(value?.leave) || isRegularObjectOf( value?.leave, isMatrixRoomId, isMatrixSyncResponseLeftRoomDTO) ) + ); +} + +export function assertMatrixSyncResponseRoomsDTO (value: any) : void { + + if(!( isRegularObject(value) )) { + throw new TypeError(`value was not regular object`); + } + + const propertyKeys = [ + 'join', + 'invite', + 'leave', + 'peek' + ]; + + if(!( hasNoOtherKeysInDevelopment(value, propertyKeys) )) { + throw new TypeError(`MatrixSyncResponseRoomsDTO: hasNoOtherKeysInDevelopment: ${explainNoOtherKeys(value, propertyKeys)}`); + } + + if(!( + isUndefined(value?.join) + || isRegularObjectOf( + value?.join, + isMatrixRoomId, + isMatrixSyncResponseJoinedRoomDTO + ) + )) { + throw new TypeError(`Property "join" was invalid: ${ + explainRegularObjectOf< + MatrixRoomId, + MatrixSyncResponseJoinedRoomDTO + >( + value?.join, + isMatrixRoomId, + isMatrixSyncResponseJoinedRoomDTO, + explainMatrixRoomId, + explainMatrixSyncResponseJoinedRoomDTO + ) + }`); + } + + if(!( ( isUndefined(value?.invite) || isRegularObjectOf(value?.invite, isMatrixRoomId, isMatrixSyncResponseInvitedRoomDTO) ) )) { + throw new TypeError(`Property "invite" was invalid: ${explainRegularObjectOf(value?.invite, isMatrixRoomId, isMatrixSyncResponseInvitedRoomDTO, explainMatrixRoomId, explainMatrixSyncResponseInvitedRoomDTO)}`); + } + + if(!( ( isUndefined(value?.leave) || isRegularObjectOf( value?.leave, isMatrixRoomId, isMatrixSyncResponseLeftRoomDTO) ) )) { + throw new TypeError(`Property "leave" was invalid`); + } + +} + +export function explainMatrixSyncResponseRoomsDTO (value : any) : string { + try { + assertMatrixSyncResponseRoomsDTO(value); + return 'No errors detected'; + } catch (err: any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseRoomsDTO (value: MatrixSyncResponseRoomsDTO): string { + return `MatrixSyncResponseRoomsDTO(${value})`; +} + +export function parseMatrixSyncResponseRoomsDTO (value: any): MatrixSyncResponseRoomsDTO | undefined { + if ( isMatrixSyncResponseRoomsDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseStateDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseStateDTO.ts new file mode 100644 index 0000000..149024b --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseStateDTO.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { concat } from "../../../../../functions/concat"; +import { find } from "../../../../../functions/find"; +import { MatrixSyncResponseStateEventDTO, + explainMatrixSyncResponseStateEventDTO, + isMatrixSyncResponseStateEventDTO +} from "./MatrixSyncResponseStateEventDTO"; +import { LogService } from "../../../../../LogService"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { isArrayOf } from "../../../../../types/Array"; + +const LOG = LogService.createLogger('MatrixSyncResponseStateDTO'); + +export interface MatrixSyncResponseStateDTO { + readonly events : readonly MatrixSyncResponseStateEventDTO[]; +} + +export function getEventsFromMatrixSyncResponseStateDTO ( + value: MatrixSyncResponseStateDTO +) : readonly MatrixSyncResponseStateEventDTO[] { + return concat([], value?.events ?? []); +} + +export function isMatrixSyncResponseStateDTO (value: any): value is MatrixSyncResponseStateDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'events' + ]) + && isArrayOf(value?.events, isMatrixSyncResponseStateEventDTO) + ); +} + +export function assertMatrixSyncResponseStateDTO (value: any): void { + + if(!( isRegularObject(value) )) { + throw new TypeError(`value was not object`); + } + + if(!( hasNoOtherKeysInDevelopment(value, [ + 'events' + ]))) { + throw new TypeError(`value had extra keys`); + } + + if(!( isArrayOf(value?.events, isMatrixSyncResponseStateEventDTO) )) { + if (!value?.events) { + LOG.debug(`Not a MatrixSyncResponseStateDTO: ${JSON.stringify(value, null, 2)}`); + throw new TypeError(`Property "events": Not array of MatrixSyncResponseStateEventDTO: Not an array: ${value?.events}`); + } + const item = find(value?.events, item => !isMatrixSyncResponseStateEventDTO(item)); + throw new TypeError(`Property "events": Not array of MatrixSyncResponseStateEventDTO: ${explainMatrixSyncResponseStateEventDTO(item)}`); + } + +} + +export function explainMatrixSyncResponseStateDTO (value : any) : string { + try { + assertMatrixSyncResponseStateDTO(value); + return 'No errors detected'; + } catch (err : any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseStateDTO (value: MatrixSyncResponseStateDTO): string { + return `MatrixSyncResponseStateDTO(${value})`; +} + +export function parseMatrixSyncResponseStateDTO (value: any): MatrixSyncResponseStateDTO | undefined { + if ( isMatrixSyncResponseStateDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseStateEventDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseStateEventDTO.ts new file mode 100644 index 0000000..574b6b6 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseStateEventDTO.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { + MatrixSyncResponseUnsignedDataDTO, + isMatrixSyncResponseUnsignedDataDTO, + explainMatrixSyncResponseUnsignedDataDTO, +} from "./MatrixSyncResponseUnsignedDataDTO"; +import { + isJsonObject, + JsonObject +} from "../../../../../Json"; +import { isUndefined } from "../../../../../types/undefined"; +import { isString } from "../../../../../types/String"; +import { isInteger } from "../../../../../types/Number"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; + +export interface MatrixSyncResponseStateEventDTO { + readonly content : JsonObject; + readonly type : string; + readonly event_id : string; + readonly sender : string; + readonly origin_server_ts : number; + readonly unsigned ?: MatrixSyncResponseUnsignedDataDTO; + readonly prev_content ?: JsonObject; + readonly state_key : string; +} + +export function isMatrixSyncResponseStateEventDTO (value: any): value is MatrixSyncResponseStateEventDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'content', + 'type', + 'event_id', + 'sender', + 'origin_server_ts', + 'unsigned', + 'prev_content', + 'state_key' + ]) + && isJsonObject(value?.content) + && isString(value?.type) + && isString(value?.event_id) + && isString(value?.sender) + && isInteger(value?.origin_server_ts) + && (isUndefined(value?.unsigned) || isMatrixSyncResponseUnsignedDataDTO(value?.unsigned)) + && (isUndefined(value?.prev_content) || isJsonObject(value?.prev_content)) + && isString(value?.state_key) + ); +} + +export function assertMatrixSyncResponseStateEventDTO (value: any) : void { + + if(!( isRegularObject(value) )) { + throw new TypeError(`value was not regular object: ${value}`); + } + + if(!( hasNoOtherKeysInDevelopment(value, [ + 'content', + 'type', + 'event_id', + 'sender', + 'origin_server_ts', + 'unsigned', + 'prev_content', + 'state_key' + ]) )) { + throw new TypeError(`value had extra keys: all keys: ${keys(value)}`); + } + + if(!( isJsonObject(value?.content) )) { + throw new TypeError(`Property "content" not JsonObject: ${value?.content}`); + } + + if(!( isString(value?.type) )) { + throw new TypeError(`Property "type" not valid: ${value?.type}`); + } + + if(!( isString(value?.event_id) )) { + throw new TypeError(`Property "event_id" not valid: ${value?.event_id}`); + } + + if(!( isString(value?.sender) )) { + throw new TypeError(`Property "sender" not valid: ${value?.sender}`); + } + + if(!( isInteger(value?.origin_server_ts) )) { + throw new TypeError(`Property "origin_server_ts" not valid: ${value?.origin_server_ts}`); + } + + if(!( (isUndefined(value?.unsigned) || isMatrixSyncResponseUnsignedDataDTO(value?.unsigned)) )) { + throw new TypeError(`Property "unsigned" not valid: ${explainMatrixSyncResponseUnsignedDataDTO(value?.unsigned)}`); + } + + if(!( (isUndefined(value?.prev_content) || isJsonObject(value?.prev_content)) )) { + throw new TypeError(`Property "prev_content" not valid: ${value?.prev_content}`); + } + + if(!( isString(value?.state_key) )) { + throw new TypeError(`Property "state_key" not valid: ${value?.state_key}`); + } + +} + +export function explainMatrixSyncResponseStateEventDTO (value: any) : string { + try { + assertMatrixSyncResponseStateEventDTO(value); + return 'No errors detected'; + } catch (err: any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseStateEventDTO (value: MatrixSyncResponseStateEventDTO): string { + return `MatrixSyncResponseStateEventDTO(${value})`; +} + +export function parseMatrixSyncResponseStateEventDTO (value: any): MatrixSyncResponseStateEventDTO | undefined { + if ( isMatrixSyncResponseStateEventDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseStrippedStateDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseStrippedStateDTO.ts new file mode 100644 index 0000000..3219fa2 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseStrippedStateDTO.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseUnsignedDataDTO, isMatrixSyncResponseUnsignedDataDTO } from "./MatrixSyncResponseUnsignedDataDTO"; +import { isJsonObject, JsonObject } from "../../../../../Json"; +import { isUndefined } from "../../../../../types/undefined"; +import { isString, isStringOrUndefined } from "../../../../../types/String"; +import { isNumberOrUndefined } from "../../../../../types/Number"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; + +export interface MatrixSyncResponseStrippedStateDTO { + readonly content : JsonObject; + readonly state_key : string; + readonly type : string; + readonly sender : string; + readonly origin_server_ts ?: number; + readonly unsigned ?: MatrixSyncResponseUnsignedDataDTO; + readonly event_id ?: string; +} + +export function isMatrixSyncResponseStrippedStateDTO (value: any): value is MatrixSyncResponseStrippedStateDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'content', + 'state_key', + 'type', + 'sender', + 'origin_server_ts', + 'unsigned', + 'event_id' + ]) + && isJsonObject(value?.content) + && isString(value?.state_key) + && isString(value?.type) + && isString(value?.sender) + && isNumberOrUndefined(value?.origin_server_ts) + && ( isUndefined(value?.unsigned) || isMatrixSyncResponseUnsignedDataDTO(value?.unsigned) ) + && isStringOrUndefined(value?.event_id) + ); +} + +export function assertMatrixSyncResponseStrippedStateDTO (value: any): void { + if(!( isRegularObject(value) )) { + throw new TypeError(`invalid: ${value}`); + } + if(!( hasNoOtherKeysInDevelopment(value, [ + 'content', + 'state_key', + 'type', + 'sender', + 'origin_server_ts', + 'unsigned', + 'event_id' + ]) )) { + throw new TypeError(`one key is extra: ${keys(value)}`); + } + if(!( isJsonObject(value?.content) )) { + throw new TypeError(`Property "content" invalid: ${value?.content}`); + } + if(!( isString(value?.state_key) )) { + throw new TypeError(`Property "state_key" invalid: ${value?.state_key}`); + } + if(!( isString(value?.type) )) { + throw new TypeError(`Property "type" invalid: ${value?.type}`); + } + if(!( isString(value?.sender) )) { + throw new TypeError(`Property "sender" invalid: ${value?.sender}`); + } + if(!( isNumberOrUndefined(value?.origin_server_ts) )) { + throw new TypeError(`Property "origin_server_ts" invalid: ${value?.origin_server_ts}`); + } + if(!( ( isUndefined(value?.unsigned) || isMatrixSyncResponseUnsignedDataDTO(value?.unsigned) ) )) { + throw new TypeError(`Property "unsigned" invalid: ${value?.unsigned}`); + } + if(!( isStringOrUndefined(value?.event_id) )) { + throw new TypeError(`Property "event_id" invalid: ${value?.event_id}`); + } + +} + +export function explainMatrixSyncResponseStrippedStateDTO (value : any) : string { + try { + assertMatrixSyncResponseStrippedStateDTO(value); + return 'No errors detected'; + } catch (err : any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseStrippedStateDTO (value: MatrixSyncResponseStrippedStateDTO): string { + return `MatrixSyncResponseStrippedStateDTO(${value})`; +} + +export function parseMatrixSyncResponseStrippedStateDTO (value: any): MatrixSyncResponseStrippedStateDTO | undefined { + if ( isMatrixSyncResponseStrippedStateDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseTimelineDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseTimelineDTO.ts new file mode 100644 index 0000000..4dc41c1 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseTimelineDTO.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseRoomEventDTO, + explainMatrixSyncResponseRoomEventDTO, + isMatrixSyncResponseRoomEventDTO +} from "./MatrixSyncResponseRoomEventDTO"; +import { concat } from "../../../../../functions/concat"; +import { find } from "../../../../../functions/find"; +import { isBoolean } from "../../../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../../../types/String"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; +import { isArrayOf } from "../../../../../types/Array"; + +export interface MatrixSyncResponseTimelineDTO { + readonly events : readonly MatrixSyncResponseRoomEventDTO[]; + readonly limited : boolean; + readonly prev_batch ?: string; +} + +export function getEventsFromMatrixSyncResponseTimelineDTO ( + value: MatrixSyncResponseTimelineDTO +) : readonly MatrixSyncResponseRoomEventDTO[] { + return concat([], value?.events ?? []); +} + +export function isMatrixSyncResponseTimelineDTO (value: any): value is MatrixSyncResponseTimelineDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'events', + 'limited', + 'prev_batch' + ]) + && isArrayOf(value?.events, isMatrixSyncResponseRoomEventDTO) + && isBoolean(value?.limited) + && isStringOrUndefined(value?.prev_batch) + ); +} + +export function assertMatrixSyncResponseTimelineDTO (value: any): void { + + if(!( isRegularObject(value) )) { + throw new TypeError(`value not object: ${value}`); + } + + if(!( hasNoOtherKeysInDevelopment(value, [ + 'events', + 'limited', + 'prev_batch' + ]))) { + throw new TypeError(`Extra properties in value: all keys: ${keys(value)}`); + } + + if(!( isArrayOf(value?.events, isMatrixSyncResponseRoomEventDTO))) { + const event = find(value?.events, item => !isMatrixSyncResponseRoomEventDTO(item)); + throw new TypeError(`Property "events" item was not correct: ${explainMatrixSyncResponseRoomEventDTO(event)}`); + } + + if(!( isBoolean(value?.limited))) { + throw new TypeError(`Property "limited" was not boolean: ${value?.limited}`); + } + + if(!( isString(value?.prev_batch))) { + throw new TypeError(`Property "prev_batch" was not string: ${value?.prev_batch}`); + } + +} + +export function explainMatrixSyncResponseTimelineDTO (value : any) : string { + try { + assertMatrixSyncResponseTimelineDTO(value); + return 'No errors detected'; + } catch (err : any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseTimelineDTO (value: MatrixSyncResponseTimelineDTO): string { + return `MatrixSyncResponseTimelineDTO(${value})`; +} + +export function parseMatrixSyncResponseTimelineDTO (value: any): MatrixSyncResponseTimelineDTO | undefined { + if ( isMatrixSyncResponseTimelineDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseToDeviceDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseToDeviceDTO.ts new file mode 100644 index 0000000..1cf8779 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseToDeviceDTO.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseEventDTO, isMatrixSyncResponseEventDTO } from "./MatrixSyncResponseEventDTO"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { isArrayOf } from "../../../../../types/Array"; + +export interface MatrixSyncResponseToDeviceDTO { + readonly events : readonly MatrixSyncResponseEventDTO[]; +} + +export function getEventsFromMatrixSyncResponseToDeviceDTO ( + value: MatrixSyncResponseToDeviceDTO +) : readonly MatrixSyncResponseEventDTO[] { + return value?.events ?? []; +} + +export function isMatrixSyncResponseToDeviceDTO (value: any): value is MatrixSyncResponseToDeviceDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'events' + ]) + && isArrayOf(value?.events, isMatrixSyncResponseEventDTO) + ); +} + +export function stringifyMatrixSyncResponseToDeviceDTO (value: MatrixSyncResponseToDeviceDTO): string { + return `MatrixSyncResponseToDeviceDTO(${value})`; +} + +export function parseMatrixSyncResponseToDeviceDTO (value: any): MatrixSyncResponseToDeviceDTO | undefined { + if ( isMatrixSyncResponseToDeviceDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseUnreadNotificationCountsDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseUnreadNotificationCountsDTO.ts new file mode 100644 index 0000000..e90b1de --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseUnreadNotificationCountsDTO.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isInteger } from "../../../../../types/Number"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; + +export interface MatrixSyncResponseUnreadNotificationCountsDTO { + readonly highlight_count : number; + readonly notification_count : number; +} + +export function isMatrixSyncResponseUnreadNotificationCountsDTO (value: any): value is MatrixSyncResponseUnreadNotificationCountsDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'highlight_count', + 'notification_count' + ]) + && isInteger(value?.highlight_count) + && isInteger(value?.notification_count) + ); +} + +export function stringifyMatrixSyncResponseUnreadNotificationCountsDTO (value: MatrixSyncResponseUnreadNotificationCountsDTO): string { + return `MatrixSyncResponseUnreadNotificationCountsDTO(${value})`; +} + +export function parseMatrixSyncResponseUnreadNotificationCountsDTO (value: any): MatrixSyncResponseUnreadNotificationCountsDTO | undefined { + if ( isMatrixSyncResponseUnreadNotificationCountsDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/sync/types/MatrixSyncResponseUnsignedDataDTO.ts b/matrix/types/response/sync/types/MatrixSyncResponseUnsignedDataDTO.ts new file mode 100644 index 0000000..7f17559 --- /dev/null +++ b/matrix/types/response/sync/types/MatrixSyncResponseUnsignedDataDTO.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { MatrixSyncResponseEventDTO, isMatrixSyncResponseEventDTO } from "./MatrixSyncResponseEventDTO"; +import { isJsonObjectOrUndefined, JsonObject } from "../../../../../Json"; +import { MatrixUserId, isMatrixUserId } from "../../../core/MatrixUserId"; +import { isUndefined } from "../../../../../types/undefined"; +import { isStringOrUndefined } from "../../../../../types/String"; +import { isIntegerOrUndefined } from "../../../../../types/Number"; +import { isRegularObject } from "../../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../../types/OtherKeys"; +import { keys } from "../../../../../functions/keys"; + +/** + * + * Note! The property "redacted_because" is defined as a string in room specs, but object in + * ClientServer-API. + * + * @see https://matrix.org/docs/spec/client_server/latest#get-matrix-client-r0-sync + * @see https://spec.matrix.org/unstable/rooms/v1/ + * @see https://spec.matrix.org/unstable/rooms/v3/ + * @see https://spec.matrix.org/unstable/rooms/v4/ + */ +export interface MatrixSyncResponseUnsignedDataDTO { + + readonly age ?: number; + readonly prev_content ?: JsonObject; + readonly prev_sender ?: MatrixUserId; + readonly redacted_because ?: MatrixSyncResponseEventDTO; + readonly replaces_state ?: string; + readonly transaction_id ?: string; + +} + +export function isMatrixSyncResponseUnsignedDataDTO (value: any): value is MatrixSyncResponseUnsignedDataDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'age', + 'prev_content', + 'prev_sender', + 'redacted_because', + 'replaces_state', + 'transaction_id' + ]) + && isIntegerOrUndefined(value?.age) + && isJsonObjectOrUndefined(value?.prev_content) + && ( isUndefined(value?.prev_sender) || isMatrixUserId(value?.prev_sender) ) + && ( isUndefined(value?.redacted_because) || isMatrixSyncResponseEventDTO(value?.redacted_because) ) + && isStringOrUndefined(value?.replaces_state) + && isStringOrUndefined(value?.transaction_id) + ); +} + +export function assertMatrixSyncResponseUnsignedDataDTO (value: any) : void { + + if(!( isRegularObject(value) )) { + throw new TypeError(`Value was not regular object`); + } + + if(!( hasNoOtherKeysInDevelopment(value, [ + 'age', + 'prev_content', + 'prev_sender', + 'redacted_because', + 'replaces_state', + 'transaction_id' + ]) )) { + throw new TypeError(`Value had extra properties: All keys: ${keys(value)}`); + } + + if(!( isIntegerOrUndefined(value?.age) )) { + throw new TypeError(`Property "age" was not valid: ${value?.age}`); + } + + if(!( isJsonObjectOrUndefined(value?.prev_content) )) { + throw new TypeError(`Property "prev_content" was not valid: ${value?.prev_content}`); + } + + if(!( isUndefined(value?.prev_sender) || isMatrixUserId(value?.prev_sender) )) { + throw new TypeError(`Property "prev_sender" was not valid: ${value?.prev_sender}`); + } + + if(!( ( isUndefined(value?.redacted_because) || isMatrixSyncResponseEventDTO(value?.redacted_because) ) )) { + throw new TypeError(`Property "redacted_because" was not valid: ${value?.redacted_because}`); + } + + if(!( isStringOrUndefined(value?.replaces_state) )) { + throw new TypeError(`Property "replaces_state" was not valid: ${value?.replaces_state}`); + } + + if(!( isStringOrUndefined(value?.transaction_id) )) { + throw new TypeError(`Property "transaction_id" was not valid: ${value?.transaction_id}`); + } + +} + +export function explainMatrixSyncResponseUnsignedDataDTO (value: any) : string { + try { + assertMatrixSyncResponseUnsignedDataDTO(value); + return 'No errors detected'; + } catch (err:any) { + return err?.message; + } +} + +export function stringifyMatrixSyncResponseUnsignedDataDTO (value: MatrixSyncResponseUnsignedDataDTO): string { + return `MatrixSyncResponseUnsignedData(${value})`; +} + +export function parseMatrixSyncResponseUnsignedDataDTO (value: any): MatrixSyncResponseUnsignedDataDTO | undefined { + if ( isMatrixSyncResponseUnsignedDataDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/response/types b/matrix/types/response/types new file mode 100644 index 0000000..e69de29 diff --git a/matrix/types/response/whoami/MatrixWhoAmIResponseDTO.ts b/matrix/types/response/whoami/MatrixWhoAmIResponseDTO.ts new file mode 100644 index 0000000..5ac41b4 --- /dev/null +++ b/matrix/types/response/whoami/MatrixWhoAmIResponseDTO.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isBooleanOrUndefined } from "../../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface MatrixWhoAmIResponseDTO { + readonly user_id : string; + readonly device_id ?: string; + readonly is_guest ?: boolean; +} + +export function createMatrixWhoAmIResponseDTO ( + user_id : string, + device_id ?: string | undefined, + is_guest ?: boolean | undefined +): MatrixWhoAmIResponseDTO { + return { + user_id, + device_id, + is_guest + }; +} + +export function isMatrixWhoAmIResponseDTO (value: any): value is MatrixWhoAmIResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'device_id', + 'is_guest', + 'user_id' + ]) + && isString(value?.user_id) + && isStringOrUndefined(value?.device_id) + && isBooleanOrUndefined(value?.is_guest) + ); +} + +export function stringifyMatrixWhoAmIResponseDTO (value: MatrixWhoAmIResponseDTO): string { + return `MatrixWhoAmIResponseDTO(${value})`; +} + +export function parseMatrixWhoAmIResponseDTO (value: any): MatrixWhoAmIResponseDTO | undefined { + if ( isMatrixWhoAmIResponseDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/synapse/SynapsePreRegisterResponseDTO.ts b/matrix/types/synapse/SynapsePreRegisterResponseDTO.ts new file mode 100644 index 0000000..0ecaaab --- /dev/null +++ b/matrix/types/synapse/SynapsePreRegisterResponseDTO.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +/** + * @see https://github.com/heusalagroup/hghs/issues/1 + */ +export interface SynapsePreRegisterResponseDTO { + readonly nonce : string; +} + +export function createSynapsePreRegisterResponseDTO ( + nonce : string +): SynapsePreRegisterResponseDTO { + return {nonce}; +} + +export function isSynapsePreRegisterResponseDTO (value: any): value is SynapsePreRegisterResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'nonce' + ]) + && isString(value?.nonce) + ); +} + +export function stringifySynapsePreRegisterResponseDTO (value: SynapsePreRegisterResponseDTO): string { + return `SynapsePreRegisterResponseDTO(${value})`; +} + +export function parseSynapsePreRegisterResponseDTO (value: any): SynapsePreRegisterResponseDTO | undefined { + if ( isSynapsePreRegisterResponseDTO(value) ) return value; + return undefined; +} diff --git a/matrix/types/synapse/SynapseRegisterRequestDTO.ts b/matrix/types/synapse/SynapseRegisterRequestDTO.ts new file mode 100644 index 0000000..7f2e4a3 --- /dev/null +++ b/matrix/types/synapse/SynapseRegisterRequestDTO.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isBoolean } from "../../../types/Boolean"; +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +/** + * @see https://matrix-org.github.io/synapse/latest/admin_api/register_api.html + */ +export interface SynapseRegisterRequestDTO { + + readonly nonce : string; + readonly username : string; + readonly displayname : string; + readonly password : string; + readonly admin : boolean; + readonly mac : string; + +} + +export function isSynapseRegisterRequestDTO (value: any): value is SynapseRegisterRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'nonce', + 'username', + 'displayname', + 'password', + 'admin', + 'mac' + ]) + && isString(value?.nonce) + && isString(value?.username) + && isString(value?.displayname) + && isString(value?.password) + && isBoolean(value?.admin) + && isString(value?.mac) + ); +} + +export function stringifySynapseRegisterRequestDTO (value: SynapseRegisterRequestDTO): string { + return `SynapseRegisterRequestDTO(${value})`; +} + +export function parseSynapseRegisterRequestDTO (value: any): SynapseRegisterRequestDTO | undefined { + if ( isSynapseRegisterRequestDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/types/synapse/SynapseRegisterResponseDTO.ts b/matrix/types/synapse/SynapseRegisterResponseDTO.ts new file mode 100644 index 0000000..fc174a8 --- /dev/null +++ b/matrix/types/synapse/SynapseRegisterResponseDTO.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +/** + * @see https://github.com/heusalagroup/hghs/issues/1 + */ +export interface SynapseRegisterResponseDTO { + readonly access_token : string; + readonly user_id : string; + readonly home_server : string; + readonly device_id : string; +} + +export function createSynapseRegisterResponseDTO ( + access_token : string, + user_id : string, + home_server : string, + device_id : string, +) : SynapseRegisterResponseDTO { + return { + access_token, + user_id, + home_server, + device_id + }; +} + +export function isSynapseRegisterResponseDTO (value: any): value is SynapseRegisterResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'access_token', + 'user_id', + 'home_server', + 'device_id' + ]) + && isString(value?.access_token) + && isString(value?.user_id) + && isString(value?.home_server) + && isString(value?.device_id) + ); +} + +export function stringifySynapseRegisterResponseDTO (value: SynapseRegisterResponseDTO): string { + return `SynapseRegisterResponseDTO(${value})`; +} + +export function parseSynapseRegisterResponseDTO (value: any): SynapseRegisterResponseDTO | undefined { + if ( isSynapseRegisterResponseDTO(value) ) return value; + return undefined; +} + + diff --git a/matrix/utils/SynapseUtils.ts b/matrix/utils/SynapseUtils.ts new file mode 100644 index 0000000..5bfb658 --- /dev/null +++ b/matrix/utils/SynapseUtils.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { createHmac } from 'crypto'; +import { SynapseRegisterRequestDTO } from "../types/synapse/SynapseRegisterRequestDTO"; +import { MatrixRegisterKind } from "../types/request/register/types/MatrixRegisterKind"; + +export class SynapseUtils { + + public static createRegisterDTO ( + sharedSecret : string, + nonce : string, + username : string, + displayName : string, + password : string, + admin : boolean, + userType ?: MatrixRegisterKind + ) : SynapseRegisterRequestDTO { + + const mac = this.generateRegisterMAC(sharedSecret, nonce, username, password, admin, userType); + + return { + nonce, + username, + 'displayname': displayName, + password, + admin, + mac + }; + + } + + public static generateRegisterMAC ( + sharedSecret : string, + nonce : string, + username : string, + password : string, + admin : boolean, + userType ?: MatrixRegisterKind + ) : string { + + let mac = createHmac('sha1', sharedSecret) + .update(nonce, 'utf8') + .update('\x00') + .update(username, 'utf8') + .update('\x00') + .update(password, 'utf8') + .update('\x00') + .update( admin ? 'admin' : 'notadmin') + ; + + if (userType) { + mac = mac.update('\x00').update(userType, 'utf8'); + } + + return mac.digest('hex'); + + } + +} + + diff --git a/mocks/MockOpenAiClient.ts b/mocks/MockOpenAiClient.ts new file mode 100644 index 0000000..d8f5871 --- /dev/null +++ b/mocks/MockOpenAiClient.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. +/** + * @module + * @overview + * + * Mocked version of `OpenAiClient` for testing purposes. + * + * Usage example: + * + * ```typescript + * import { HgAiCommandServiceImpl } from "./HgAiCommandServiceImpl"; + * import { MockOpenAiClient } from "./MockOpenAiClient"; + * + * describe("HgAiCommandServiceImpl", () => { + * let service: HgAiCommandServiceImpl; + * let client: MockOpenAiClient; + * + * beforeEach(() => { + * client = new MockOpenAiClient(); + * service = new HgAiCommandServiceImpl(client); + * }); + * + * // Your tests go here + * }); + * ``` + * + */ + +import { OpenAiModel } from "../openai/types/OpenAiModel"; +import { OpenAiCompletionResponseDTO } from "../openai/dto/OpenAiCompletionResponseDTO"; +import { OpenAiEditResponseDTO } from "../openai/dto/OpenAiEditResponseDTO"; +import { OpenAiClient } from "../openai/OpenAiClient"; + +/** + * Mocked version of `OpenAiClient` for testing purposes. + * + * Usage example: + * + * ```typescript + * import { HgAiCommandServiceImpl } from "./HgAiCommandServiceImpl"; + * import { MockOpenAiClient } from "./MockOpenAiClient"; + * + * describe("HgAiCommandServiceImpl", () => { + * let service: HgAiCommandServiceImpl; + * let client: MockOpenAiClient; + * + * beforeEach(() => { + * client = new MockOpenAiClient(); + * service = new HgAiCommandServiceImpl(client); + * }); + * + * // Your tests go here + * }); + * ``` + */ +export class MockOpenAiClient implements OpenAiClient { + + getUrl(): string { + return ""; + } + + async getCompletion( + // @ts-ignore + prompt : string, + // @ts-ignore + model ?: OpenAiModel | string | undefined, + // @ts-ignore + max_tokens ?: number | undefined, + // @ts-ignore + temperature ?: number | undefined, + // @ts-ignore + top_p ?: number | undefined, + // @ts-ignore + frequency_penalty ?: number | undefined, + // @ts-ignore + presence_penalty ?: number | undefined + ): Promise { + return {} as OpenAiCompletionResponseDTO; + } + + async getEdit( + // @ts-ignore + instruction : string, + // @ts-ignore + input ?: string | undefined, + // @ts-ignore + model ?: OpenAiModel | string | undefined, + // @ts-ignore + n ?: number | undefined, + // @ts-ignore + temperature ?: number | undefined, + // @ts-ignore + top_p ?: number | undefined + ): Promise { + return {} as OpenAiEditResponseDTO; + } + +} diff --git a/mocks/MockPermissionManager.ts b/mocks/MockPermissionManager.ts new file mode 100644 index 0000000..fb51f44 --- /dev/null +++ b/mocks/MockPermissionManager.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { PermissionManager } from "../types/PermissionManager"; +import { PermissionList, PermissionObject, PermissionUtils } from "../PermissionUtils"; + +/** + * Permission manager intended to be used in tests + */ +export class MockPermissionManager implements PermissionManager { + + private _entityId: string; + private _targetId: string | undefined; + private _entityPermissions: PermissionList; + + public constructor ( + entityId: string, + targetId: string | undefined, + entityPermissions: PermissionList + ) { + this._entityId = entityId; + this._targetId = targetId; + this._entityPermissions = entityPermissions; + } + + public setState ( + id: string, + targetId: string | undefined, + entityPermissions: PermissionList + ) { + this._entityId = id; + this._targetId = targetId; + this._entityPermissions = entityPermissions; + } + + public async getEntityPermissionList ( + entityId: string, + targetId ?: string + ): Promise> { + return this._entityId === entityId && this._targetId === targetId ? this._entityPermissions : []; + } + + public async checkEntityPermission ( + checkPermissions: PermissionList, + entityId: string, + targetId ?: string + ): Promise { + const entityPermissions = await this.getEntityPermissionList(entityId, targetId); + return PermissionUtils.checkPermissionList( + checkPermissions, + entityPermissions + ); + } + +} diff --git a/modules/moment.ts b/modules/moment.ts new file mode 100644 index 0000000..338bedb --- /dev/null +++ b/modules/moment.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import moment from "moment-timezone"; + +import { + tz as momentTz, + utc as parseUtc +} from 'moment-timezone'; + +export type momentType = moment.Moment; + +export { + moment, + momentTz, + parseUtc +}; diff --git a/native/NativeMessageType.ts b/native/NativeMessageType.ts new file mode 100644 index 0000000..9501ab4 --- /dev/null +++ b/native/NativeMessageType.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../types/Enum"; + +export enum NativeMessageType { + SEND_SMS = "fi.hg.native.SEND_SMS" +} + +export function isNativeMessageType (value: any) : value is NativeMessageType { + switch (value) { + case NativeMessageType.SEND_SMS: + return true; + default: + return false; + } +} + +export function explainNativeMessageType (value : any) : string { + return explainEnum("NativeMessageType", NativeMessageType, isNativeMessageType, value); +} + +export function stringifyNativeMessageType (value : NativeMessageType) : string { + switch (value) { + case NativeMessageType.SEND_SMS : return 'SEND_SMS'; + } + throw new TypeError(`Unsupported NativeMessageType value: ${value}`) +} + +export function parseNativeMessageType (value: any) : NativeMessageType | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'SEND_SMS' : return NativeMessageType.SEND_SMS; + default : return undefined; + } +} diff --git a/native/NativeSmsMessage.ts b/native/NativeSmsMessage.ts new file mode 100644 index 0000000..d1b42de --- /dev/null +++ b/native/NativeSmsMessage.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { NativeMessageType } from "./NativeMessageType"; +import { explain, explainNot, explainOk, explainProperty } from "../types/explain"; +import { explainString, isString } from "../types/String"; +import { explainRegularObject, isRegularObject } from "../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../types/OtherKeys"; + +/** + * Intended to be sent from webview to the native application to send a text + * body + */ +export interface NativeSmsMessage { + + readonly type : NativeMessageType.SEND_SMS; + + /** + * Target phone number + */ + readonly to : string; + + /** + * Message content + */ + readonly body : string; + +} + +export function createNativeSmsMessage ( + to: string, + body : string +) : NativeSmsMessage { + return { + type: NativeMessageType.SEND_SMS, + to, + body + }; +} + +export function isNativeSmsMessage (value: any) : value is NativeSmsMessage { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'type', + 'to', + 'body' + ]) + && value?.type === NativeMessageType.SEND_SMS + && isString(value?.to) + && isString(value?.body) + ); +} + +export function explainNativeSmsMessage (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'type', + 'to', + 'body' + ]) + , explainProperty("type", value?.type === NativeMessageType.SEND_SMS ? explainOk() : explainNot('NativeMessageType.SEND_SMS') ) + , explainProperty("to", explainString(value?.to)) + , explainProperty("body", explainString(value?.body)) + ] + ); +} + +export function stringifyNativeSmsMessage (value : NativeSmsMessage) : string { + return `NativeSmsMessage(${value})`; +} + +export function parseNativeSmsMessage (value: any) : NativeSmsMessage | undefined { + if (isNativeSmsMessage(value)) return value; + return undefined; +} diff --git a/op/OpAccountDataClient.ts b/op/OpAccountDataClient.ts new file mode 100644 index 0000000..173c0ce --- /dev/null +++ b/op/OpAccountDataClient.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpAccountListDTO } from "./dto/OpAccountListDTO"; +import { OpAccountDetailsDTO } from "./dto/OpAccountDetailsDTO"; +import { OpTransactionListDTO } from "./dto/OpTransactionListDTO"; + +/** + * OP Corporate AccountData API client + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api + */ +export interface OpAccountDataClient { + + /** + * Returns list of accounts and balances. + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/accounts + */ + getAccountList (): Promise; + + /** + * Returns account details for single account. + * + * @param surrogateId Account surrogateId. You can get this value by using .getAccountList() call. + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/account + */ + getAccountDetails ( + surrogateId: string + ) : Promise; + + /** + * Returns account transactions. + * + * Note that the sandbox environment doesn't seem to return coherent time + * values between calls. + * + * @param surrogateId Account surrogateId. You can get this value by using .getAccountList() call. + * @param fromTimestamp Note, this is microseconds! + * @param maxPast + * @param maxFuture + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/transactionsV2 + * @see DateUtils.getMicroSeconds() + */ + getTransactionListFromTimestamp ( + surrogateId : string, + fromTimestamp : number, + maxPast ?: number, + maxFuture ?: number, + ) : Promise; + + /** + * Returns account transactions using object Id. + * + * It seems the sandbox environment does not support object IDs. Not easy + * to test or develop using this without a production environment. + * + * @param surrogateId Account surrogateId. You can get this value by using .getAccountList() call. + * @param objectId + * @param maxPast + * @param maxFuture + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/transactionsV2 + */ + getTransactionListFromObjectId ( + surrogateId : string, + objectId : string, + maxPast ?: number, + maxFuture ?: number, + ) : Promise; + + /** + * Returns account transactions between two timestamps. + * + * Note that the sandbox environment doesn't seem to return coherent time + * values between calls. Not easy to test without production environment. + * + * @param surrogateId Account surrogateId. You can get this value by using .getAccountList() call. + * @param fromTimestamp Note, this is microseconds! Use something like `Date.now()*1000`. Any timestamp equal or greater than this value will be included in the set. + * @param toTimestamp Note, this is microseconds! Use something like `Date.now()*1000`. Any timestamp equal or less than this value will be included in the set. + * @param bufferSize How many items to request with single request. + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/transactionsV2 + */ + getTransactionListFromTimestampRange ( + surrogateId : string, + fromTimestamp : number, + toTimestamp : number, + bufferSize : number, + ) : Promise; + +} diff --git a/op/OpAccountDataClientImpl.system.test.ts b/op/OpAccountDataClientImpl.system.test.ts new file mode 100644 index 0000000..5471251 --- /dev/null +++ b/op/OpAccountDataClientImpl.system.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ProcessUtils } from "../ProcessUtils"; +ProcessUtils.initEnvFromDefaultFiles(); + +import HTTP from "http"; +import HTTPS from "https"; +import { OP_SANDBOX_URL } from "./op-constants"; +// @ts-ignore +import { NodeRequestClient } from "../../node/requestClient/node/NodeRequestClient"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { OpAccountDataClientImpl } from "./OpAccountDataClientImpl"; +import { OpAuthClientImpl } from "./OpAuthClientImpl"; +import { map } from "../functions/map"; +import { OpAccountDTO } from "./dto/OpAccountDTO"; +import { shuffle } from "../functions/shuffle"; +import { slice } from "../functions/slice"; +import { parseInteger } from "../types/Number"; +import { LogLevel } from "../types/LogLevel"; +import { OpAccountDataClient } from "./OpAccountDataClient"; +import { OpAuthClient } from "./OpAuthClient"; + +const API_SERVER = process.env.OP_SANDBOX_URL ?? OP_SANDBOX_URL; +const CLIENT_ID = process.env.OP_CLIENT_ID ?? ''; +const CLIENT_SECRET = process.env.OP_CLIENT_SECRET ?? ''; +const MTLS_KEY = process.env.OP_MTLS_KEY ?? ''; +const MTLS_CRT = process.env.OP_MTLS_CRT ?? ''; + +/** + * Since there may be a way too many transactions to test every one, we test + * only this amount by random. + * + * The intention is so that we could find if our DTOs have correct types. + * + * This will make this test unstable though. It may fail sometimes and not fail + * other times. + */ +const TRANSACTION_TEST_LIMIT = parseInteger(process.env.OP_TRANSACTION_TEST_LIMIT ?? '5') ?? 5; + +/** + * To run these tests, create `.env` file like this: + * ``` + * OP_SANDBOX_URL=sandbox-url + * OP_CLIENT_ID=clientId + * OP_CLIENT_SECRET=clientSecret + * OP_MTLS_KEY="-----BEGIN RSA PRIVATE KEY----- + * ... + * -----END RSA PRIVATE KEY-----" + * OP_MTLS_CRT="-----BEGIN CERTIFICATE----- + * ... + * -----END CERTIFICATE-----" + * ``` + */ +describe('system', () => { + (CLIENT_ID ? describe : describe.skip)('OpAccountDataClientImpl', () => { + let accountDataClient : OpAccountDataClient; + + beforeAll(() => { + RequestClientImpl.setLogLevel(LogLevel.NONE); + NodeRequestClient.setLogLevel(LogLevel.NONE); + OpAuthClientImpl.setLogLevel(LogLevel.NONE); + OpAccountDataClientImpl.setLogLevel(LogLevel.NONE); + }); + + beforeEach(async () => { + const requestClient = RequestClientImpl.create( + NodeRequestClient.create( + HTTP, + HTTPS, + { + cert: MTLS_CRT, + key: MTLS_KEY, + } + ) + ); + const authClient : OpAuthClient = OpAuthClientImpl.create( + requestClient, + API_SERVER, + ); + accountDataClient = OpAccountDataClientImpl.create( + requestClient, + authClient, + API_SERVER, + ); + await authClient.authenticate( + CLIENT_ID, + CLIENT_SECRET, + ); + }); + + describe('#getAccountList', () => { + it('should return a list of accounts', async () => { + const accountList = await accountDataClient.getAccountList(); + expect(accountList).toBeDefined(); + expect(Array.isArray(accountList)).toBeTruthy(); + }); + }); + + describe('surrogateId operations', () => { + + let surrogateIdList : string[]; + + beforeEach( async () => { + const accountList = await accountDataClient.getAccountList(); + expect(accountList.length).toBeGreaterThanOrEqual(1); + surrogateIdList = map( + accountList, + (item: OpAccountDTO) => item.surrogateId + ); + }); + + describe('#getAccountDetails', () => { + + it('should return details for a all accounts', async () => { + for(let i=0; i { + + it('should return a list of transactions for each SurrogateId', async () => { + for(let i=0; i { + + it('should return a list of transactions', async () => { + for(let i=0; i item.objectId + )), 0, TRANSACTION_TEST_LIMIT); + + for (let i=0; i. All rights reserved. + +import { jest } from '@jest/globals'; +import { OpAuthClient } from "./OpAuthClient"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { OpAccountListDTO } from "./dto/OpAccountListDTO"; +import { OpAccountDataClientImpl } from "./OpAccountDataClientImpl"; +import { OpAccountDetailsDTO } from "./dto/OpAccountDetailsDTO"; +import { OpTransactionListDTO } from "./dto/OpTransactionListDTO"; +import { createOpTransactionDTO } from "./dto/OpTransactionDTO"; + +describe('OpAccountDataClientImpl', () => { + + let mockAuthClient: jest.Mocked; + let mockClient: jest.Mocked; + + beforeEach(() => { + mockAuthClient = { + isAuthenticated: jest.fn(), + authenticate: jest.fn(), + getAccessKey: jest.fn(), + } as any; + + mockClient = { + getJson: jest.fn(), + } as any; + }); + + describe('#getAccountList', () => { + it('returns the account list', async () => { + const expectedAccountList: OpAccountListDTO = [ + { + bic: 'OKOYFIHH', + iban: 'FI7450009420999999', + name: 'Companys payroll account', + balance: '-12.3', + currency: 'EUR', + surrogateId: 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', + productNames: { + property1: 'string', + property2: 'string' + } + } + ]; + mockClient.getJson.mockResolvedValueOnce(expectedAccountList as unknown as any); + mockAuthClient.isAuthenticated.mockReturnValueOnce(true); + const client = OpAccountDataClientImpl.create( + mockClient, mockAuthClient, 'url'); + + const accountList = await client.getAccountList(); + expect(accountList).toEqual(expectedAccountList); + }); + }); + + describe('#getAccountDetails', () => { + it('returns the account details for a specific surrogateId', async () => { + const expectedAccountDetails: OpAccountDetailsDTO = { + bic: 'OKOYFIHH', + iban: 'FI7450009499999999', + dueDate: '29.11.2019', + ownerId: '1234567-8', + currency: 'EUR', + netBalance: '222.22', + accountName: 'Some name given by client', + creditLimit: 0, + surrogateId: 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', + accountOwner: 'Firstname Lastname', + creationDate: '29.11.2011', + grossBalance: '222.22', + intraDayLimit: '222.22' + }; + mockClient.getJson.mockResolvedValueOnce(expectedAccountDetails as unknown as any); + mockAuthClient.isAuthenticated.mockReturnValueOnce(true); + const client = OpAccountDataClientImpl.create(mockClient, mockAuthClient, 'url'); + + const accountDetails = await client.getAccountDetails('surrogateId'); + expect(accountDetails).toEqual(expectedAccountDetails); + }); + }); + + describe('#getTransactionListFromTimestamp', () => { + it('returns the transaction list from a specific timestamp', async () => { + const expectedTransactionList: OpTransactionListDTO = [ + createOpTransactionDTO( + '100.00', '200.00', '100.00', 'Test', 'EUR', '1234', '1234', 'OKOYFIHH', + '1234', 'RF1234', '2023-07-21', 'John Doe', '2023-07-21', 'OKOYFIHH', '2023-07-21', 'Jane Doe', + 'FI7450009420999999', 'FI7450009420999999', 'end2end', 1234567890, '101', 'Transaction', '1234-5678-9012-3456' + ) + ]; + mockClient.getJson.mockResolvedValueOnce(expectedTransactionList as unknown as any); + mockAuthClient.isAuthenticated.mockReturnValueOnce(true); + const client = OpAccountDataClientImpl.create(mockClient, mockAuthClient, 'url'); + + const transactionList = await client.getTransactionListFromTimestamp('surrogateId', 1234567890); + expect(transactionList).toEqual(expectedTransactionList); + }); + }); + + describe('#getTransactionListFromObjectId', () => { + it('returns the transaction list from a specific objectId', async () => { + const expectedTransactionList: OpTransactionListDTO = [ + createOpTransactionDTO( + '100.00', '200.00', '100.00', 'Test', 'EUR', '1234', '1234', 'OKOYFIHH', + '1234', 'RF1234', '2023-07-21', 'John Doe', '2023-07-21', 'OKOYFIHH', '2023-07-21', 'Jane Doe', + 'FI7450009420999999', 'FI7450009420999999', 'end2end', 1234567890, '101', 'Transaction', '1234-5678-9012-3456' + ) + ]; + mockClient.getJson.mockResolvedValueOnce(expectedTransactionList as unknown as any); + mockAuthClient.isAuthenticated.mockReturnValueOnce(true); + const client = OpAccountDataClientImpl.create(mockClient, mockAuthClient, 'url'); + + const transactionList = await client.getTransactionListFromObjectId('surrogateId', 'objectId'); + expect(transactionList).toEqual(expectedTransactionList); + }); + }); + +}); diff --git a/op/OpAccountDataClientImpl.ts b/op/OpAccountDataClientImpl.ts new file mode 100644 index 0000000..e217513 --- /dev/null +++ b/op/OpAccountDataClientImpl.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpAccountDataClient } from "./OpAccountDataClient"; +import { LogService } from "../LogService"; +import { + OP_ACCOUNT_DATA_GET_ACCOUNT_DETAILS_PATH, + OP_ACCOUNT_DATA_GET_ACCOUNT_LIST_PATH, + OP_ACCOUNT_DATA_GET_TRANSACTION_LIST_PATH, + OP_PRODUCTION_URL +} from "./op-constants"; +import { OpAuthClient } from "./OpAuthClient"; +import { LogLevel } from "../types/LogLevel"; +import { explainOpAccountListDTO, isOpAccountListDTO, OpAccountListDTO } from "./dto/OpAccountListDTO"; +import { explainOpAccountDetailsDTO, isOpAccountDetailsDTO, OpAccountDetailsDTO } from "./dto/OpAccountDetailsDTO"; +import { explainOpTransactionListDTO, isOpTransactionListDTO, OpTransactionListDTO } from "./dto/OpTransactionListDTO"; +import { QueryParamUtils } from "../QueryParamUtils"; +import { OpRequestUtils } from "./OpRequestUtils"; +import { RequestClient } from "../RequestClient"; +import { forEach } from "../functions/forEach"; +import { OpTransactionDTO } from "./dto/OpTransactionDTO"; + +const LOG = LogService.createLogger( 'OpAccountDataClientImpl' ); + +/** + * OP Corporate Payment API implementation + */ +export class OpAccountDataClientImpl implements OpAccountDataClient, OpAuthClient { + + private readonly _client: RequestClient; + private readonly _auth: OpAuthClient; + private readonly _url: string; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static create ( + client: RequestClient, + auth: OpAuthClient, + url : string = OP_PRODUCTION_URL + ) : OpAccountDataClientImpl { + return new OpAccountDataClientImpl( + client, + auth, + url, + ); + } + + private constructor ( + client: RequestClient, + auth: OpAuthClient, + url: string, + ) { + this._client = client; + this._auth = auth; + this._url = url; + } + + /** + * @inheritDoc + */ + public isAuthenticated () : boolean { + return this._auth.isAuthenticated(); + } + + /** + * @inheritDoc + */ + public async authenticate ( + clientId : string, + clientSecret : string, + ) : Promise { + await this._auth.authenticate( + clientId, + clientSecret, + ); + } + + /** + * @inheritDoc + */ + public getAccessKey() : string { + return this._auth.getAccessKey(); + } + + /** + * @inheritDoc + */ + public async getAccountList (): Promise { + return await OpRequestUtils.getJsonRequest( + this._client, + this._auth, + this._url, + OP_ACCOUNT_DATA_GET_ACCOUNT_LIST_PATH, + isOpAccountListDTO, + explainOpAccountListDTO, + "OpAccountListDTO", + ); + } + + /** + * @inheritDoc + */ + public async getAccountDetails ( + surrogateId: string + ): Promise { + return await OpRequestUtils.getJsonRequest( + this._client, + this._auth, + this._url, + OP_ACCOUNT_DATA_GET_ACCOUNT_DETAILS_PATH(surrogateId), + isOpAccountDetailsDTO, + explainOpAccountDetailsDTO, + "OpAccountDetailsDTO", + ); + } + + /** + * @inheritDoc + */ + public async getTransactionListFromTimestamp ( + surrogateId : string, + fromTimestamp : number, + maxPast ?: number, + maxFuture ?: number, + ): Promise { + const queryParams : string = QueryParamUtils.stringifyQueryParams( + { + fromTimestamp: `${fromTimestamp.toFixed(0)}`, + ...(maxPast !== undefined ? {maxPast: `${maxPast.toFixed(0)}`} : {}), + ...(maxFuture !== undefined ? {maxFuture: `${maxFuture.toFixed(0)}`} : {}), + } + ); + return await OpRequestUtils.getJsonRequest( + this._client, + this._auth, + this._url, + `${OP_ACCOUNT_DATA_GET_TRANSACTION_LIST_PATH(surrogateId)}${queryParams}`, + isOpTransactionListDTO, + explainOpTransactionListDTO, + "OpTransactionListDTO", + ); + } + + /** + * @inheritDoc + */ + public async getTransactionListFromObjectId ( + surrogateId: string, + objectId : string, + maxPast ?: number, + maxFuture ?: number, + ): Promise { + const queryParams : string = QueryParamUtils.stringifyQueryParams( + { + objectId, + ...(maxPast !== undefined ? {maxPast: `${maxPast.toFixed(0)}`} : {}), + ...(maxFuture !== undefined ? {maxFuture: `${maxFuture.toFixed(0)}`} : {}), + } + ); + return await OpRequestUtils.getJsonRequest( + this._client, + this._auth, + this._url, + `${OP_ACCOUNT_DATA_GET_TRANSACTION_LIST_PATH(surrogateId)}${queryParams}`, + isOpTransactionListDTO, + explainOpTransactionListDTO, + "OpTransactionListDTO", + ); + } + + /** + * @inheritDoc + */ + public async getTransactionListFromTimestampRange ( + surrogateId : string, + fromTimestamp : number, + toTimestamp : number, + bufferSize : number = 150, + ) : Promise { + let list : OpTransactionListDTO; + let fullList : OpTransactionDTO[] = []; + let prevStartTime = fromTimestamp; + do { + + LOG.debug(`Fetching from ${fromTimestamp} to ${toTimestamp} for ${bufferSize} items`); + list = await this.getTransactionListFromTimestamp( + surrogateId, + fromTimestamp, + 0, + bufferSize + ); + prevStartTime = fromTimestamp; + + if ( list.length > 0 ) { + let breakLoop : boolean = false; + forEach( + list, + (item: OpTransactionDTO) : void => { + const time : number = item.timestamp; + if (time > toTimestamp) { + breakLoop = true; + } else { + if (time >= prevStartTime) { + fullList.push(item); + if (time + 1 > fromTimestamp) { + fromTimestamp = time + 1; + } + } else { + LOG.warn(`Warning! Got older items with time ${time}: `, item); + } + } + } + ); + if (breakLoop) break; + } + + if (prevStartTime === fromTimestamp) { + break; + } + + } while ( list.length > 0 ); + + // Sort by timestamp + fullList.sort(( + a: OpTransactionDTO, + b: OpTransactionDTO, + ) : number => { + const aa : number = a.timestamp; + const bb : number = b.timestamp; + if (aa === bb) return 0; + return aa < bb ? -1 : 1; + }); + + return fullList; + } + +} diff --git a/op/OpAuthClient.ts b/op/OpAuthClient.ts new file mode 100644 index 0000000..62eacd2 --- /dev/null +++ b/op/OpAuthClient.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Auth client for OP APIs + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#section/Usage-example + */ +export interface OpAuthClient { + + /** + * Returns true if we have a access token + */ + isAuthenticated () : boolean; + + /** + * Authenticates + * + * @param clientId OP client ID + * @param clientSecret OP client secret + */ + authenticate ( + clientId: string, + clientSecret: string, + ) : Promise; + + /** + * Returns access token + */ + getAccessKey () : string; + +} diff --git a/op/OpAuthClientImpl.system.test.ts b/op/OpAuthClientImpl.system.test.ts new file mode 100644 index 0000000..3ab4fc8 --- /dev/null +++ b/op/OpAuthClientImpl.system.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ProcessUtils } from "../ProcessUtils"; +ProcessUtils.initEnvFromDefaultFiles(); + +import HTTP from "http"; +import HTTPS from "https"; +// @ts-ignore +import { NodeRequestClient } from "../../node/requestClient/node/NodeRequestClient"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { OpAuthClientImpl } from "./OpAuthClientImpl"; +import { LogLevel } from "../types/LogLevel"; +// @ts-ignore +import { HgNode } from "../../node/HgNode"; +import { OP_SANDBOX_URL } from "./op-constants"; +import { OpAuthClient } from "./OpAuthClient"; + +const API_SERVER = process.env.OP_SANDBOX_URL ?? OP_SANDBOX_URL; +const CLIENT_ID = process.env.OP_CLIENT_ID ?? ''; +const CLIENT_SECRET = process.env.OP_CLIENT_SECRET ?? ''; +const MTLS_KEY = process.env.OP_MTLS_KEY ?? ''; +const MTLS_CRT = process.env.OP_MTLS_CRT ?? ''; + +/** + * To run these tests, create `.env` file like this: + * ``` + * OP_SANDBOX_URL=sandbox-url + * OP_CLIENT_ID=clientId + * OP_CLIENT_SECRET=clientSecret + * OP_MTLS_KEY="-----BEGIN RSA PRIVATE KEY----- + * ... + * -----END RSA PRIVATE KEY-----" + * OP_MTLS_CRT="-----BEGIN CERTIFICATE----- + * ... + * -----END CERTIFICATE-----" + * ``` + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#section/Usage-example + */ +describe('system', () => { + + (CLIENT_ID ? describe : describe.skip)('OpAuthClientImpl', () => { + let client : OpAuthClient; + + beforeAll(() => { + RequestClientImpl.setLogLevel(LogLevel.NONE); + NodeRequestClient.setLogLevel(LogLevel.NONE); + OpAuthClientImpl.setLogLevel(LogLevel.NONE); + HgNode.initialize(); + }); + + beforeEach(() => { + const requestClient = RequestClientImpl.create( + NodeRequestClient.create( + HTTP, + HTTPS, + { + cert: MTLS_CRT, + key: MTLS_KEY, + } + ) + ); + client = OpAuthClientImpl.create( + requestClient, + API_SERVER, + ); + }); + + describe('#isAuthenticated', () => { + it('should return false if not authenticated', () => { + expect(client.isAuthenticated()).toBeFalsy(); + }); + }); + + describe('#authenticate', () => { + it('should authenticate successfully', async () => { + await client.authenticate( + CLIENT_ID, + CLIENT_SECRET, + ); + expect(client.isAuthenticated()).toBeTruthy(); + }); + }); + + describe('#getAccessKey', () => { + + it('should throw an error if not authenticated', () => { + expect(() => { + client.getAccessKey(); + }).toThrowError('Not authenticated'); + }); + + it('should return access key when authenticated', async () => { + await client.authenticate( + CLIENT_ID, + CLIENT_SECRET, + ); + const accessKey = client.getAccessKey(); + expect(typeof accessKey).toBe('string'); + }); + + }); + + }); + +}); diff --git a/op/OpAuthClientImpl.test.ts b/op/OpAuthClientImpl.test.ts new file mode 100644 index 0000000..aa14a14 --- /dev/null +++ b/op/OpAuthClientImpl.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { RequestClientImpl } from "../RequestClientImpl"; +import { OpAuthClientImpl } from "./OpAuthClientImpl"; +import { LogLevel } from "../types/LogLevel"; + +describe('OpAuthClientImpl', () => { + + let mockClient: jest.Mocked; + + beforeAll( () => { + OpAuthClientImpl.setLogLevel(LogLevel.NONE); + }) + + beforeEach(() => { + mockClient = { + postText: jest.fn(), + } as any; + }); + + describe('#create', () => { + it('creates new OpAuthClientImpl instance', () => { + const authClient = OpAuthClientImpl.create(mockClient); + expect(authClient).toBeInstanceOf(OpAuthClientImpl); + }); + }); + + describe('#isAuthenticated', () => { + it('returns false when there is no access token', () => { + const authClient = OpAuthClientImpl.create(mockClient); + expect(authClient.isAuthenticated()).toBe(false); + }); + + it('returns true when there is an access token', async () => { + const expectedToken = 'access-token'; + mockClient.postText.mockResolvedValueOnce(JSON.stringify({ access_token: expectedToken })); + const authClient = OpAuthClientImpl.create(mockClient); + await authClient.authenticate('clientId', 'clientSecret'); + expect(authClient.isAuthenticated()).toBe(true); + }); + }); + + describe('#authenticate', () => { + it('authenticates and sets access token correctly', async () => { + const expectedToken = 'access-token'; + mockClient.postText.mockResolvedValueOnce(JSON.stringify({ access_token: expectedToken })); + const authClient = OpAuthClientImpl.create(mockClient); + + await authClient.authenticate('clientId', 'clientSecret'); + expect(authClient.isAuthenticated()).toBe(true); + }); + + it('throws error when no token found after authentication', async () => { + mockClient.postText.mockResolvedValueOnce(JSON.stringify({})); + const authClient = OpAuthClientImpl.create(mockClient); + + await expect(authClient.authenticate('clientId', 'clientSecret')).rejects.toThrow(TypeError); + expect(authClient.isAuthenticated()).toBe(false); + }); + + }); + + describe('#getAccessKey', () => { + + it('throws an error when not authenticated', () => { + const authClient = OpAuthClientImpl.create(mockClient); + expect(() => authClient.getAccessKey()).toThrow('Not authenticated'); + }); + + it('returns the access token when authenticated', async () => { + const expectedToken = 'access-token'; + mockClient.postText.mockResolvedValueOnce(JSON.stringify({ access_token: expectedToken })); + const authClient = OpAuthClientImpl.create(mockClient); + + await authClient.authenticate('clientId', 'clientSecret'); + expect(authClient.getAccessKey()).toBe(expectedToken); + }); + + }); + + describe('#getAccessToken', () => { + it('throws error when response is not a valid JSON object', async () => { + mockClient.postText.mockResolvedValueOnce('invalid JSON string'); + await expect(OpAuthClientImpl.getAccessToken(mockClient, 'url', 'clientId', 'clientSecret')).rejects.toThrow(TypeError); + }); + + it('throws error when no access token found in the response', async () => { + mockClient.postText.mockResolvedValueOnce(JSON.stringify({})); + await expect(OpAuthClientImpl.getAccessToken(mockClient, 'url', 'clientId', 'clientSecret')).rejects.toThrow('No access token found in the response'); + }); + + it('returns access token when it is found in the response', async () => { + const expectedToken = 'access-token'; + mockClient.postText.mockResolvedValueOnce(JSON.stringify({ access_token: expectedToken })); + const token = await OpAuthClientImpl.getAccessToken(mockClient, 'url', 'clientId', 'clientSecret'); + expect(token).toBe(expectedToken); + }); + }); + +}); diff --git a/op/OpAuthClientImpl.ts b/op/OpAuthClientImpl.ts new file mode 100644 index 0000000..27132fa --- /dev/null +++ b/op/OpAuthClientImpl.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isNonEmptyString } from "../types/String"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { explainJsonObject, isJsonObject, parseJson } from "../Json"; +import { OP_PRODUCTION_URL } from "./op-constants"; +import { OpAuthClient } from "./OpAuthClient"; +import { RequestClient } from "../RequestClient"; + +const LOG = LogService.createLogger( 'OpAuthClientImpl' ); + +/** + * OP Auth API implementation + */ +export class OpAuthClientImpl implements OpAuthClient { + + private readonly _client: RequestClient; + private readonly _url: string; + private _token: string | undefined; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + protected constructor ( + client: RequestClient, + url: string, + token ?: string | undefined, + ) { + this._client = client; + this._url = url; + this._token = token; + } + + public static create ( + client : RequestClient, + url : string = OP_PRODUCTION_URL, + token ?: string | undefined, + ) : OpAuthClientImpl { + return new OpAuthClientImpl( + client, + url, + token, + ); + } + + public isAuthenticated () : boolean { + return !!this._token; + } + + public async authenticate ( + clientId: string, + clientSecret: string, + ) : Promise { + this._token = await OpAuthClientImpl.getAccessToken( + this._client, + this._url, + clientId, + clientSecret + ); + } + + public getAccessKey () : string { + if (!this._token) throw new Error('Not authenticated'); + return this._token; + } + + public static async getAccessToken ( + client: RequestClient, + url: string, + clientId: string, + clientSecret: string, + ): Promise { + const a = new URLSearchParams(); + a.append('grant_type', 'client_credentials'); + a.append('client_id', clientId); + a.append('client_secret', clientSecret); + const credentialsData = a.toString(); + LOG.debug(`getAccessToken: credentialsData = `, credentialsData); + const response = await client.postText( + `${url}/corporate-oidc/v1/token`, + credentialsData, + { + 'Content-Type': 'application/x-www-form-urlencoded' + } + ); + const data = parseJson(response!); + if (!isJsonObject(data)) { + LOG.debug(`getAccessToken: response = `, response); + throw new TypeError(`Response was invalid: ${explainJsonObject(data)}`); + } + const accessToken = data?.access_token; + if (!isNonEmptyString(accessToken)) { + throw new TypeError('OpAuthClientImpl.getAccessToken: No access token found in the response'); + } + return accessToken; + } + +} diff --git a/op/OpPaymentClient.ts b/op/OpPaymentClient.ts new file mode 100644 index 0000000..df2c2dd --- /dev/null +++ b/op/OpPaymentClient.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpPaymentRequestDTO } from "./dto/OpPaymentRequestDTO"; +import { OpPaymentResponseDTO } from "./dto/OpPaymentResponseDTO"; +import { OpPaymentListDTO } from "./dto/OpPaymentListDTO"; + +/** + * OP Corporate Payment API client + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#section/Usage-example + */ +export interface OpPaymentClient { + + /** + * Initiates payment processing + * + * @param paymentRequest The sandbox-signing.key + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#operation/payment + */ + createPayment ( + paymentRequest: OpPaymentRequestDTO, + ): Promise; + + /** + * Initiates instant payment processing + * + * @param paymentRequest The sandbox-signing.key + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#operation/instantPayment + */ + createInstantPayment ( + paymentRequest: OpPaymentRequestDTO, + ): Promise; + + /** + * Get the status of an initiated SEPA Instant payment. Should be only used + * to query payments that ended up with paymentType=SCT_INST and + * status=PROCESSING. For other payment type and status combinations, the + * result list may be empty. + * + * @param instructionId instructionId used when the payment was initiated + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#operation/instantPaymentStatus + */ + getInstantPaymentStatus ( + instructionId: string + ): Promise; + +} diff --git a/op/OpPaymentClientImpl.system.test.ts b/op/OpPaymentClientImpl.system.test.ts new file mode 100644 index 0000000..1df4f3e --- /dev/null +++ b/op/OpPaymentClientImpl.system.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ProcessUtils } from "../ProcessUtils"; + +ProcessUtils.initEnvFromDefaultFiles(); + +import HTTP from "http"; +import HTTPS from "https"; +// @ts-ignore +import { HgNode } from "../../node/HgNode"; +import { OP_SANDBOX_URL } from "./op-constants"; +import { OpPaymentClient } from "./OpPaymentClient"; +import { OpPaymentRequestDTO } from "./dto/OpPaymentRequestDTO"; +import { Currency } from "../types/Currency"; +import { CountryCode } from "../types/CountryCode"; +import { OpPaymentResponseDTO } from "./dto/OpPaymentResponseDTO"; +// @ts-ignore +import { NodeRequestClient } from "../../node/requestClient/node/NodeRequestClient"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { Headers } from "../request/types/Headers"; +import { OpPaymentClientImpl } from "./OpPaymentClientImpl"; +import { OpAuthClientImpl } from "./OpAuthClientImpl"; +import { LogLevel } from "../types/LogLevel"; +import { OpRequestSigner } from "./OpRequestSigner"; +import { OpPaymentStatus } from "./types/OpPaymentStatus"; +import { OpPaymentListDTO } from "./dto/OpPaymentListDTO"; +import { first } from "../functions/first"; +import { OpAuthClient } from "./OpAuthClient"; + +const API_SERVER = process.env.OP_SANDBOX_URL ?? OP_SANDBOX_URL; +const CLIENT_ID = process.env.OP_CLIENT_ID ?? ''; +const CLIENT_SECRET = process.env.OP_CLIENT_SECRET ?? ''; +const MTLS_KEY = process.env.OP_MTLS_KEY ?? ''; +const MTLS_CRT = process.env.OP_MTLS_CRT ?? ''; +const SIGNING_KEY = process.env.OP_SIGNING_KEY ?? ''; +const SIGNING_KID = process.env.OP_SIGNING_KID ?? ''; + +/** + * To run these tests, create `.env` file like this: + * ``` + * OP_CLIENT_ID=clientId + * OP_CLIENT_SECRET=clientSecret + * OP_SIGNING_KEY="-----BEGIN RSA PRIVATE KEY----- + * ... + * -----END RSA PRIVATE KEY-----" + * OP_SIGNING_KID=signing-kid + * OP_MTLS_KEY="-----BEGIN RSA PRIVATE KEY----- + * ... + * -----END RSA PRIVATE KEY-----" + * OP_MTLS_CRT="-----BEGIN CERTIFICATE----- + * ... + * -----END CERTIFICATE-----" + * ``` + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#section/Usage-example + */ +describe('system', () => { + (CLIENT_ID ? describe : describe.skip)('OpPaymentClientImpl', () => { + let client : OpPaymentClient; + + beforeAll(() => { + Headers.setLogLevel(LogLevel.NONE); + RequestClientImpl.setLogLevel(LogLevel.NONE); + NodeRequestClient.setLogLevel(LogLevel.NONE); + OpRequestSigner.setLogLevel(LogLevel.NONE); + OpAuthClientImpl.setLogLevel(LogLevel.NONE); + OpPaymentClientImpl.setLogLevel(LogLevel.NONE); + HgNode.initialize(); + }); + + beforeEach(async () => { + const requestClient = RequestClientImpl.create( + NodeRequestClient.create( + HTTP, + HTTPS, + { + cert: MTLS_CRT, + key: MTLS_KEY, + } + ) + ); + const authClient : OpAuthClient = OpAuthClientImpl.create( + requestClient, + API_SERVER, + ); + client = OpPaymentClientImpl.create( + requestClient, + authClient, + OpRequestSigner.create( + SIGNING_KID, + SIGNING_KEY, + ), + API_SERVER, + ); + await authClient.authenticate( + CLIENT_ID, + CLIENT_SECRET, + ); + }); + + describe('#createPayment', () => { + + it('should return a successful response with valid input', async () => { + + const instructionId = `${Date.now()}${(Math.random()*100000).toFixed(0)}`; // unique instruction id + const endToEndId = "endToEndId"; + + const paymentRequest : OpPaymentRequestDTO = { + instructionId, + endToEndId, + creditor: { + name: "Creditor Name", + iban: "FI3859991620004143", + address: { + addressLine: ["a1", "a2"], + country: CountryCode.FI + } + }, + debtor: { + name: "Debtor Name", + iban: "FI6359991620004275", + address: { + addressLine: ["a1", "a2"], + country: CountryCode.FI + } + }, + instructedAmount: { + currency: Currency.EUR, + amount: "0.16" + }, + reference: "00000000000000482738" + }; + + const paymentResponse : OpPaymentResponseDTO = await client.createPayment(paymentRequest); + expect(paymentResponse).toBeDefined(); + expect(paymentResponse.transactionId).toBeDefined(); + expect(paymentResponse.status).toBeDefined(); + expect(paymentResponse.endToEndId).toBe(endToEndId); + + }); + + }); + + describe('#createInstantPayment', () => { + + it('should return a successful response with valid input', async () => { + + const instructionId = `${Date.now()}${(Math.random()*100000).toFixed(0)}`; // unique instruction id + const endToEndId = "endToEndId"; + + const paymentRequest : OpPaymentRequestDTO = { + instructionId, + endToEndId, + creditor: { + name: "Creditor Name", + iban: "FI3859991620004143", + address: { + addressLine: ["a1", "a2"], + country: CountryCode.FI + } + }, + debtor: { + name: "Debtor Name", + iban: "FI6359991620004275", + address: { + addressLine: ["a1", "a2"], + country: CountryCode.FI + } + }, + instructedAmount: { + currency: Currency.EUR, + amount: "0.16" + }, + reference: "00000000000000482738" + }; + + const paymentResponse : OpPaymentResponseDTO = await client.createInstantPayment(paymentRequest); + expect(paymentResponse).toBeDefined(); + expect(paymentResponse.transactionId).toBeDefined(); + expect(paymentResponse.status).toBeDefined(); + expect(paymentResponse.endToEndId).toBe(endToEndId); + + }); + + }); + + describe('#getInstantPaymentStatus', () => { + + let instructionId : string; + let paymentResponse : OpPaymentResponseDTO; + let endToEndId : string; + + beforeEach( async () => { + + let counter : number = 0; + while (counter < 10) { + + instructionId = `${Date.now()}${(Math.random()*100000).toFixed(0)}`; // unique instruction id + endToEndId = `${Date.now()}${(Math.random()*100000).toFixed(0)}`; // unique end to end id + + const paymentRequest : OpPaymentRequestDTO = { + instructionId, + endToEndId, + creditor: { + name: "Creditor Name", + iban: "FI3859991620004143", + address: { + addressLine: ["a1", "a2"], + country: CountryCode.FI + } + }, + debtor: { + name: "Debtor Name", + iban: "FI6359991620004275", + address: { + addressLine: ["a1", "a2"], + country: CountryCode.FI + } + }, + instructedAmount: { + currency: Currency.EUR, + amount: "0.16" + }, + reference: "00000000000000482738" + }; + + paymentResponse = await client.createPayment(paymentRequest); + // console.log(`${counter} / 10: paymentResponse.status = ${paymentResponse.status}`); + if (paymentResponse.status === OpPaymentStatus.PROCESSING) { + break; + } + counter += 1; + } + + expect(paymentResponse).toBeDefined(); + expect(paymentResponse.transactionId).toBeDefined(); + expect(paymentResponse.status).toBe(OpPaymentStatus.PROCESSING); + expect(paymentResponse.endToEndId).toBe(endToEndId); + + }); + + it('should return a successful response with valid input', async () => { + const list : OpPaymentListDTO = await client.getInstantPaymentStatus(instructionId); + expect(list).toHaveLength(1); + const dto = first(list); + expect(dto).toBeDefined(); + expect(dto?.transactionId).toBeDefined(); + expect(dto?.status).toBeDefined(); + expect(dto?.endToEndId).toBe(endToEndId); + }); + + }); + + }); + +}); diff --git a/op/OpPaymentClientImpl.test.ts b/op/OpPaymentClientImpl.test.ts new file mode 100644 index 0000000..4f2f1d7 --- /dev/null +++ b/op/OpPaymentClientImpl.test.ts @@ -0,0 +1,298 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { OpPaymentCreditor } from "./types/OpPaymentCreditor"; +import { CountryCode } from "../types/CountryCode"; +import { Currency } from "../types/Currency"; +import { OpPaymentRequestDTO } from "./dto/OpPaymentRequestDTO"; +import { LogLevel } from "../types/LogLevel"; +import { OpPaymentResponseDTO } from "./dto/OpPaymentResponseDTO"; +import { OpPaymentType } from "./types/OpPaymentType"; +import { OpPaymentStatus } from "./types/OpPaymentStatus"; +import { OpPaymentClient } from "./OpPaymentClient"; +import { RequestClient } from "../RequestClient"; +import { MockRequestClient } from "../requestClient/mock/MockRequestClient"; +import { OpAuthClient } from "./OpAuthClient"; +import { MockOpAuthClient } from "./mocks/MockOpAuthClient"; +import { RequestSigner } from "../types/RequestSigner"; +import { OpPaymentClientImpl } from "./OpPaymentClientImpl"; + +jest.mock('../RequestClient'); + +const MOCK_OPPAYMENTREQUEST_DTO: OpPaymentRequestDTO = { + instructionId: '123456', + creditor: { + name: 'Test Name', + iban: 'FI3859991620004143', + address: { + country: 'FI' as CountryCode, + addressLine: ['a1', 'a2'] + } + } as OpPaymentCreditor, + debtor: { + name: 'Test Name', + iban: 'FI3859991620004143', + address: { + country: 'FI' as CountryCode, + addressLine: ['a1', 'a2'] + } + }, + instructedAmount: { + amount: '100.00', + currency: 'USD' as Currency, + } +}; + +const MOCK_OP_PAYMENT_RESPONSE_DTO = { + "amount": "3.45", + "status": OpPaymentStatus.PROCESSED, + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": OpPaymentType.SCT_INST, + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" +}; + +const MOCK_INSTRUCTION_ID = '123456'; +const MOCK_SIGNATURE = 'my-signature'; +const MOCK_ACCESS_TOKEN = 'testToken'; + +const MOCK_PAYMENT_LIST_ITEM_RESPONSE: OpPaymentResponseDTO = { + "amount": "3.45", + "status": OpPaymentStatus.PROCESSED, + "currency": Currency.EUR, + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": OpPaymentType.SCT_INST, + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" +}; + +const MOCK_PAYMENT_LIST_RESPONSE: readonly OpPaymentResponseDTO[] = [MOCK_PAYMENT_LIST_ITEM_RESPONSE]; + +describe("OpPaymentClientImpl", () => { + + let requestClient : RequestClient; + let authClient : OpAuthClient; + let requestSigner : RequestSigner; + let client: OpPaymentClient; + + beforeAll(() => { + OpPaymentClientImpl.setLogLevel(LogLevel.NONE); + + requestClient = new MockRequestClient(); + authClient = new MockOpAuthClient(); + requestSigner = jest.fn(); + + jest.spyOn(requestClient, 'postText'); + jest.spyOn(requestClient, 'getJson'); + jest.spyOn(authClient, 'isAuthenticated'); + jest.spyOn(authClient, 'authenticate'); + jest.spyOn(authClient, 'getAccessKey'); + + }); + + beforeEach(() => { + client = OpPaymentClientImpl.create( + requestClient, + authClient, + requestSigner, + "https://example.com" + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#createPayment', () => { + + it("should initiate payment correctly with unauthenticated auth client", async () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(false); + (authClient.getAccessKey as any).mockImplementation(() => { + throw new Error('Not authenticated'); + }); + (authClient.authenticate as any).mockReturnValue(Promise.resolve()); + (requestClient.postText as any).mockReturnValue(Promise.resolve(JSON.stringify(MOCK_OP_PAYMENT_RESPONSE_DTO))); + (requestSigner as any).mockReturnValue(MOCK_SIGNATURE); + + // Act + await expect(client.createPayment(MOCK_OPPAYMENTREQUEST_DTO)).rejects.toThrow('Not authenticated'); + + // Assert + expect(authClient.isAuthenticated).not.toHaveBeenCalled(); + expect(authClient.authenticate).not.toHaveBeenCalled(); + expect(requestSigner).not.toHaveBeenCalled(); + expect(requestClient.postText).not.toHaveBeenCalled(); + + }); + + it("should initiate payment correctly with authenticated auth client", async () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(true); + (authClient.getAccessKey as any).mockReturnValue(MOCK_ACCESS_TOKEN); + (requestClient.postText as any).mockReturnValue(Promise.resolve(JSON.stringify(MOCK_OP_PAYMENT_RESPONSE_DTO))); + (requestSigner as any).mockReturnValue(MOCK_SIGNATURE); + + // Act + const result = await client.createPayment(MOCK_OPPAYMENTREQUEST_DTO); + + // Assert + expect(authClient.isAuthenticated).not.toHaveBeenCalled(); + expect(authClient.authenticate).not.toHaveBeenCalled(); + + expect(requestSigner).toHaveBeenCalledTimes(1); + expect(requestSigner).toHaveBeenCalledWith(JSON.stringify(MOCK_OPPAYMENTREQUEST_DTO)); + + expect(requestClient.postText).toHaveBeenCalledTimes(1); + expect(requestClient.postText).toHaveBeenCalledWith( + "https://example.com/corporate-payment/v1/sepa-payment", + JSON.stringify(MOCK_OPPAYMENTREQUEST_DTO), + expect.objectContaining({ + "Content-Type": "application/json", + "Authorization": `Bearer ${MOCK_ACCESS_TOKEN}`, + "X-Req-Signature": MOCK_SIGNATURE, + }) + ); + + // Expect result to match mock OpPaymentResponseDTO + expect(result).toStrictEqual(MOCK_OP_PAYMENT_RESPONSE_DTO); + + }); + + }); + + describe('#createInstantPayment', () => { + + it("should initiate payment correctly with unauthenticated auth client", async () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(false); + (authClient.getAccessKey as any).mockImplementation(() => { + throw new Error('Not authenticated'); + }); + (authClient.authenticate as any).mockReturnValue(Promise.resolve()); + (requestClient.postText as any).mockReturnValue(Promise.resolve(JSON.stringify(MOCK_OP_PAYMENT_RESPONSE_DTO))); + (requestSigner as any).mockReturnValue(MOCK_SIGNATURE); + + // Act + await expect(client.createInstantPayment(MOCK_OPPAYMENTREQUEST_DTO)).rejects.toThrow('Not authenticated'); + + // Assert + expect(authClient.isAuthenticated).not.toHaveBeenCalled(); + expect(authClient.authenticate).not.toHaveBeenCalled(); + expect(requestSigner).not.toHaveBeenCalled(); + expect(requestClient.postText).not.toHaveBeenCalled(); + + }); + + it("should initiate payment correctly with authenticated auth client", async () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(true); + (authClient.getAccessKey as any).mockReturnValue(MOCK_ACCESS_TOKEN); + (requestClient.postText as any).mockReturnValue(Promise.resolve(JSON.stringify(MOCK_OP_PAYMENT_RESPONSE_DTO))); + (requestSigner as any).mockReturnValue(MOCK_SIGNATURE); + + // Act + const result = await client.createInstantPayment(MOCK_OPPAYMENTREQUEST_DTO); + + // Assert + expect(authClient.isAuthenticated).not.toHaveBeenCalled(); + expect(authClient.authenticate).not.toHaveBeenCalled(); + + expect(requestSigner).toHaveBeenCalledTimes(1); + expect(requestSigner).toHaveBeenCalledWith(JSON.stringify(MOCK_OPPAYMENTREQUEST_DTO)); + + expect(requestClient.postText).toHaveBeenCalledTimes(1); + expect(requestClient.postText).toHaveBeenCalledWith( + "https://example.com/corporate-payment/v1/sepa-instant-payment", + JSON.stringify(MOCK_OPPAYMENTREQUEST_DTO), + expect.objectContaining({ + "Content-Type": "application/json", + "Authorization": `Bearer ${MOCK_ACCESS_TOKEN}`, + "X-Req-Signature": MOCK_SIGNATURE, + }) + ); + + // Expect result to match mock OpPaymentResponseDTO + expect(result).toStrictEqual(MOCK_OP_PAYMENT_RESPONSE_DTO); + + }); + + }); + + describe('getInstantPaymentStatus', () => { + + it('should get valid status of an initiated instant payment when auth client is authenticated', async () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(true); + (authClient.getAccessKey as any).mockReturnValue(MOCK_ACCESS_TOKEN); + (requestClient.getJson as any).mockReturnValue(Promise.resolve(MOCK_PAYMENT_LIST_RESPONSE)); + (requestSigner as any).mockReturnValue(MOCK_SIGNATURE); + + // Act + const result = await client.getInstantPaymentStatus(MOCK_INSTRUCTION_ID); + + // Assert + expect(authClient.isAuthenticated).not.toHaveBeenCalled(); + expect(authClient.authenticate).not.toHaveBeenCalled(); + expect(requestSigner).not.toHaveBeenCalled(); + + expect(requestClient.getJson).toHaveBeenCalledTimes(1); + expect(requestClient.getJson).toHaveBeenCalledWith( + `https://example.com/corporate-payment/v1/sepa-instant-payment/${MOCK_INSTRUCTION_ID}`, + expect.objectContaining({ + "Content-Type": "application/json", + "Authorization": `Bearer ${MOCK_ACCESS_TOKEN}`, + "X-Request-ID": expect.any(String) + }) + ); + + // Expect result to match mock OpPaymentResponseDTO + expect(result).toStrictEqual(MOCK_PAYMENT_LIST_RESPONSE); + + }); + + it('should throw an error when auth client is not authenticated', async () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(false); + (authClient.getAccessKey as any).mockImplementation(() => { + throw new Error('Not authenticated'); + }); + (authClient.authenticate as any).mockReturnValue(Promise.resolve()); + (requestClient.getJson as any).mockReturnValue(Promise.resolve(MOCK_PAYMENT_LIST_RESPONSE)); + (requestSigner as any).mockReturnValue(MOCK_SIGNATURE); + + // Act + await expect(client.getInstantPaymentStatus(MOCK_INSTRUCTION_ID)).rejects.toThrow('Not authenticated'); + + // Assert + expect(authClient.isAuthenticated).not.toHaveBeenCalled(); + expect(authClient.authenticate).not.toHaveBeenCalled(); + expect(requestSigner).not.toHaveBeenCalled(); + expect(requestClient.getJson).not.toHaveBeenCalled(); + + }); + + }); + +}); diff --git a/op/OpPaymentClientImpl.ts b/op/OpPaymentClientImpl.ts new file mode 100644 index 0000000..26e20cd --- /dev/null +++ b/op/OpPaymentClientImpl.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpPaymentClient } from "./OpPaymentClient"; +import { OpPaymentRequestDTO } from "./dto/OpPaymentRequestDTO"; +import { explainOpPaymentResponseDTO, isOpPaymentResponseDTO, OpPaymentResponseDTO } from "./dto/OpPaymentResponseDTO"; +import { LogService } from "../LogService"; +import { OP_CREATE_SEPA_INSTANT_PAYMENT_PATH, OP_CREATE_SEPA_PAYMENT_PATH, OP_PRODUCTION_URL, OP_SEPA_INSTANT_PAYMENT_STATUS_PATH } from "./op-constants"; +import { OpAuthClient } from "./OpAuthClient"; +import { LogLevel } from "../types/LogLevel"; +import { OpRequestUtils } from "./OpRequestUtils"; +import { explainOpPaymentListDTO, isOpPaymentListDTO, OpPaymentListDTO } from "./dto/OpPaymentListDTO"; +import { RequestSigner } from "../types/RequestSigner"; +import { RequestClient } from "../RequestClient"; + +const LOG = LogService.createLogger( 'OpPaymentClientImpl' ); + +/** + * OP Corporate Payment API implementation + */ +export class OpPaymentClientImpl implements OpPaymentClient, OpAuthClient { + + private readonly _client: RequestClient; + private readonly _auth: OpAuthClient; + private readonly _signer: RequestSigner; + private readonly _url: string; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static create ( + client: RequestClient, + auth: OpAuthClient, + signer: RequestSigner, + url : string = OP_PRODUCTION_URL + ) : OpPaymentClientImpl { + return new OpPaymentClientImpl( + client, + auth, + signer, + url, + ); + } + + private constructor ( + client: RequestClient, + auth: OpAuthClient, + signer: RequestSigner, + url: string, + ) { + this._client = client; + this._auth = auth; + this._signer = signer; + this._url = url; + } + + public isAuthenticated () : boolean { + return this._auth.isAuthenticated(); + } + + public async authenticate ( + clientId : string, + clientSecret : string, + ) : Promise { + await this._auth.authenticate(clientId, clientSecret); + } + + public getAccessKey() : string { + return this._auth.getAccessKey(); + } + + /** + * @inheritDoc + */ + public async createPayment (paymentRequestDto: OpPaymentRequestDTO): Promise { + return await OpRequestUtils.postSignedRequest( + this._client, + this._auth, + this._signer, + this._url, + OP_CREATE_SEPA_PAYMENT_PATH, + paymentRequestDto, + isOpPaymentResponseDTO, + explainOpPaymentResponseDTO, + "OpPaymentResponseDTO", + ); + } + + /** + * @inheritDoc + */ + public async createInstantPayment (paymentRequestDto: OpPaymentRequestDTO): Promise { + return await OpRequestUtils.postSignedRequest( + this._client, + this._auth, + this._signer, + this._url, + OP_CREATE_SEPA_INSTANT_PAYMENT_PATH, + paymentRequestDto, + isOpPaymentResponseDTO, + explainOpPaymentResponseDTO, + "OpPaymentResponseDTO", + ); + } + + /** + * @inheritDoc + */ + public async getInstantPaymentStatus ( + instructionId: string + ): Promise { + return await OpRequestUtils.getJsonRequest( + this._client, + this._auth, + this._url, + OP_SEPA_INSTANT_PAYMENT_STATUS_PATH(instructionId), + isOpPaymentListDTO, + explainOpPaymentListDTO, + "OpPaymentListDTO", + ); + } + +} diff --git a/op/OpRefundClient.ts b/op/OpRefundClient.ts new file mode 100644 index 0000000..1837fff --- /dev/null +++ b/op/OpRefundClient.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpRefundRequestDTO } from "./dto/OpRefundRequestDTO"; +import { OpRefundResponseDTO } from "./dto/OpRefundResponseDTO"; + +/** + * Interface for the OpRefundClient, which defines functionalities related to the refund payment process. + */ +export interface OpRefundClient { + + /** + * Initiates a refund process based on the provided refund payment request data. + * + * @param {OpRefundRequestDTO} paymentRequest - The refund payment request data. + * @returns {Promise} - Returns a promise which resolves to the refund payment response data. + * + * @example + * const refundClient = getRefundClient(); // Assuming this function provides an instance of OpRefundClient. + * const request: OpRefundRequestDTO = { + * // ... refund request data + * }; + * + * refundClient.refundPayment(request) + * .then(response => { + * console.log(response); + * }) + * .catch(error => { + * console.error(error); + * }); + * + * @throws Will throw an error if the refund process fails. + */ + refundPayment ( + paymentRequest: OpRefundRequestDTO, + ): Promise; + +} diff --git a/op/OpRefundClientImpl.system.test.ts b/op/OpRefundClientImpl.system.test.ts new file mode 100644 index 0000000..0cbafe7 --- /dev/null +++ b/op/OpRefundClientImpl.system.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ProcessUtils } from "../ProcessUtils"; + +ProcessUtils.initEnvFromDefaultFiles(); + +// @ts-ignore +import HTTP from "http"; +// @ts-ignore +import HTTPS from "https"; +// @ts-ignore +import { HgNode } from "../../node/HgNode"; +import { OpAccountDTO } from "./dto/OpAccountDTO"; +import { OpAccountListDTO } from "./dto/OpAccountListDTO"; +import { OpTransactionDTO } from "./dto/OpTransactionDTO"; +import { OpTransactionListDTO } from "./dto/OpTransactionListDTO"; +import { OP_SANDBOX_URL } from "./op-constants"; +import { OpAccountDataClient } from "./OpAccountDataClient"; +import { OpAccountDataClientImpl } from "./OpAccountDataClientImpl"; +import { OpRefundClient } from "./OpRefundClient"; +import { OpRefundRequestDTO } from "./dto/OpRefundRequestDTO"; +import { OpRefundResponseDTO } from "./dto/OpRefundResponseDTO"; +// @ts-ignore +import { NodeRequestClient } from "../../node/requestClient/node/NodeRequestClient"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { Headers } from "../request/types/Headers"; +import { OpRefundClientImpl } from "./OpRefundClientImpl"; +import { OpAuthClientImpl } from "./OpAuthClientImpl"; +import { LogLevel } from "../types/LogLevel"; +import { OpRequestSigner } from "./OpRequestSigner"; +import { first } from "../functions/first"; + +const API_SERVER = process.env.OP_SANDBOX_URL ?? OP_SANDBOX_URL; +const CLIENT_ID = process.env.OP_CLIENT_ID ?? ''; +const CLIENT_SECRET = process.env.OP_CLIENT_SECRET ?? ''; +const MTLS_KEY = process.env.OP_MTLS_KEY ?? ''; +const MTLS_CRT = process.env.OP_MTLS_CRT ?? ''; +const SIGNING_KEY = process.env.OP_SIGNING_KEY ?? ''; +const SIGNING_KID = process.env.OP_SIGNING_KID ?? ''; + +describe('system', () => { + (CLIENT_ID ? describe : describe.skip)('OpRefundClientImpl', () => { + let client : OpRefundClient; + let accountDataClient : OpAccountDataClient; + let accountList : OpAccountListDTO; + let account : OpAccountDTO; + let transaction : OpTransactionDTO; + + beforeAll(() => { + Headers.setLogLevel(LogLevel.NONE); + RequestClientImpl.setLogLevel(LogLevel.NONE); + NodeRequestClient.setLogLevel(LogLevel.NONE); + OpRequestSigner.setLogLevel(LogLevel.NONE); + OpAuthClientImpl.setLogLevel(LogLevel.NONE); + OpRefundClientImpl.setLogLevel(LogLevel.NONE); + HgNode.initialize(); + }); + + beforeEach(async () => { + const requestClient = RequestClientImpl.create( + NodeRequestClient.create( + HTTP, + HTTPS, + { + cert: MTLS_CRT, + key: MTLS_KEY, + } + ) + ); + const authClient = OpAuthClientImpl.create( + requestClient, + API_SERVER, + ); + + accountDataClient = OpAccountDataClientImpl.create( + requestClient, + authClient, + API_SERVER, + ); + + client = OpRefundClientImpl.create( + requestClient, + authClient, + OpRequestSigner.create( + SIGNING_KID, + SIGNING_KEY, + ), + API_SERVER, + ); + await authClient.authenticate( + CLIENT_ID, + CLIENT_SECRET, + ); + + accountList = await accountDataClient.getAccountList(); + if (accountList.length === 0) throw new TypeError('No accounts found'); + account = first(accountList) as unknown as OpAccountDTO; + + const surrogateId = account.surrogateId; + const fromTimestamp = Date.now()*1000; + const transactionList : OpTransactionListDTO = await accountDataClient.getTransactionListFromTimestamp( surrogateId, fromTimestamp ); + expect( transactionList ).toBeDefined(); + + if (transactionList.length === 0) throw new TypeError('No transactions found'); + transaction = first(transactionList) as unknown as OpTransactionDTO; + + }); + + describe('#refundPayment', () => { + + it('should return a successful response with valid archive ID', async () => { + + const refundRequest: OpRefundRequestDTO = { + "archiveId": transaction.archiveId, + "amount": "12.35", + "message": "Refund", + "accountIban": account.iban, + "transactionDate": transaction.paymentDate, + "endToEndId": "544652-end2end", + }; + + const refundResponse: OpRefundResponseDTO = await client.refundPayment(refundRequest); + expect(refundResponse).toBeDefined(); + // add more specific assertions about the refund response as needed + }); + + // @FIXME: 12345678901234567890 should be invalid but works anyway + it.skip('should return an error response with invalid archive ID format', async () => { + + const refundRequest: OpRefundRequestDTO = { + "archiveId": "12345678901234567890", + "amount": "12.35", + "message": "Refund", + "accountIban": account.iban, + "transactionDate": transaction.paymentDate, + "endToEndId": "544652-end2end", + }; + + await expect(client.refundPayment(refundRequest)).rejects.toThrow("Transaction not found"); + + }); + + // Add more tests for other specific error cases mentioned in the provided information + + }); + + }); +}); diff --git a/op/OpRefundClientImpl.test.ts b/op/OpRefundClientImpl.test.ts new file mode 100644 index 0000000..e83a8fc --- /dev/null +++ b/op/OpRefundClientImpl.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { RequestClient } from "../RequestClient"; +import { MockRequestClient } from "../requestClient/mock/MockRequestClient"; +import { LogLevel } from "../types/LogLevel"; +import { RequestSigner } from "../types/RequestSigner"; +import { MockOpAuthClient } from "./mocks/MockOpAuthClient"; +import { OpAuthClient } from "./OpAuthClient"; +import { OpRefundClientImpl } from "./OpRefundClientImpl"; + +jest.mock('../RequestClient'); + +const MOCK_OPREFUNDREQUEST_DTO = { + "archiveId": "20190524593156999999999999999999999", + "amount": "12.35", + "message": "Less money, fewer problems", + "accountIban": "FI4550009420888888", + "transactionDate": "2023-01-14", + "endToEndId": "544652-end2end" +}; + +const MOCK_OP_REFUND_RESPONSE_DTO = { + "original": { + "archiveId": "20190524593156999999999999999999999", + "message": "Less money," + + " fewer problems", + "reference": "00000000000000482738", + "amount": "12.35", + "bookingDate": "2019-05-12", + "debtorName": "Debbie Debtor" + }, + "refund": { + "amount": "3.45", + "status": "PROCESSED", + "message": "MAKSUN PALAUTUS. Maksun tiedot: 01.01.2020 Your own refund message", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorName": "Cedric Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + } +}; + +const MOCK_CLIENT_ID = 'testClientId'; +const MOCK_CLIENT_SECRET = 'testClientSecret'; +const MOCK_ACCESS_TOKEN = 'testToken'; +const MOCK_SIGNATURE = 'signature'; + +describe("OpRefundClientImpl", () => { + + let requestClient : RequestClient; + let authClient : OpAuthClient; + let requestSigner : RequestSigner; + let client: OpRefundClientImpl; + + beforeAll(() => { + OpRefundClientImpl.setLogLevel(LogLevel.NONE); + + requestClient = new MockRequestClient(); + authClient = new MockOpAuthClient(); + requestSigner = jest.fn(); + + jest.spyOn(requestClient, 'postText'); + jest.spyOn(authClient, 'isAuthenticated'); + jest.spyOn(authClient, 'authenticate'); + jest.spyOn(authClient, 'getAccessKey'); + + }); + + beforeEach(() => { + client = OpRefundClientImpl.create( + requestClient, + authClient, + requestSigner, + "https://example.com" + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#refundPayment', () => { + + it("should initiate refund correctly with unauthenticated auth client", async () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(false); + (authClient.getAccessKey as any).mockImplementation(() => { + throw new Error('Not authenticated'); + }); + (authClient.authenticate as any).mockReturnValue(Promise.resolve()); + (requestClient.postText as any).mockReturnValue(Promise.resolve(JSON.stringify(MOCK_OP_REFUND_RESPONSE_DTO))); + (requestSigner as any).mockReturnValue(MOCK_SIGNATURE); + + // Act + await expect(client.refundPayment(MOCK_OPREFUNDREQUEST_DTO)).rejects.toThrow('Not authenticated'); + + // Assert + expect(authClient.isAuthenticated).not.toHaveBeenCalled(); + expect(authClient.authenticate).not.toHaveBeenCalled(); + expect(requestSigner).not.toHaveBeenCalled(); + expect(requestClient.postText).not.toHaveBeenCalled(); + + }); + + it("should initiate refund correctly with authenticated auth client", async () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(true); + (authClient.getAccessKey as any).mockReturnValue(MOCK_ACCESS_TOKEN); + (requestClient.postText as any).mockReturnValue(Promise.resolve(JSON.stringify(MOCK_OP_REFUND_RESPONSE_DTO))); + (requestSigner as any).mockReturnValue(MOCK_SIGNATURE); + + // Act + const result = await client.refundPayment(MOCK_OPREFUNDREQUEST_DTO); + + // Assert + expect(authClient.isAuthenticated).not.toHaveBeenCalled(); + expect(authClient.authenticate).not.toHaveBeenCalled(); + + expect(requestSigner).toHaveBeenCalledTimes(1); + expect(requestSigner).toHaveBeenCalledWith(JSON.stringify(MOCK_OPREFUNDREQUEST_DTO)); + + expect(requestClient.postText).toHaveBeenCalledTimes(1); + expect(requestClient.postText).toHaveBeenCalledWith( + // Please adjust the URL endpoint according to your actual implementation. + "https://example.com/corporate-payment/v2/payment-refund", + JSON.stringify(MOCK_OPREFUNDREQUEST_DTO), + expect.objectContaining({ + "Content-Type": "application/json", + "Authorization": `Bearer ${MOCK_ACCESS_TOKEN}`, + "X-Req-Signature": MOCK_SIGNATURE, + }) + ); + + // Expect result to match mock OpRefundResponseDTO + expect(result).toStrictEqual(MOCK_OP_REFUND_RESPONSE_DTO); + + }); + + }); + + describe('#authenticate', () => { + + it('should authenticate correctly', async () => { + + // Arrange + (authClient.authenticate as any).mockReturnValue(Promise.resolve()); + + // Act + await client.authenticate(MOCK_CLIENT_ID, MOCK_CLIENT_SECRET); + + // Assert + expect(authClient.authenticate).toHaveBeenCalledWith(MOCK_CLIENT_ID, MOCK_CLIENT_SECRET); + + }); + + }); + + describe('#isAuthenticated', () => { + + it('should check if auth client is authenticated correctly', () => { + + // Arrange + (authClient.isAuthenticated as any).mockReturnValue(true); + + // Act + const result = client.isAuthenticated(); + + // Assert + expect(authClient.isAuthenticated).toHaveBeenCalled(); + expect(result).toBe(true); + + }); + + }); + + describe('#getAccessKey', () => { + + it('should get the access key correctly', () => { + + // Arrange + (authClient.getAccessKey as any).mockReturnValue(MOCK_ACCESS_TOKEN); + + // Act + const result = client.getAccessKey(); + + // Assert + expect(authClient.getAccessKey).toHaveBeenCalled(); + expect(result).toBe(MOCK_ACCESS_TOKEN); + + }); + + }); + +}); + diff --git a/op/OpRefundClientImpl.ts b/op/OpRefundClientImpl.ts new file mode 100644 index 0000000..c70b583 --- /dev/null +++ b/op/OpRefundClientImpl.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../LogService"; +import { RequestClient } from "../RequestClient"; +import { LogLevel } from "../types/LogLevel"; +import { RequestSigner } from "../types/RequestSigner"; +import { OpRefundRequestDTO } from "./dto/OpRefundRequestDTO"; +import { explainOpRefundResponseDTO, isOpRefundResponseDTO, OpRefundResponseDTO } from "./dto/OpRefundResponseDTO"; +import { OP_CREATE_SEPA_REFUND_PATH, OP_PRODUCTION_URL } from "./op-constants"; +import { OpAuthClient } from "./OpAuthClient"; +import { OpRefundClient } from "./OpRefundClient"; +import { OpRequestUtils } from "./OpRequestUtils"; + +const LOG = LogService.createLogger( 'OpRefundClientImpl' ); + +/** + * @inheritDoc + */ +export class OpRefundClientImpl implements OpRefundClient, OpAuthClient { + + private readonly _client: RequestClient; + private readonly _auth: OpAuthClient; + private readonly _signer: RequestSigner; + private readonly _url: string; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static create ( + client: RequestClient, + auth: OpAuthClient, + signer: RequestSigner, + url : string = OP_PRODUCTION_URL + ) : OpRefundClientImpl { + return new OpRefundClientImpl( + client, + auth, + signer, + url, + ); + } + + protected constructor ( + client: RequestClient, + auth: OpAuthClient, + signer: RequestSigner, + url: string, + ) { + this._client = client; + this._auth = auth; + this._signer = signer; + this._url = url; + } + + /** + * @inheritDoc + */ + public isAuthenticated () : boolean { + return this._auth.isAuthenticated(); + } + + /** + * @inheritDoc + */ + public async authenticate ( + clientId : string, + clientSecret : string, + ) : Promise { + await this._auth.authenticate(clientId, clientSecret); + } + + /** + * @inheritDoc + */ + public getAccessKey() : string { + return this._auth.getAccessKey(); + } + + /** + * @inheritDoc + */ + public async refundPayment ( + refundRequest: OpRefundRequestDTO, + ): Promise { + return await OpRequestUtils.postSignedRequest( + this._client, + this._auth, + this._signer, + this._url, + OP_CREATE_SEPA_REFUND_PATH, + refundRequest, + isOpRefundResponseDTO, + explainOpRefundResponseDTO, + "OpRefundResponseDTO", + ); + } + +} diff --git a/op/OpRequestSigner.test.ts b/op/OpRequestSigner.test.ts new file mode 100644 index 0000000..7a01382 --- /dev/null +++ b/op/OpRequestSigner.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals' +import { createSign } from 'crypto'; +import { RequestSigner } from "../types/RequestSigner"; +import { OpRequestSigner } from "./OpRequestSigner"; +import { LogLevel } from "../types/LogLevel"; + +jest.mock('crypto'); + +describe('OpRequestSigner', () => { + let signingKid: string; + let signingKey: string; + let bodyString: string; + let signer: RequestSigner; + + beforeAll(() => { + OpRequestSigner.setLogLevel(LogLevel.NONE); + }); + + beforeEach(() => { + signingKid = 'signingKid'; + signingKey = 'signingKey'; + bodyString = JSON.stringify({ key: 'value' }); + + signer = OpRequestSigner.create(signingKid, signingKey); + }); + + it('creates a RequestSigner', () => { + expect(typeof signer).toEqual('function'); + }); + + describe('when the RequestSigner is used', () => { + let signMock: jest.Mock; + let result: string; + + beforeEach(() => { + signMock = jest.fn().mockReturnThis(); + (signMock as any).sign = jest.fn(); + (signMock as any).write = jest.fn(); + (signMock as any).end = jest.fn(); + (createSign as jest.Mock).mockReturnValue(signMock); + }); + + it('creates a signature', () => { + result = signer(bodyString); + expect(createSign).toHaveBeenCalledWith('SHA256'); + expect((signMock as any).write).toHaveBeenCalledWith(expect.any(String)); + expect((signMock as any).end).toHaveBeenCalled(); + expect((signMock as any).sign).toHaveBeenCalledWith(signingKey, 'base64url'); + expect(result).toContain('..'); // Signature string contains two parts divided by '..' + }); + + }); + +}); diff --git a/op/OpRequestSigner.ts b/op/OpRequestSigner.ts new file mode 100644 index 0000000..42f8930 --- /dev/null +++ b/op/OpRequestSigner.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestSigner } from "../types/RequestSigner"; +import { LogService } from "../LogService"; +import { createSign } from "crypto"; +import { LogLevel } from "../types/LogLevel"; + +const LOG = LogService.createLogger( 'OpRequestSigner' ); + +export class OpRequestSigner { + + public static setLogLevel (level : LogLevel) : void { + LOG.setLogLevel(level); + } + + public static create ( + signingKid: string, + signingKey: string, + ) : RequestSigner { + const signingAlgorithm = 'SHA256'; + return ( + bodyString: string + ) : string => { + const iat = Math.floor(Date.now() / 1000); + const headerString = JSON.stringify({ + "b64": false, + "crit": ["b64", "urn:op.api.iat"], + "alg": "RS256", + "urn:op.api.iat": iat, + "kid": signingKid + }); + const headerEnc = Buffer.from(headerString, 'utf8').toString('base64url'); + LOG.debug(`HEADER_ENC = "${headerEnc}"`); + const sign = createSign(signingAlgorithm); + sign.write(`${headerEnc}.${bodyString}`); + sign.end(); + const signatureString = sign.sign(signingKey, 'base64url'); + LOG.debug(`SIGNATURE = "${signatureString}"`); + const requestSignatureString = `${headerEnc}..${signatureString}`; + LOG.debug(`REQ_SIGNATURE = "${requestSignatureString}"`); + return requestSignatureString; + }; + } + +} + diff --git a/op/OpRequestUtils.test.ts b/op/OpRequestUtils.test.ts new file mode 100644 index 0000000..362e0e7 --- /dev/null +++ b/op/OpRequestUtils.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals' +import { OpRequestUtils } from './OpRequestUtils'; +import { OpAuthClient } from "./OpAuthClient"; +import { ExplainCallback } from "../types/ExplainCallback"; +import { RequestClient } from "../RequestClient"; +import { LogLevel } from "../types/LogLevel"; + +const authClient: jest.Mocked = { + isAuthenticated: jest.fn(), + authenticate: jest.fn(), + getAccessKey: jest.fn(), +}; + +const requestClient: jest.Mocked = { + getJson: jest.fn(), + postText: jest.fn(), +} as unknown as jest.Mocked; + +const mockSigner = jest.fn(); + +const isDTO : jest.Mock = jest.fn() as unknown as jest.Mock; +const explainDTO: jest.Mock & ExplainCallback = jest.fn(); + +// Test data +const mockURL = 'https://test.com'; +const mockPath = '/path'; +const mockDTOName = 'DTO'; +const mockToken = 'mockToken'; +const mockBody = { key: 'value' }; + +describe('OpRequestUtils', () => { + + beforeAll(() => { + OpRequestUtils.setLogLevel(LogLevel.NONE); + }); + + beforeEach(() => { + jest.clearAllMocks(); + authClient.isAuthenticated.mockReturnValue(true); + authClient.getAccessKey.mockReturnValue(mockToken); + requestClient.getJson.mockResolvedValue({}); + requestClient.postText.mockResolvedValue(JSON.stringify({})); + isDTO.mockReturnValue(true); + }); + + describe('#createRequestHeaders', () => { + it('should create headers with the provided token', () => { + const headers = OpRequestUtils.createRequestHeaders(mockToken); + expect(headers['Content-Type']).toEqual('application/json'); + expect(headers['Authorization']).toEqual(`Bearer ${mockToken}`); + expect(headers['X-Request-ID']).toBeDefined(); + }); + }); + + describe('#authenticateIfNot', () => { + it('should authenticate if not already authenticated', async () => { + authClient.isAuthenticated.mockReturnValue(false); + await OpRequestUtils.authenticateIfNot(authClient, 'clientId', 'clientSecret'); + expect(authClient.isAuthenticated).toHaveBeenCalledTimes(1); + expect(authClient.authenticate).toHaveBeenCalledTimes(1); + expect(authClient.authenticate).toHaveBeenCalledWith('clientId', 'clientSecret'); + }); + }); + + describe('#getJsonRequest', () => { + + it('should successfully fetch JSON when authenticated', async () => { + const data = await OpRequestUtils.getJsonRequest(requestClient, authClient, mockURL, mockPath, isDTO as any, explainDTO, mockDTOName); + expect(requestClient.getJson).toHaveBeenCalledWith(`${mockURL}${mockPath}`, expect.anything()); + expect(data).toBeDefined(); + }); + + it('should throw an error if not already authenticated', async () => { + authClient.isAuthenticated.mockReturnValue(false); + authClient.getAccessKey.mockImplementation(() => { + throw new Error('Not authenticated'); + }); + await expect(OpRequestUtils.getJsonRequest(requestClient, authClient, mockURL, mockPath, isDTO as any, explainDTO, mockDTOName)).rejects.toThrowError('Not authenticated'); + expect(authClient.getAccessKey).toHaveBeenCalledTimes(1); + expect(authClient.authenticate).not.toHaveBeenCalled(); + }); + + it('should throw an error if the response is not a valid DTO', async () => { + isDTO.mockReturnValue(false); + explainDTO.mockReturnValue('Invalid DTO'); + await expect(OpRequestUtils.getJsonRequest(requestClient, authClient, mockURL, mockPath, isDTO as any, explainDTO, mockDTOName)) + .rejects + .toThrowError(`'${mockPath}': Response was not ${mockDTOName}: Invalid DTO`); + }); + + }); + + describe('#postSignedRequest', () => { + + it('should successfully post data when authenticated', async () => { + const data = await OpRequestUtils.postSignedRequest(requestClient, authClient, mockSigner, mockURL, mockPath, mockBody, isDTO as any, explainDTO, mockDTOName); + expect(requestClient.postText).toHaveBeenCalledWith(`${mockURL}${mockPath}`, JSON.stringify(mockBody), expect.anything()); + expect(data).toBeDefined(); + }); + + it('should throw an error if not already authenticated', async () => { + authClient.isAuthenticated.mockReturnValue(false); + authClient.getAccessKey.mockImplementation(() => { + throw new Error('Not authenticated'); + }); + await expect(OpRequestUtils.postSignedRequest(requestClient, authClient, mockSigner, mockURL, mockPath, mockBody, isDTO as any, explainDTO, mockDTOName)).rejects.toThrowError('Not authenticated'); + expect(authClient.getAccessKey).toHaveBeenCalledTimes(1); + expect(authClient.authenticate).not.toHaveBeenCalled(); + }); + + it('should throw an error if the response is not a valid DTO', async () => { + isDTO.mockReturnValue(false); + explainDTO.mockReturnValue('Invalid DTO'); + await expect(OpRequestUtils.postSignedRequest(requestClient, authClient, mockSigner, mockURL, mockPath, mockBody, isDTO as any, explainDTO, mockDTOName)) + .rejects + .toThrowError(`'${mockPath}': Response was not ${mockDTOName}: Invalid DTO`); + }); + + }); + +}); diff --git a/op/OpRequestUtils.ts b/op/OpRequestUtils.ts new file mode 100644 index 0000000..c5b601f --- /dev/null +++ b/op/OpRequestUtils.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { TestCallbackNonStandardOf } from "../types/TestCallback"; +import { ExplainCallback } from "../types/ExplainCallback"; +import { JsonAny, parseJson } from "../Json"; +import { OpAuthClient } from "./OpAuthClient"; +import { LogLevel } from "../types/LogLevel"; +import { LogService } from "../LogService"; +import { RequestClient } from "../RequestClient"; +import { RequestSigner } from "../types/RequestSigner"; + +const LOG = LogService.createLogger( 'OpRequestUtils' ); + +export class OpRequestUtils { + + public static setLogLevel (level : LogLevel) : void { + LOG.setLogLevel(level); + } + + /** + * + * @param token + */ + public static createRequestHeaders ( + token : string + ) : {[key: string]: string} { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'X-Request-ID': OpRequestUtils.createRequestId() + }; + } + + /** + * Create unique ID for this request + */ + public static createRequestId () : string { + return `${Date.now()}-${(Math.random()*100000000).toFixed(0)}`; + } + + /** + * Authenticates with the Op API if there is no session open. + * + * @param authClient + * @param clientId + * @param clientSecret + */ + public static async authenticateIfNot ( + authClient : OpAuthClient, + clientId : string, + clientSecret : string, + ) : Promise { + if (!authClient.isAuthenticated()) { + await authClient.authenticate(clientId, clientSecret); + } + } + + /** + * Performs JSON request on the OP API. + * + * Use `await OpRequestUtils.authenticateIfNot()` before calling this to + * verify there is open API access active. + * + * @param requestClient + * @param authClient + * @param url + * @param path + * @param isDTO + * @param explainDTO + * @param dtoName + */ + public static async getJsonRequest ( + requestClient: RequestClient, + authClient: OpAuthClient, + url: string, + path: string, + isDTO: TestCallbackNonStandardOf, + explainDTO: ExplainCallback, + dtoName : string, + ) : Promise { + const dto : JsonAny | undefined = await requestClient.getJson( + `${url}${path}`, + OpRequestUtils.createRequestHeaders(authClient.getAccessKey()) + ); + if (!isDTO(dto)) { + LOG.debug(`'${path}': invalid response = `, dto); + throw new TypeError(`'${path}': Response was not ${dtoName}: ${explainDTO(dto)}`); + } + return dto; + } + + /** + * Initiate a signed POST request. + * + * Use `await OpRequestUtils.authenticateIfNot()` before calling this to + * verify there is open API access active. + * + * @param requestClient + * @param authClient + * @param requestSigner + * @param url + * @param path Request path + * @param body Request body + * @param isDTO Response DTO check callback + * @param explainDTO Response DTO explain callback + * @param dtoName Response DTO type name + */ + public static async postSignedRequest ( + requestClient: RequestClient, + authClient: OpAuthClient, + requestSigner : RequestSigner, + url: string, + path: string, + body: BodyT, + isDTO: TestCallbackNonStandardOf, + explainDTO: ExplainCallback, + dtoName : string, + ) : Promise { + const token = authClient.getAccessKey(); + const bodyString : string = JSON.stringify(body); + const xReqSignature : string = requestSigner(bodyString); + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'X-Req-Signature': xReqSignature + }; + const resultString = await requestClient.postText( + `${url}${path}`, + bodyString, + headers + ); + const dto = parseJson(resultString!); + if (!isDTO(dto)) { + LOG.debug(`'${path}': Response = `, dto); + throw new TypeError(`'${path}': Response was not ${dtoName}: ${explainDTO(dto)}`); + } + return dto; + } + +} diff --git a/op/dto/OpAccountDTO.test.ts b/op/dto/OpAccountDTO.test.ts new file mode 100644 index 0000000..c538470 --- /dev/null +++ b/op/dto/OpAccountDTO.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpAccountDTO, createOpAccountDTO, isOpAccountDTO, explainOpAccountDTO, parseOpAccountDTO, isOpAccountDTOOrUndefined, explainOpAccountDTOOrUndefined } from './OpAccountDTO'; + +describe('OpAccountDTO', () => { + const validOpAccountDTO: OpAccountDTO = { + bic: 'OKOYFIHH', + iban: 'FI7450009420999999', + name: 'Companys payroll account', + balance: '-12.3', + currency: 'EUR', + surrogateId: 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', + productNames: { + property1: 'string', + property2: 'string' + } + }; + const invalidOpAccountDTO = { + bic: 1234, + iban: 'FI7450009420999999', + name: 'Companys payroll account', + balance: '-12.3', + currency: 'EUR', + surrogateId: 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', + productNames: { + property1: 'string', + property2: 'string' + } + }; + + describe('createOpAccountDTO', () => { + + it('should create a valid OpAccountDTO object', () => { + expect(createOpAccountDTO('OKOYFIHH', 'FI7450009420999999', 'Companys payroll account', '-12.3', 'EUR', 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', { property1: 'string', property2: 'string' })) + .toEqual(validOpAccountDTO); + }); + + }); + + describe('isOpAccountDTO', () => { + + it('should return true for a valid OpAccountDTO', () => { + expect(isOpAccountDTO(validOpAccountDTO)).toBe(true); + }); + + it('should return false for an invalid OpAccountDTO', () => { + expect(isOpAccountDTO(invalidOpAccountDTO)).toBe(false); + }); + + }); + + describe('explainOpAccountDTO', () => { + + it('should return OK for a valid OpAccountDTO', () => { + expect(explainOpAccountDTO(validOpAccountDTO)).toBe('OK'); + }); + + it('should return explanation for an invalid OpAccountDTO', () => { + expect(explainOpAccountDTO(invalidOpAccountDTO)).toContain('property "bic"'); + }); + + }); + + describe('parseOpAccountDTO', () => { + + it('should return valid OpAccountDTO object for a valid input', () => { + expect(parseOpAccountDTO(validOpAccountDTO)).toEqual(validOpAccountDTO); + }); + + it('should return undefined for an invalid input', () => { + expect(parseOpAccountDTO(invalidOpAccountDTO)).toBeUndefined(); + }); + + }); + + describe('isOpAccountDTOOrUndefined', () => { + + it('should return true for a valid OpAccountDTO', () => { + expect(isOpAccountDTOOrUndefined(validOpAccountDTO)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpAccountDTOOrUndefined(undefined)).toBe(true); + }); + + it('should return false for an invalid OpAccountDTO', () => { + expect(isOpAccountDTOOrUndefined(invalidOpAccountDTO)).toBe(false); + }); + + }); + + describe('explainOpAccountDTOOrUndefined', () => { + + it('should return OK for a valid OpAccountDTO', () => { + expect(explainOpAccountDTOOrUndefined(validOpAccountDTO)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + expect(explainOpAccountDTOOrUndefined(undefined)).toBe('OK'); + }); + + it('should return explanation for an invalid OpAccountDTO', () => { + expect(explainOpAccountDTOOrUndefined(invalidOpAccountDTO)).toContain('not OpAccountDTO or undefined'); + }); + + }); + +}); diff --git a/op/dto/OpAccountDTO.ts b/op/dto/OpAccountDTO.ts new file mode 100644 index 0000000..82ac403 --- /dev/null +++ b/op/dto/OpAccountDTO.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainReadonlyJsonObject, isReadonlyJsonObject, ReadonlyJsonObject } from "../../Json"; + +/** + * OP bank account information inside a response list. + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/accounts + * + * @example + * { + * "bic": "OKOYFIHH", + * "iban": "FI7450009420999999", + * "name": "Companys payroll account", + * "balance": "-12.3", + * "currency": "EUR", + * "surrogateId": "rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==", + * "productNames": { + * "property1": "string", + * "property2": "string" + * } + * } + */ +export interface OpAccountDTO { + + /** + * Bank Identification Code + */ + readonly bic: string; + + /** + * International Bank Account Number + */ + readonly iban: string; + + /** + * Account name given by account user or if not set the corresponding + * product name + */ + readonly name : string; + + /** + * Balance of an account + */ + readonly balance : string; + + /** + * ISO currency code + */ + readonly currency : string; + + /** + * Surrogate identifier used in place of actual iban in further requests + */ + readonly surrogateId : string; + + /** + * Map of languages to product names + */ + readonly productNames : ReadonlyJsonObject; + + /** + * Account type code. + * + * Note! This is undocumented at OP API documentation, but exist in + * the response. Hence, this is defined as optional since no information if + * it is optional or not. + */ + readonly accountTypeCode ?: string; + +} + +export function createOpAccountDTO ( + bic : string, + iban : string, + name : string, + balance : string, + currency : string, + surrogateId : string, + productNames : ReadonlyJsonObject, + accountTypeCode ?: string, +) : OpAccountDTO { + return { + bic, + iban, + name, + balance, + currency, + surrogateId, + productNames, + accountTypeCode + }; +} + +export function isOpAccountDTO (value: unknown) : value is OpAccountDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'bic', + 'iban', + 'name', + 'balance', + 'currency', + 'surrogateId', + 'productNames', + 'accountTypeCode', + ]) + && isString(value?.bic) + && isString(value?.iban) + && isString(value?.name) + && isString(value?.balance) + && isString(value?.currency) + && isString(value?.surrogateId) + && isReadonlyJsonObject(value?.productNames) + && isStringOrUndefined(value?.accountTypeCode) + ); +} + +export function explainOpAccountDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'bic', + 'iban', + 'name', + 'balance', + 'currency', + 'surrogateId', + 'productNames', + 'accountTypeCode', + ]) + , explainProperty("bic", explainString(value?.bic)) + , explainProperty("iban", explainString(value?.iban)) + , explainProperty("name", explainString(value?.name)) + , explainProperty("balance", explainString(value?.balance)) + , explainProperty("currency", explainString(value?.currency)) + , explainProperty("surrogateId", explainString(value?.surrogateId)) + , explainProperty("productNames", explainReadonlyJsonObject(value?.productNames)) + , explainProperty("accountTypeCode", explainStringOrUndefined(value?.accountTypeCode)) + ] + ); +} + +export function parseOpAccountDTO (value: unknown) : OpAccountDTO | undefined { + if (isOpAccountDTO(value)) return value; + return undefined; +} + +export function isOpAccountDTOOrUndefined (value: unknown): value is OpAccountDTO | undefined { + return isUndefined(value) || isOpAccountDTO(value); +} + +export function explainOpAccountDTOOrUndefined (value: unknown): string { + return isOpAccountDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpAccountDTO', 'undefined'])); +} diff --git a/op/dto/OpAccountDetailsDTO.test.ts b/op/dto/OpAccountDetailsDTO.test.ts new file mode 100644 index 0000000..fec9b86 --- /dev/null +++ b/op/dto/OpAccountDetailsDTO.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpAccountDetailsDTO, explainOpAccountDetailsDTO, explainOpAccountDetailsDTOOrUndefined, isOpAccountDetailsDTO, isOpAccountDetailsDTOOrUndefined, parseOpAccountDetailsDTO } from "./OpAccountDetailsDTO"; + +describe('OpAccountDetailsDTO functions', () => { + const validOpAccountDetailsDTO = { + bic: 'OKOYFIHH', + iban: 'FI7450009499999999', + dueDate: '29.11.2019', + ownerId: '1234567-8', + currency: 'EUR', + netBalance: '222.22', + accountName: 'Some name given by client', + creditLimit: 0, + surrogateId: 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', + accountOwner: 'Firstname Lastname', + creationDate: '29.11.2011', + grossBalance: '222.22', + intraDayLimit: '222.22' + }; + + const invalidOpAccountDetailsDTO = { + bic: 'OKOYFIHH', + iban: 'FI7450009499999999', + dueDate: '29.11.2019', + ownerId: '1234567-8', + currency: 'EUR', + netBalance: '222.22', + accountName: 'Some name given by client', + creditLimit: 'invalid', + surrogateId: 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', + accountOwner: 'Firstname Lastname', + creationDate: '29.11.2011', + grossBalance: '222.22', + intraDayLimit: '222.22' + }; + + describe('createOpAccountDetailsDTO', () => { + it('should create an OpAccountDetailsDTO', () => { + const account = createOpAccountDetailsDTO( + 'OKOYFIHH', + 'FI7450009499999999', + '29.11.2019', + '1234567-8', + 'EUR', + '222.22', + 'Some name given by client', + 0, + 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', + 'Firstname Lastname', + '29.11.2011', + '222.22', + '222.22'); + expect(account).toEqual(validOpAccountDetailsDTO); + }); + }); + + describe('isOpAccountDetailsDTO', () => { + it('should return true for a valid OpAccountDetailsDTO', () => { + expect(isOpAccountDetailsDTO(validOpAccountDetailsDTO)).toBe(true); + }); + + it('should return false for an invalid OpAccountDetailsDTO', () => { + expect(isOpAccountDetailsDTO(invalidOpAccountDetailsDTO)).toBe(false); + }); + }); + + describe('explainOpAccountDetailsDTO', () => { + + it('should return OK for a valid OpAccountDetailsDTO', () => { + expect(explainOpAccountDetailsDTO(validOpAccountDetailsDTO)).toBe('OK'); + }); + + it('should return explanation for an invalid OpAccountDetailsDTO', () => { + expect(explainOpAccountDetailsDTO(invalidOpAccountDetailsDTO)).toContain('property "creditLimit"'); + }); + + }); + describe('parseOpAccountDetailsDTO', () => { + it('should return the given value if it is a valid OpAccountDetailsDTO', () => { + expect(parseOpAccountDetailsDTO(validOpAccountDetailsDTO)).toEqual(validOpAccountDetailsDTO); + }); + + it('should return undefined if the given value is not a valid OpAccountDetailsDTO', () => { + expect(parseOpAccountDetailsDTO(invalidOpAccountDetailsDTO)).toBeUndefined(); + }); + }); + + describe('isOpAccountDetailsDTOOrUndefined', () => { + it('should return true for a valid OpAccountDetailsDTO', () => { + expect(isOpAccountDetailsDTOOrUndefined(validOpAccountDetailsDTO)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpAccountDetailsDTOOrUndefined(undefined)).toBe(true); + }); + + it('should return false for an invalid OpAccountDetailsDTO', () => { + expect(isOpAccountDetailsDTOOrUndefined(invalidOpAccountDetailsDTO)).toBe(false); + }); + }); + + describe('explainOpAccountDetailsDTOOrUndefined', () => { + it('should return OK for a valid OpAccountDetailsDTO', () => { + expect(explainOpAccountDetailsDTOOrUndefined(validOpAccountDetailsDTO)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + expect(explainOpAccountDetailsDTOOrUndefined(undefined)).toBe('OK'); + }); + + it('should return explanation for an invalid OpAccountDetailsDTO', () => { + expect(explainOpAccountDetailsDTOOrUndefined(invalidOpAccountDetailsDTO)).toContain('not OpAccountDetailsDTO or undefined'); + }); + }); + +}); diff --git a/op/dto/OpAccountDetailsDTO.ts b/op/dto/OpAccountDetailsDTO.ts new file mode 100644 index 0000000..7b71312 --- /dev/null +++ b/op/dto/OpAccountDetailsDTO.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrNull, isString, isStringOrNull } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainNumber, isNumber } from "../../types/Number"; + +/** + * OP bank account details response DTO. + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/account + * + * @example + * + * { + * "bic": "OKOYFIHH", + * "iban": "FI7450009499999999", + * "dueDate": "29.11.2019", + * "ownerId": "1234567-8", + * "currency": "EUR", + * "netBalance": "222.22", + * "accountName": "Some name given by client", + * "creditLimit": 0, + * "surrogateId": "rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==", + * "accountOwner": "Firstname Lastname", + * "creationDate": "29.11.2011", + * "grossBalance": "222.22", + * "intraDayLimit": "222.22" + * } + */ +export interface OpAccountDetailsDTO { + + /** + * Bank Identification Code + */ + readonly bic: string; + + /** + * International Bank Account Number + */ + readonly iban: string; + + /** + * Account due date for fixed term accounts + */ + readonly dueDate: string | null; + + /** + * Owner identifier + */ + readonly ownerId: string; + + /** + * ISO currency code + */ + readonly currency : string; + + /** + * Balance of the account including cover reservations + */ + readonly netBalance : string; + + /** + * Account name + */ + readonly accountName : string | null; + + /** + * Maximum credit amount for the account + */ + readonly creditLimit : number; + + /** + * Surrogate identifier used in place of actual iban in further requests + */ + readonly surrogateId : string; + + /** + * Account owner + */ + readonly accountOwner : string; + + /** + * Account creation date + */ + readonly creationDate : string; + + /** + * Gross balance of the account + */ + readonly grossBalance : string; + + /** + * Maximum allowed amount for negative balance per day + */ + readonly intraDayLimit : string | null; + +} + +export function createOpAccountDetailsDTO ( + bic: string, + iban: string, + dueDate: string | null, + ownerId: string, + currency : string, + netBalance : string, + accountName : string | null, + creditLimit : number, + surrogateId : string, + accountOwner : string, + creationDate : string, + grossBalance : string, + intraDayLimit : string | null, +) : OpAccountDetailsDTO { + return { + bic, + iban, + dueDate, + ownerId, + currency, + netBalance, + accountName, + creditLimit, + surrogateId, + accountOwner, + creationDate, + grossBalance, + intraDayLimit, + }; +} + +export function isOpAccountDetailsDTO (value: unknown) : value is OpAccountDetailsDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'bic', + 'iban', + 'dueDate', + 'ownerId', + 'currency', + 'netBalance', + 'accountName', + 'creditLimit', + 'surrogateId', + 'accountOwner', + 'creationDate', + 'grossBalance', + 'intraDayLimit', + ]) + && isString(value?.bic) + && isString(value?.iban) + && isStringOrNull(value?.dueDate) + && isString(value?.ownerId) + && isString(value?.currency) + && isString(value?.netBalance) + && isStringOrNull(value?.accountName) + && isNumber(value?.creditLimit) + && isString(value?.surrogateId) + && isString(value?.accountOwner) + && isString(value?.creationDate) + && isString(value?.grossBalance) + && isStringOrNull(value?.intraDayLimit) + ); +} + +export function explainOpAccountDetailsDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'bic', + 'iban', + 'dueDate', + 'ownerId', + 'currency', + 'netBalance', + 'accountName', + 'creditLimit', + 'surrogateId', + 'accountOwner', + 'creationDate', + 'grossBalance', + 'intraDayLimit', + ]) + , explainProperty("bic", explainString(value?.bic)) + , explainProperty("iban", explainString(value?.iban)) + , explainProperty("dueDate", explainStringOrNull(value?.dueDate)) + , explainProperty("ownerId", explainString(value?.ownerId)) + , explainProperty("currency", explainString(value?.currency)) + , explainProperty("netBalance", explainString(value?.netBalance)) + , explainProperty("accountName", explainStringOrNull(value?.accountName)) + , explainProperty("creditLimit", explainNumber(value?.creditLimit)) + , explainProperty("surrogateId", explainString(value?.surrogateId)) + , explainProperty("accountOwner", explainString(value?.accountOwner)) + , explainProperty("creationDate", explainString(value?.creationDate)) + , explainProperty("grossBalance", explainString(value?.grossBalance)) + , explainProperty("intraDayLimit", explainStringOrNull(value?.intraDayLimit)) + ] + ); +} + +export function parseOpAccountDetailsDTO (value: unknown) : OpAccountDetailsDTO | undefined { + if (isOpAccountDetailsDTO(value)) return value; + return undefined; +} + +export function isOpAccountDetailsDTOOrUndefined (value: unknown): value is OpAccountDetailsDTO | undefined { + return isUndefined(value) || isOpAccountDetailsDTO(value); +} + +export function explainOpAccountDetailsDTOOrUndefined (value: unknown): string { + return isOpAccountDetailsDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpAccountDetailsDTO', 'undefined'])); +} diff --git a/op/dto/OpAccountListDTO.test.ts b/op/dto/OpAccountListDTO.test.ts new file mode 100644 index 0000000..c14b74e --- /dev/null +++ b/op/dto/OpAccountListDTO.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpAccountDTO } from './OpAccountDTO'; +import { + OpAccountListDTO, + createOpAccountListDTO, + isOpAccountListDTO, + explainOpAccountListDTO, + parseOpAccountListDTO, + isOpAccountListDTOOrUndefined, + explainOpAccountListDTOOrUndefined +} from './OpAccountListDTO'; + +describe('OpAccountListDTO', () => { + const validOpAccountDTO: OpAccountDTO = { + bic: 'OKOYFIHH', + iban: 'FI7450009420999999', + name: 'Companys payroll account', + balance: '-12.3', + currency: 'EUR', + surrogateId: 'rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==', + productNames: { + property1: 'string', + property2: 'string' + } + }; + const validOpAccountListDTO: OpAccountListDTO = [validOpAccountDTO, validOpAccountDTO]; + const invalidOpAccountListDTO = [validOpAccountDTO, 'invalid data']; + + describe('createOpAccountListDTO', () => { + it('should create a valid OpAccountListDTO object', () => { + expect(createOpAccountListDTO([validOpAccountDTO, validOpAccountDTO])).toEqual(validOpAccountListDTO); + }); + }); + + describe('isOpAccountListDTO', () => { + it('should return true for a valid OpAccountListDTO', () => { + expect(isOpAccountListDTO(validOpAccountListDTO)).toBe(true); + }); + + it('should return false for an invalid OpAccountListDTO', () => { + expect(isOpAccountListDTO(invalidOpAccountListDTO)).toBe(false); + }); + }); + + describe('explainOpAccountListDTO', () => { + it('should return OK for a valid OpAccountListDTO', () => { + expect(explainOpAccountListDTO(validOpAccountListDTO)).toBe('OK'); + }); + + it('should return explanation for an invalid OpAccountListDTO', () => { + expect(explainOpAccountListDTO(invalidOpAccountListDTO)).toContain('OpAccountDTO'); + }); + }); + + describe('parseOpAccountListDTO', () => { + it('should return valid OpAccountListDTO object for a valid input', () => { + expect(parseOpAccountListDTO(validOpAccountListDTO)).toEqual(validOpAccountListDTO); + }); + + it('should return undefined for an invalid input', () => { + expect(parseOpAccountListDTO(invalidOpAccountListDTO)).toBeUndefined(); + }); + }); + + describe('isOpAccountListDTOOrUndefined', () => { + it('should return true for a valid OpAccountListDTO', () => { + expect(isOpAccountListDTOOrUndefined(validOpAccountListDTO)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpAccountListDTOOrUndefined(undefined)).toBe(true); + }); + + it('should return false for an invalid OpAccountListDTO', () => { + expect(isOpAccountListDTOOrUndefined(invalidOpAccountListDTO)).toBe(false); + }); + }); + + describe('explainOpAccountListDTOOrUndefined', () => { + it('should return OK for a valid OpAccountListDTO', () => { + expect(explainOpAccountListDTOOrUndefined(validOpAccountListDTO)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + expect(explainOpAccountListDTOOrUndefined(undefined)).toBe('OK'); + }); + + it('should return explanation for an invalid OpAccountListDTO', () => { + expect(explainOpAccountListDTOOrUndefined(invalidOpAccountListDTO)).toContain('OpAccountListDTO'); + }); + }); + +}); diff --git a/op/dto/OpAccountListDTO.ts b/op/dto/OpAccountListDTO.ts new file mode 100644 index 0000000..eab9717 --- /dev/null +++ b/op/dto/OpAccountListDTO.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpAccountDTO, isOpAccountDTO, OpAccountDTO } from "./OpAccountDTO"; +import { map } from "../../functions/map"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +/** + * OP Bank account list response DTO. + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/accounts + * + * @example + * [ + * { + * "bic": "OKOYFIHH", + * "iban": "FI7450009420999999", + * "name": "Companys payroll account", + * "balance": "-12.3", + * "currency": "EUR", + * "surrogateId": "rNEl6nJ-VgIqbIfyNDBPo-Un2SBTa6zMDspKshS3J6fOlQ==", + * "productNames": { + * "property1": "string", + * "property2": "string" + * } + * } + * ] + */ +export type OpAccountListDTO = readonly OpAccountDTO[]; + +export function createOpAccountListDTO ( + list : readonly OpAccountDTO[] +) : OpAccountListDTO { + return map( + list, + (item : OpAccountDTO ) : OpAccountDTO => item + ); +} + +export function isOpAccountListDTO (value: unknown) : value is OpAccountListDTO { + return isArrayOf(value, isOpAccountDTO); +} + +export function explainOpAccountListDTO (value: any) : string { + return explainArrayOf("OpAccountDTO", explainOpAccountDTO, value, isOpAccountDTO); +} + +export function parseOpAccountListDTO (value: unknown) : OpAccountListDTO | undefined { + if (isOpAccountListDTO(value)) return value; + return undefined; +} + +export function isOpAccountListDTOOrUndefined (value: unknown): value is OpAccountListDTO | undefined { + return isUndefined(value) || isOpAccountListDTO(value); +} + +export function explainOpAccountListDTOOrUndefined (value: unknown): string { + return isOpAccountListDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpAccountListDTO', 'undefined'])); +} diff --git a/op/dto/OpPaymentDTO.ts b/op/dto/OpPaymentDTO.ts new file mode 100644 index 0000000..cf34809 --- /dev/null +++ b/op/dto/OpPaymentDTO.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpPaymentRequestDTO, isOpPaymentRequestDTO, OpPaymentRequestDTO } from "./OpPaymentRequestDTO"; +import { explainOpPaymentResponseDTOOrUndefined, isOpPaymentResponseDTOOrUndefined, OpPaymentResponseDTO } from "./OpPaymentResponseDTO"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export interface OpPaymentDTO { + readonly request : OpPaymentRequestDTO; + readonly response ?: OpPaymentResponseDTO; +} + +export function createOpPaymentDTO ( + request : OpPaymentRequestDTO, + response ?: OpPaymentResponseDTO, +) : OpPaymentDTO { + return { + request, + response + }; +} + +export function isOpPaymentDTO (value: unknown) : value is OpPaymentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'request', + 'response', + ]) + && isOpPaymentRequestDTO(value?.request) + && isOpPaymentResponseDTOOrUndefined(value?.response) + ); +} + +export function explainOpPaymentDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'request', + 'response', + ]) + , explainProperty("request", explainOpPaymentRequestDTO(value?.request)) + , explainProperty("response", explainOpPaymentResponseDTOOrUndefined(value?.response)) + ] + ); +} + +export function parseOpPaymentDTO (value: unknown) : OpPaymentDTO | undefined { + if (isOpPaymentDTO(value)) return value; + return undefined; +} + +export function isOpPaymentDTOOrUndefined (value: unknown): value is OpPaymentDTO | undefined { + return isUndefined(value) || isOpPaymentDTO(value); +} + +export function explainOpPaymentDTOOrUndefined (value: unknown): string { + return isOpPaymentDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentDTO', 'undefined'])); +} diff --git a/op/dto/OpPaymentListDTO.test.ts b/op/dto/OpPaymentListDTO.test.ts new file mode 100644 index 0000000..6a19c7c --- /dev/null +++ b/op/dto/OpPaymentListDTO.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpPaymentResponseDTO } from "./OpPaymentResponseDTO"; +import { createOpPaymentListDTO, explainOpPaymentListDTO, explainOpPaymentListDTOOrUndefined, isOpPaymentListDTO, isOpPaymentListDTOOrUndefined, parseOpPaymentListDTO } from "./OpPaymentListDTO"; +import { OpPaymentStatus } from "../types/OpPaymentStatus"; +import { OpPaymentType } from "../types/OpPaymentType"; +import { Currency } from "../../types/Currency"; + +const MOCK_PAYMENT_RESPONSE: OpPaymentResponseDTO = { + "amount": "3.45", + "status": OpPaymentStatus.PROCESSED, + "currency": Currency.EUR, + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": OpPaymentType.SCT_INST, + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" +}; + +const MOCK_PAYMENT_LIST: readonly OpPaymentResponseDTO[] = [MOCK_PAYMENT_RESPONSE]; + +describe('OpPaymentListDTO functions', () => { + + describe('createOpPaymentListDTO', () => { + it('should create OpPaymentListDTO from given OpPaymentResponseDTO array', () => { + const result = createOpPaymentListDTO(MOCK_PAYMENT_LIST); + expect(result).toEqual(MOCK_PAYMENT_LIST); + }); + }); + + describe('isOpPaymentListDTO', () => { + it('should correctly identify OpPaymentListDTO', () => { + expect(isOpPaymentListDTO(MOCK_PAYMENT_LIST)).toBe(true); + expect(isOpPaymentListDTO({})).toBe(false); + }); + }); + + describe('explainOpPaymentListDTO', () => { + it('should provide correct explanation for OpPaymentListDTO', () => { + expect(explainOpPaymentListDTO(MOCK_PAYMENT_LIST)).toEqual('OK'); + expect(explainOpPaymentListDTO({})).toEqual('not OpPaymentResponseDTO'); + }); + }); + + describe('parseOpPaymentListDTO', () => { + it('should parse OpPaymentListDTO correctly', () => { + expect(parseOpPaymentListDTO(MOCK_PAYMENT_LIST)).toEqual(MOCK_PAYMENT_LIST); + expect(parseOpPaymentListDTO({})).toBeUndefined(); + }); + }); + + describe('isOpPaymentListDTOOrUndefined', () => { + it('should correctly identify OpPaymentListDTO or undefined', () => { + expect(isOpPaymentListDTOOrUndefined(MOCK_PAYMENT_LIST)).toBe(true); + expect(isOpPaymentListDTOOrUndefined(undefined)).toBe(true); + expect(isOpPaymentListDTOOrUndefined({})).toBe(false); + }); + }); + + describe('explainOpPaymentListDTOOrUndefined', () => { + it('should provide correct explanation for OpPaymentListDTO or undefined', () => { + expect(explainOpPaymentListDTOOrUndefined(MOCK_PAYMENT_LIST)).toEqual('OK'); + expect(explainOpPaymentListDTOOrUndefined(undefined)).toEqual('OK'); + expect(explainOpPaymentListDTOOrUndefined({})).toEqual('not OpPaymentListDTO or undefined'); + }); + }); + +}); diff --git a/op/dto/OpPaymentListDTO.ts b/op/dto/OpPaymentListDTO.ts new file mode 100644 index 0000000..967a78a --- /dev/null +++ b/op/dto/OpPaymentListDTO.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpPaymentResponseDTO, isOpPaymentResponseDTO, OpPaymentResponseDTO } from "./OpPaymentResponseDTO"; +import { map } from "../../functions/map"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +/** + * OP payment list response DTO. + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#operation/instantPaymentStatus + * + * @example + * [ + * { + * "amount": "3.45", + * "status": "PROCESSED", + * "currency": "EUR", + * "archiveId": "20190524593156999999", + * "debtorIban": "FI4550009420888888", + * "ultimateDebtorName": "Ultimate Debtor", + * "bookingDate": "2019-05-12", + * "paymentType": "SCT_INST", + * "creditorIban": "FI4550009420999999", + * "creditorName": "Cedric Creditor", + * "ultimateCreditorName": "Ultimate Creditor", + * "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + * "transactionDate": "2019-05-11", + * "endToEndId": "544652-end2end" + * } + * ] + */ +export type OpPaymentListDTO = readonly OpPaymentResponseDTO[]; + +export function createOpPaymentListDTO ( + list : readonly OpPaymentResponseDTO[] +) : OpPaymentListDTO { + return map( + list, + (item : OpPaymentResponseDTO ) : OpPaymentResponseDTO => item + ); +} + +export function isOpPaymentListDTO (value: unknown) : value is OpPaymentListDTO { + return isArrayOf(value, isOpPaymentResponseDTO); +} + +export function explainOpPaymentListDTO (value: any) : string { + return explainArrayOf("OpPaymentResponseDTO", explainOpPaymentResponseDTO, value, isOpPaymentResponseDTO); +} + +export function parseOpPaymentListDTO (value: unknown) : OpPaymentListDTO | undefined { + if (isOpPaymentListDTO(value)) return value; + return undefined; +} + +export function isOpPaymentListDTOOrUndefined (value: unknown): value is OpPaymentListDTO | undefined { + return isUndefined(value) || isOpPaymentListDTO(value); +} + +export function explainOpPaymentListDTOOrUndefined (value: unknown): string { + return isOpPaymentListDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentListDTO', 'undefined'])); +} diff --git a/op/dto/OpPaymentRequestDTO.test.ts b/op/dto/OpPaymentRequestDTO.test.ts new file mode 100644 index 0000000..f1014e5 --- /dev/null +++ b/op/dto/OpPaymentRequestDTO.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CountryCode } from "../../types/CountryCode"; +import { Currency } from "../../types/Currency"; +import { createOpPaymentRequestDTO, explainOpPaymentRequestDTO, explainOpPaymentRequestDTOOrUndefined, isOpPaymentRequestDTO, isOpPaymentRequestDTOOrUndefined, OpPaymentRequestDTO, parseOpPaymentRequestDTO } from "./OpPaymentRequestDTO"; +import { OpPaymentCreditor } from "../types/OpPaymentCreditor"; + +describe('OpPaymentRequestDTO', () => { + + const validPaymentRequest: OpPaymentRequestDTO = { + instructionId: '123456', + creditor: { + name: 'Test Name', + iban: 'FI3859991620004143', + address: { + country: 'FI' as CountryCode, + addressLine: ['a1', 'a2'] + } + } as OpPaymentCreditor, + debtor: { + name: 'Test Name', + iban: 'FI3859991620004143', + address: { + country: 'FI' as CountryCode, + addressLine: ['a1', 'a2'] + } + }, + instructedAmount: { + amount: '100.00', + currency: 'USD' as Currency, + } + }; + + const invalidPaymentRequest = { unexpectedKey: 'unexpectedValue' }; + + describe('createOpPaymentRequestDTO', () => { + it('should create an OpPaymentRequestDTO', () => { + const paymentRequest = createOpPaymentRequestDTO( + validPaymentRequest.instructionId, + validPaymentRequest.creditor, + validPaymentRequest.debtor, + validPaymentRequest.instructedAmount, + ); + expect(paymentRequest).toEqual(validPaymentRequest); + }); + }); + + describe('isOpPaymentRequestDTO', () => { + it('should return true for valid OpPaymentRequestDTO', () => { + expect(isOpPaymentRequestDTO(validPaymentRequest)).toBe(true); + }); + + it('should return false for invalid OpPaymentRequestDTO', () => { + expect(isOpPaymentRequestDTO(invalidPaymentRequest)).toBe(false); + }); + }); + + describe('explainOpPaymentRequestDTO', () => { + it('should return expected explanation for valid and invalid requests', () => { + // replace 'OK' with the actual expected explanation + expect(explainOpPaymentRequestDTO(validPaymentRequest)).toBe('OK'); + expect(explainOpPaymentRequestDTO(invalidPaymentRequest)).toContain('Value had extra properties: unexpectedKey'); + }); + }); + + describe('parseOpPaymentRequestDTO', () => { + it('should parse valid object to OpPaymentRequestDTO', () => { + expect(parseOpPaymentRequestDTO(validPaymentRequest)).toEqual(validPaymentRequest); + }); + + it('should return undefined for invalid object', () => { + expect(parseOpPaymentRequestDTO(invalidPaymentRequest)).toBeUndefined(); + }); + }); + + describe('isOpPaymentRequestDTOOrUndefined', () => { + it('should return true for valid OpPaymentRequestDTO or undefined', () => { + expect(isOpPaymentRequestDTOOrUndefined(validPaymentRequest)).toBe(true); + expect(isOpPaymentRequestDTOOrUndefined(undefined)).toBe(true); + }); + + it('should return false for invalid OpPaymentRequestDTO', () => { + expect(isOpPaymentRequestDTOOrUndefined(invalidPaymentRequest)).toBe(false); + }); + }); + + describe('explainOpPaymentRequestDTOOrUndefined', () => { + it('should return expected explanation for valid, invalid, and undefined requests', () => { + // replace 'OK' with the actual expected explanation + expect(explainOpPaymentRequestDTOOrUndefined(validPaymentRequest)).toBe('OK'); + expect(explainOpPaymentRequestDTOOrUndefined(invalidPaymentRequest)).toBe('not OpPaymentRequestDTO or undefined'); + expect(explainOpPaymentRequestDTOOrUndefined(undefined)).toBe('OK'); + }); + }); + +}); diff --git a/op/dto/OpPaymentRequestDTO.ts b/op/dto/OpPaymentRequestDTO.ts new file mode 100644 index 0000000..ffd964e --- /dev/null +++ b/op/dto/OpPaymentRequestDTO.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainOpPaymentCreditor, isOpPaymentCreditor, OpPaymentCreditor } from "../types/OpPaymentCreditor"; +import { explainOpPaymentDebtor, isOpPaymentDebtor, OpPaymentDebtor } from "../types/OpPaymentDebtor"; +import { explainOpPaymentInstructedAmount, isOpPaymentInstructedAmount, OpPaymentInstructedAmount } from "../types/OpPaymentInstructedAmount"; +import { explainOpUltimateDebtorOrUndefined, isOpUltimateDebtorOrUndefined, OpUltimateDebtor } from "../types/OpUltimateDebtor"; +import { explainOpUltimateCreditorOrUndefined, isOpUltimateCreditorOrUndefined, OpUltimateCreditor } from "../types/OpUltimateCreditor"; + +/** + * SEPA and SEPA instant payment request DTO. + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#operation/payment + * @example + * { + * "instructionId":"$INSTRUCTIONID", + * "endToEndId":"endToEndId", + * "creditor":{ + * "name":"Creditor Name", + * "iban":"FI3859991620004143", + * "address":{ + * "addressLine":["a1","a2"], + * "country":"FI" +* } + * }, + * "debtor":{ + * "name":"Debtor Name", + * "iban":"FI6359991620004275", + * "address":{ + * "addressLine":["a1","a2"], + * "country":"FI" +* } + * }, + * "instructedAmount":{ + * "currency":"EUR", + * "amount":"0.16" + * }, + * "reference":"00000000000000482738" + * } + * + * @example + * + * { + * "debtor": { + * "iban": "FI4550009420999999", + * "name": "Debtor Name", + * "address": { + * "country": "FI", + * "addressLine": [ + * "string", + * "string" + * ] + * } + * }, + * "ultimateDebtor": { + * "name": "Ultimate Debtor", + * "identification": { + * "id": "1234567-8", + * "schemeName": "BIC", + * "issuer": "string" + * }, + * "address": { + * "country": "FI", + * "addressLine": [ + * "string", + * "string" + * ] + * } + * }, + * "message": "Less money, fewer problems", + * "creditor": { + * "iban": "FI4550009420888888", + * "name": "Creditor Name", + * "address": { + * "country": "FI", + * "addressLine": [ + * "string", + * "string" + * ] + * } + * }, + * "ultimateCreditor": { + * "name": "Ultimate Creditor", + * "identification": { + * "id": "1234567-8", + * "schemeName": "BIC", + * "issuer": "string" + * }, + * "address": { + * "country": "FI", + * "addressLine": [ + * "string", + * "string" + * ] + * } + * }, + * "reference": "00000000000000482738", + * "endToEndId": "544652-end2end", + * "instructionId": "AtoZatoz01234567898NoLongerThan35", + * "instructedAmount": { + * "amount": "12.35", + * "currency": "EUR" + * } + * } + * + */ +export interface OpPaymentRequestDTO { + + /** + * ^[a-zA-Z0-9]{1,35}$ + * + * Unique identification, as assigned by the original instructing party for + * the original instructed party, to unambiguously identify the original + * instruction. This is used to check for duplicate payments, for example, + * in cases where the end-user has not received a response from the server. + * In this case the end user can initiate the same payment with the same + * instructionId and the server can check if the payment has already been + * processed based on the value of the instructionId. + */ + readonly instructionId: string; + + /** + * Creditor for the payment + */ + readonly creditor: OpPaymentCreditor; + + /** + * Debtor for the payment + */ + readonly debtor: OpPaymentDebtor; + + /** + * + */ + readonly instructedAmount: OpPaymentInstructedAmount; + + /** + * Structured creditor reference, either international RF reference (ISO + * 11649) or Finnish reference number (viitenumero). Either reference or + * message should be given for an outgoing payment. + */ + readonly reference ?: string; + + /** + * Free form message from debtor to creditor. Either message or reference + * should be given to an outgoing payment. + * + * [ 1 .. 140 ] characters + */ + readonly message ?: string; + + /** + * Unique identification, as assigned by the original initiating party, to + * unambiguously identify the original transaction. + * + * ^[0-9A-Za-z-åäöÅÄÖ_=:.,+]{1,35}$ + */ + readonly endToEndId ?: string; + + /** + * Ultimate debtor for the payment + */ + readonly ultimateDebtor ?: OpUltimateDebtor; + + /** + * Ultimate creditor for the payment + */ + readonly ultimateCreditor ?: OpUltimateCreditor; + +} + +export function createOpPaymentRequestDTO ( + instructionId: string, + creditor: OpPaymentCreditor, + debtor: OpPaymentDebtor, + instructedAmount: OpPaymentInstructedAmount, + reference ?: string, + message ?: string, + endToEndId ?: string, + ultimateDebtor ?: OpUltimateDebtor, + ultimateCreditor ?: OpUltimateCreditor, +) : OpPaymentRequestDTO { + return { + instructionId, + creditor, + debtor, + instructedAmount, + ...(reference !== undefined ? {reference}: {}), + ...(message !== undefined ? {message}: {}), + ...(endToEndId !== undefined ? {endToEndId}: {}), + ...(ultimateDebtor !== undefined ? {ultimateDebtor}: {}), + ...(ultimateCreditor !== undefined ? {ultimateCreditor}: {}), + }; +} + +export function isOpPaymentRequestDTO (value: unknown) : value is OpPaymentRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'instructionId', + 'creditor', + 'debtor', + 'instructedAmount', + 'reference', + 'message', + 'endToEndId', + 'ultimateDebtor', + 'ultimateCreditor', + ]) + && isString(value?.instructionId) + && isOpPaymentCreditor(value?.creditor) + && isOpPaymentDebtor(value?.debtor) + && isOpPaymentInstructedAmount(value?.instructedAmount) + && isStringOrUndefined(value?.reference) + && isStringOrUndefined(value?.message) + && isStringOrUndefined(value?.endToEndId) + && isOpUltimateDebtorOrUndefined(value?.ultimateDebtor) + && isOpUltimateCreditorOrUndefined(value?.ultimateCreditor) + ); +} + +export function explainOpPaymentRequestDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'instructionId', + 'creditor', + 'debtor', + 'instructedAmount', + 'reference', + 'message', + 'endToEndId', + 'ultimateDebtor', + 'ultimateCreditor', + ]) + , explainProperty("instructionId", explainString(value?.instructionId)) + , explainProperty("creditor", explainOpPaymentCreditor(value?.creditor)) + , explainProperty("debtor", explainOpPaymentDebtor(value?.debtor)) + , explainProperty("instructedAmount", explainOpPaymentInstructedAmount(value?.instructedAmount)) + , explainProperty("reference", explainStringOrUndefined(value?.reference)) + , explainProperty("message", explainStringOrUndefined(value?.message)) + , explainProperty("endToEndId", explainStringOrUndefined(value?.endToEndId)) + , explainProperty("ultimateDebtor", explainOpUltimateDebtorOrUndefined(value?.ultimateDebtor)) + , explainProperty("ultimateCreditor", explainOpUltimateCreditorOrUndefined(value?.ultimateCreditor)) + ] + ); +} + +export function parseOpPaymentRequestDTO (value: unknown) : OpPaymentRequestDTO | undefined { + if (isOpPaymentRequestDTO(value)) return value; + return undefined; +} + +export function isOpPaymentRequestDTOOrUndefined (value: unknown): value is OpPaymentRequestDTO | undefined { + return isUndefined(value) || isOpPaymentRequestDTO(value); +} + +export function explainOpPaymentRequestDTOOrUndefined (value: unknown): string { + return isOpPaymentRequestDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentRequestDTO', 'undefined'])); +} diff --git a/op/dto/OpPaymentResponseDTO.test.ts b/op/dto/OpPaymentResponseDTO.test.ts new file mode 100644 index 0000000..8d686cf --- /dev/null +++ b/op/dto/OpPaymentResponseDTO.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpPaymentResponseDTO, explainOpPaymentResponseDTO, explainOpPaymentResponseDTOOrUndefined, isOpPaymentResponseDTO, isOpPaymentResponseDTOOrUndefined, parseOpPaymentResponseDTO } from "./OpPaymentResponseDTO"; +import { OpPaymentStatus } from "../types/OpPaymentStatus"; +import { OpPaymentType } from "../types/OpPaymentType"; +import { Currency } from "../../types/Currency"; + +describe('OpPaymentResponseDTO', () => { + + describe('createOpPaymentResponseDTO', () => { + + it('creates a valid DTO given valid inputs', () => { + const validDtoOrigin = { + "amount": "3.45", + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + const validDto = createOpPaymentResponseDTO( + "3.45", + OpPaymentStatus.PROCESSED, + Currency.EUR, + "20190524593156999999", + "FI4550009420888888", + "Ultimate Debtor", + "2019-05-12", + OpPaymentType.SCT_INST, + "FI4550009420999999", + "Cedric Creditor", + "Ultimate Creditor", + "A_50009420112088_2019-05-24_20190524593156999999_0", + "2019-05-11", + "544652-end2end", + ); + + expect(isOpPaymentResponseDTO(validDto)).toBe(true); + expect(validDto).toStrictEqual(validDtoOrigin); + }); + + }); + + describe('isOpPaymentResponseDTO', () => { + + it('returns true for valid DTO', () => { + const validDto = { + "amount": "3.45", + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(isOpPaymentResponseDTO(validDto)).toBe(true); + }); + + it('returns false for invalid DTO', () => { + const invalidDto = { + "amount": 3.45, + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(isOpPaymentResponseDTO(invalidDto)).toBe(false); + }); + + }); + + describe('explainOpPaymentResponseDTO', () => { + + it('returns "OK" for valid DTO', () => { + const validDto = { + "amount": "3.45", + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(explainOpPaymentResponseDTO(validDto)).toBe('OK'); + }); + + it('explains the problem for invalid DTO', () => { + const invalidDto = { + "amount": 3.45, + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(explainOpPaymentResponseDTO(invalidDto)).toContain('property "amount" not string'); + }); + + }); + + describe('parseOpPaymentResponseDTO', () => { + + it('returns DTO for valid input', () => { + const validDto = { + "amount": "3.45", + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(parseOpPaymentResponseDTO(validDto)).toEqual(validDto); + }); + + it('returns undefined for invalid input', () => { + const invalidDto = { + "amount": 3.45, + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(parseOpPaymentResponseDTO(invalidDto)).toBeUndefined(); + }); + + }); + + describe('isOpPaymentResponseDTOOrUndefined', () => { + it('returns true for valid DTO or undefined', () => { + const validDto = { + "amount": "3.45", + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(isOpPaymentResponseDTOOrUndefined(validDto)).toBe(true); + expect(isOpPaymentResponseDTOOrUndefined(undefined)).toBe(true); + }); + + it('returns false for invalid DTO', () => { + const invalidDto = { /* Invalid properties here */ }; + + expect(isOpPaymentResponseDTOOrUndefined(invalidDto)).toBe(false); + }); + }); + + describe('explainOpPaymentResponseDTOOrUndefined', () => { + it('returns "Ok" for valid DTO or undefined', () => { + const validDto = { + "amount": "3.45", + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(explainOpPaymentResponseDTOOrUndefined(validDto)).toBe('OK'); + expect(explainOpPaymentResponseDTOOrUndefined(undefined)).toBe('OK'); + }); + + it('explains the problem for invalid DTO', () => { + const invalidDto = { + "amount": 3.45, + "status": "PROCESSED", + "currency": "EUR", + "archiveId": "20190524593156999999", + "debtorIban": "FI4550009420888888", + "ultimateDebtorName": "Ultimate Debtor", + "bookingDate": "2019-05-12", + "paymentType": "SCT_INST", + "creditorIban": "FI4550009420999999", + "creditorName": "Cedric Creditor", + "ultimateCreditorName": "Ultimate Creditor", + "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + "transactionDate": "2019-05-11", + "endToEndId": "544652-end2end" + }; + + expect(explainOpPaymentResponseDTOOrUndefined(invalidDto)).toContain('not OpPaymentResponseDTO or undefined'); + }); + }); + +}); diff --git a/op/dto/OpPaymentResponseDTO.ts b/op/dto/OpPaymentResponseDTO.ts new file mode 100644 index 0000000..1d18a82 --- /dev/null +++ b/op/dto/OpPaymentResponseDTO.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainOpPaymentStatus, isOpPaymentStatus, OpPaymentStatus } from "../types/OpPaymentStatus"; +import { explainOpPaymentType, isOpPaymentType, OpPaymentType } from "../types/OpPaymentType"; +import { Currency } from "../../types/Currency"; + +/** + * @example + * + * { + * "amount": "3.45", + * "status": "PROCESSED", + * "currency": "EUR", + * "archiveId": "20190524593156999999", + * "debtorIban": "FI4550009420888888", + * "ultimateDebtorName": "Ultimate Debtor", + * "bookingDate": "2019-05-12", + * "paymentType": "SCT_INST", + * "creditorIban": "FI4550009420999999", + * "creditorName": "Cedric Creditor", + * "ultimateCreditorName": "Ultimate Creditor", + * "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + * "transactionDate": "2019-05-11", + * "endToEndId": "544652-end2end" + * } + */ +export interface OpPaymentResponseDTO { + readonly amount: string; + readonly status: OpPaymentStatus; + readonly currency: Currency; + readonly archiveId: string; + readonly debtorIban: string; + readonly bookingDate: string; + readonly paymentType: OpPaymentType; + readonly creditorIban: string; + readonly creditorName: string; + readonly ultimateDebtorName ?: string; + readonly ultimateCreditorName ?: string; + readonly transactionId: string; + readonly transactionDate: string; + readonly endToEndId: string; +} + +export function createOpPaymentResponseDTO ( + amount : string, + status : OpPaymentStatus, + currency : Currency, + archiveId : string, + debtorIban : string, + ultimateDebtorName : string | undefined, + bookingDate : string, + paymentType : OpPaymentType, + creditorIban : string, + creditorName : string, + ultimateCreditorName : string | undefined, + transactionId : string, + transactionDate : string, + endToEndId : string, +) : OpPaymentResponseDTO { + return { + amount, + status, + currency, + archiveId, + debtorIban, + bookingDate, + paymentType, + creditorIban, + creditorName, + transactionId, + transactionDate, + endToEndId, + ...(ultimateDebtorName !== undefined ? {ultimateDebtorName} : {}), + ...(ultimateCreditorName !== undefined ? {ultimateCreditorName} : {}), + }; +} + +export function isOpPaymentResponseDTO (value: unknown) : value is OpPaymentResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'amount', + 'status', + 'currency', + 'archiveId', + 'debtorIban', + 'ultimateDebtorName', + 'bookingDate', + 'paymentType', + 'creditorIban', + 'creditorName', + 'ultimateCreditorName', + 'transactionId', + 'transactionDate', + 'endToEndId', + ]) + && isString(value?.amount) + && isOpPaymentStatus(value?.status) + && isString(value?.currency) + && isString(value?.archiveId) + && isString(value?.debtorIban) + && isStringOrUndefined(value?.ultimateDebtorName) + && isString(value?.bookingDate) + && isOpPaymentType(value?.paymentType) + && isString(value?.creditorIban) + && isString(value?.creditorName) + && isStringOrUndefined(value?.ultimateCreditorName) + && isString(value?.transactionId) + && isString(value?.transactionDate) + && isString(value?.endToEndId) + ); +} + +export function explainOpPaymentResponseDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'amount', + 'status', + 'currency', + 'archiveId', + 'debtorIban', + 'ultimateDebtorName', + 'bookingDate', + 'paymentType', + 'creditorIban', + 'creditorName', + 'ultimateCreditorName', + 'transactionId', + 'transactionDate', + 'endToEndId', + ]) + , explainProperty("amount", explainString(value?.amount)) + , explainProperty("status", explainOpPaymentStatus(value?.status)) + , explainProperty("currency", explainString(value?.currency)) + , explainProperty("archiveId", explainString(value?.archiveId)) + , explainProperty("debtorIban", explainString(value?.debtorIban)) + , explainProperty("ultimateDebtorName", explainStringOrUndefined(value?.ultimateDebtorName)) + , explainProperty("bookingDate", explainString(value?.bookingDate)) + , explainProperty("paymentType", explainOpPaymentType(value?.paymentType)) + , explainProperty("creditorIban", explainString(value?.creditorIban)) + , explainProperty("creditorName", explainString(value?.creditorName)) + , explainProperty("ultimateCreditorName", explainStringOrUndefined(value?.ultimateCreditorName)) + , explainProperty("transactionId", explainString(value?.transactionId)) + , explainProperty("transactionDate", explainString(value?.transactionDate)) + , explainProperty("endToEndId", explainString(value?.endToEndId)) + ] + ); +} + +export function parseOpPaymentResponseDTO (value: unknown) : OpPaymentResponseDTO | undefined { + if (isOpPaymentResponseDTO(value)) return value; + return undefined; +} + +export function isOpPaymentResponseDTOOrUndefined (value: unknown): value is OpPaymentResponseDTO | undefined { + return isUndefined(value) || isOpPaymentResponseDTO(value); +} + +export function explainOpPaymentResponseDTOOrUndefined (value: unknown): string { + return isOpPaymentResponseDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentResponseDTO', 'undefined'])); +} diff --git a/op/dto/OpRefundRequestDTO.test.ts b/op/dto/OpRefundRequestDTO.test.ts new file mode 100644 index 0000000..03702c6 --- /dev/null +++ b/op/dto/OpRefundRequestDTO.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EXPLAIN_OK } from "../../types/explain"; +import { createOpRefundRequestDTO, explainOpRefundRequestDTO, explainOpRefundRequestDTOOrUndefined, isOpRefundRequestDTO, isOpRefundRequestDTOOrUndefined, OpRefundRequestDTO, parseOpRefundRequestDTO } from "./OpRefundRequestDTO"; + +describe('OpRefundRequestDTO', () => { + + const validDTO: OpRefundRequestDTO = { + archiveId: "12345", + amount: "100", + message: "refund", + accountIban: "FI4550009420888888", + transactionDate: "2023-01-14", + endToEndId: "12345-end2end" + }; + + describe('createOpRefundRequestDTO', () => { + it('creates a valid DTO', () => { + const result = createOpRefundRequestDTO( + "12345", "100", "refund", "FI4550009420888888", "2023-01-14", "12345-end2end" + ); + expect(result).toEqual(validDTO); + }); + }); + + describe('isOpRefundRequestDTO', () => { + it('returns true for valid DTO', () => { + expect(isOpRefundRequestDTO(validDTO)).toBeTruthy(); + }); + + it('returns false for invalid DTO', () => { + const invalidDTO = { ...validDTO, archiveId: 12345 }; // made archiveId a number + expect(isOpRefundRequestDTO(invalidDTO)).toBeFalsy(); + }); + }); + + describe('explainOpRefundRequestDTO', () => { + it('explains valid DTO as OK', () => { + expect(explainOpRefundRequestDTO(validDTO)).toBe(EXPLAIN_OK); + }); + + it('provides explanations for invalid properties', () => { + const invalidDTO = { ...validDTO, amount: 100 }; // made amount a number + expect(explainOpRefundRequestDTO(invalidDTO)).toContain('property "amount"'); + }); + }); + + describe('parseOpRefundRequestDTO', () => { + it('parses and returns valid DTO', () => { + expect(parseOpRefundRequestDTO(validDTO)).toEqual(validDTO); + }); + + it('returns undefined for invalid DTO', () => { + const invalidDTO = { ...validDTO, archiveId: 12345 }; // made archiveId a number + expect(parseOpRefundRequestDTO(invalidDTO)).toBeUndefined(); + }); + }); + + describe('isOpRefundRequestDTOOrUndefined', () => { + it('returns true for valid DTO', () => { + expect(isOpRefundRequestDTOOrUndefined(validDTO)).toBeTruthy(); + }); + + it('returns true for undefined', () => { + expect(isOpRefundRequestDTOOrUndefined(undefined)).toBeTruthy(); + }); + + it('returns false for invalid DTO', () => { + const invalidDTO = { ...validDTO, archiveId: 12345 }; // made archiveId a number + expect(isOpRefundRequestDTOOrUndefined(invalidDTO)).toBeFalsy(); + }); + }); + + describe('explainOpRefundRequestDTOOrUndefined', () => { + it('explains valid DTO as OK', () => { + expect(explainOpRefundRequestDTOOrUndefined(validDTO)).toBe(EXPLAIN_OK); + }); + + it('explains undefined as OK', () => { + expect(explainOpRefundRequestDTOOrUndefined(undefined)).toBe(EXPLAIN_OK); + }); + + it('provides explanations for invalid properties', () => { + const invalidDTO = { ...validDTO, amount: 100 }; // made amount a number + expect(explainOpRefundRequestDTOOrUndefined(invalidDTO)).toContain('not OpPaymentRefundRequestDTO or undefined'); + }); + }); + +}); diff --git a/op/dto/OpRefundRequestDTO.ts b/op/dto/OpRefundRequestDTO.ts new file mode 100644 index 0000000..b508ef2 --- /dev/null +++ b/op/dto/OpRefundRequestDTO.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainString, explainStringOrNull, isString, isStringOrNull } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; + +/** + * @example + * { + * "archiveId": "20190524593156999999999999999999999", + * "amount": "12.35", + * "message": "Less money, fewer problems", + * "accountIban": "FI4550009420888888", + * "transactionDate": "2023-01-14", + * "endToEndId": "544652-end2end" + * } + */ +export interface OpRefundRequestDTO { + readonly archiveId: string; + readonly amount: string; + readonly message: string; + readonly accountIban: string; + readonly transactionDate: string; + readonly endToEndId: string | null; +} + +export function createOpRefundRequestDTO ( + archiveId : string, + amount : string, + message : string, + accountIban : string, + transactionDate : string, + endToEndId : string | null, +) : OpRefundRequestDTO { + return { + archiveId, + amount, + message, + accountIban, + transactionDate, + endToEndId, + }; +} + +export function isOpRefundRequestDTO (value: unknown) : value is OpRefundRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'archiveId', + 'amount', + 'message', + 'accountIban', + 'transactionDate', + 'endToEndId', + 'foo', + ]) + && isString(value?.archiveId) + && isString(value?.amount) + && isString(value?.message) + && isString(value?.accountIban) + && isString(value?.transactionDate) + && isStringOrNull(value?.endToEndId) + ); +} + +export function explainOpRefundRequestDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'archiveId', + 'amount', + 'message', + 'accountIban', + 'transactionDate', + 'endToEndId', + ]) + , explainProperty("archiveId", explainString(value?.archiveId)) + , explainProperty("amount", explainString(value?.amount)) + , explainProperty("message", explainString(value?.message)) + , explainProperty("accountIban", explainString(value?.accountIban)) + , explainProperty("transactionDate", explainString(value?.transactionDate)) + , explainProperty("endToEndId", explainStringOrNull(value?.endToEndId)) + ] + ); +} + +export function parseOpRefundRequestDTO (value: unknown) : OpRefundRequestDTO | undefined { + if (isOpRefundRequestDTO(value)) return value; + return undefined; +} + +export function isOpRefundRequestDTOOrUndefined (value: unknown): value is OpRefundRequestDTO | undefined { + return isUndefined(value) || isOpRefundRequestDTO(value); +} + +export function explainOpRefundRequestDTOOrUndefined (value: unknown): string { + return isOpRefundRequestDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentRefundRequestDTO', 'undefined'])); +} diff --git a/op/dto/OpRefundResponseDTO.test.ts b/op/dto/OpRefundResponseDTO.test.ts new file mode 100644 index 0000000..9261fe7 --- /dev/null +++ b/op/dto/OpRefundResponseDTO.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpRefundPaymentType } from "../types/OpRefundPaymentType"; +import { OpRefundStatus } from "../types/OpRefundStatus"; +import { createOpRefundResponseDTO, explainOpRefundResponseDTO, explainOpRefundResponseDTOOrUndefined, isOpRefundResponseDTO, isOpRefundResponseDTOOrUndefined, OpRefundResponseDTO, parseOpRefundResponseDTO } from "./OpRefundResponseDTO"; + +describe('OpRefundResponseDTO functions', () => { + const validObject: OpRefundResponseDTO = { + original: { + archiveId: "20190524593156999999999999999999999", + message: "Less money, fewer problems", + reference: "00000000000000482738", + amount: "12.35", + bookingDate: "2019-05-12", + debtorName: "Debbie Debtor" + }, + refund: { + amount: "3.45", + status: OpRefundStatus.PROCESSED, + message: "MAKSUN PALAUTUS. Maksun tiedot: 01.01.2020 Your own refund message", + currency: "EUR", + archiveId: "20190524593156999999", + debtorIban: "FI4550009420888888", + bookingDate: "2019-05-12", + paymentType: OpRefundPaymentType.SCT_INST, + creditorName: "Cedric Creditor", + transactionId: "A_50009420112088_2019-05-24_20190524593156999999_0", + transactionDate: "2019-05-11", + endToEndId: "544652-end2end" + } + }; + + describe('createOpRefundResponseDTO', () => { + it('should create a valid object', () => { + const result = createOpRefundResponseDTO(validObject.original, validObject.refund); + expect(result).toEqual(validObject); + }); + }); + + describe('isOpRefundResponseDTO', () => { + it('should return true for a valid object', () => { + expect(isOpRefundResponseDTO(validObject)).toBe(true); + }); + + it('should return false for an invalid object', () => { + const invalidObject = { ...validObject, original: null }; + expect(isOpRefundResponseDTO(invalidObject)).toBe(false); + }); + }); + + describe('explainOpRefundResponseDTO', () => { + it('should explain a valid object correctly', () => { + expect(explainOpRefundResponseDTO(validObject)).toContain('OK'); // Assuming "OK" is part of a valid explanation. + }); + + it('should provide explanations for invalid properties', () => { + const invalidObject = { ...validObject, original: null }; + expect(explainOpRefundResponseDTO(invalidObject)).toContain('property "original"'); + }); + }); + + describe('parseOpRefundResponseDTO', () => { + it('should parse and return a valid object', () => { + expect(parseOpRefundResponseDTO(validObject)).toEqual(validObject); + }); + + it('should return undefined for an invalid object', () => { + const invalidObject = { ...validObject, refund: null }; + expect(parseOpRefundResponseDTO(invalidObject)).toBeUndefined(); + }); + }); + + describe('isOpRefundResponseDTOOrUndefined', () => { + it('should return true for a valid object', () => { + expect(isOpRefundResponseDTOOrUndefined(validObject)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpRefundResponseDTOOrUndefined(undefined)).toBe(true); + }); + + it('should return false for an invalid object', () => { + const invalidObject = { ...validObject, original: null }; + expect(isOpRefundResponseDTOOrUndefined(invalidObject)).toBe(false); + }); + }); + + describe('explainOpRefundResponseDTOOrUndefined', () => { + it('should explain a valid object correctly', () => { + expect(explainOpRefundResponseDTOOrUndefined(validObject)).toContain('OK'); + }); + + it('should explain undefined correctly', () => { + expect(explainOpRefundResponseDTOOrUndefined(undefined)).toContain('OK'); + }); + + it('should provide explanations for invalid properties', () => { + const invalidObject = { ...validObject, refund: null }; + expect(explainOpRefundResponseDTOOrUndefined(invalidObject)).toContain('OpRefundResponseDTO'); + }); + }); + +}); diff --git a/op/dto/OpRefundResponseDTO.ts b/op/dto/OpRefundResponseDTO.ts new file mode 100644 index 0000000..8e9b818 --- /dev/null +++ b/op/dto/OpRefundResponseDTO.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { isUndefined } from "../../types/undefined"; +import { explainOpRefundOriginalObject, isOpRefundOriginalObject, OpRefundOriginalObject } from "../types/OpRefundOriginalObject"; +import { explainOpRefundRefundObject, isOpRefundRefundObject, OpRefundRefundObject } from "../types/OpRefundRefundObject"; + +/** + * @example + * { + * "original": { + * "archiveId": "20190524593156999999999999999999999", + * "message": "Less money, fewer problems", + * "reference": "00000000000000482738", + * "amount": "12.35", + * "bookingDate": "2019-05-12", + * "debtorName": "Debbie Debtor" + * }, + * "refund": { + * "amount": "3.45", + * "status": "PROCESSED", + * "message": "MAKSUN PALAUTUS. Maksun tiedot: 01.01.2020 Your own refund message", + * "currency": "EUR", + * "archiveId": "20190524593156999999", + * "debtorIban": "FI4550009420888888", + * "bookingDate": "2019-05-12", + * "paymentType": "SCT_INST", + * "creditorName": "Cedric Creditor", + * "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + * "transactionDate": "2019-05-11", + * "endToEndId": "544652-end2end" + * } + * } + */ +export interface OpRefundResponseDTO { + readonly original: OpRefundOriginalObject; + readonly refund : OpRefundRefundObject; +} + +export function createOpRefundResponseDTO ( + original : OpRefundOriginalObject, + refund : OpRefundRefundObject, +) : OpRefundResponseDTO { + return { + original, + refund, + }; +} + +export function isOpRefundResponseDTO (value: unknown) : value is OpRefundResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'original', + 'refund', + ]) + && isOpRefundOriginalObject(value?.original) + && isOpRefundRefundObject(value?.refund) + ); +} + +export function explainOpRefundResponseDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'original', + 'refund', + ]) + , explainProperty("original", explainOpRefundOriginalObject(value?.original)) + , explainProperty("refund", explainOpRefundRefundObject(value?.refund)) + ] + ); +} + +export function parseOpRefundResponseDTO (value: unknown) : OpRefundResponseDTO | undefined { + if (isOpRefundResponseDTO(value)) return value; + return undefined; +} + +export function isOpRefundResponseDTOOrUndefined (value: unknown): value is OpRefundResponseDTO | undefined { + return isUndefined(value) || isOpRefundResponseDTO(value); +} + +export function explainOpRefundResponseDTOOrUndefined (value: unknown): string { + return isOpRefundResponseDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpRefundResponseDTO', 'undefined'])); +} diff --git a/op/dto/OpTransactionDTO.test.ts b/op/dto/OpTransactionDTO.test.ts new file mode 100644 index 0000000..c4b96f2 --- /dev/null +++ b/op/dto/OpTransactionDTO.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpTransactionDTO, + isOpTransactionDTO, + explainOpTransactionDTO, + parseOpTransactionDTO, + isOpTransactionDTOOrUndefined, + explainOpTransactionDTOOrUndefined, + OpTransactionDTO +} from './OpTransactionDTO'; + +describe('OpTransactionDTO', () => { + + const mockTransaction: OpTransactionDTO = createOpTransactionDTO( + '100.00', '200.00', '100.00', 'Test', 'EUR', '1234', '1234', 'OKOYFIHH', + '1234', 'RF1234', '2023-07-21', 'John Doe', '2023-07-21', 'OKOYFIHH', '2023-07-21', 'Jane Doe', + 'FI7450009420999999', 'FI7450009420999999', 'end2end', 1234567890, '123', 'Transaction', '1234-5678-9012-3456' + ); + + const invalidTransaction = {...mockTransaction, amount: 100}; // Invalid because 'amount' is number instead of string. + + describe('isOpTransactionDTO', () => { + it('validates if a value is OpTransactionDTO', () => { + expect(isOpTransactionDTO(mockTransaction)).toBeTruthy(); + expect(isOpTransactionDTO(invalidTransaction)).toBeFalsy(); + }); + }); + + describe('explainOpTransactionDTO', () => { + it('provides explanation if a value is not OpTransactionDTO', () => { + expect(explainOpTransactionDTO(mockTransaction)).toEqual('OK'); + expect(explainOpTransactionDTO(invalidTransaction)).toContain('amount'); + }); + }); + + describe('parseOpTransactionDTO', () => { + it('parses an OpTransactionDTO or returns undefined', () => { + expect(parseOpTransactionDTO(mockTransaction)).toEqual(mockTransaction); + expect(parseOpTransactionDTO(invalidTransaction)).toBeUndefined(); + }); + }); + + describe('isOpTransactionDTOOrUndefined', () => { + it('validates if a value is OpTransactionDTO or undefined', () => { + expect(isOpTransactionDTOOrUndefined(mockTransaction)).toBeTruthy(); + expect(isOpTransactionDTOOrUndefined(invalidTransaction)).toBeFalsy(); + expect(isOpTransactionDTOOrUndefined(undefined)).toBeTruthy(); + }); + }); + + describe('explainOpTransactionDTOOrUndefined', () => { + it('provides explanation if a value is not OpTransactionDTO or undefined', () => { + expect(explainOpTransactionDTOOrUndefined(mockTransaction)).toEqual('OK'); + expect(explainOpTransactionDTOOrUndefined(invalidTransaction)).toContain('not OpTransactionDTO or undefined'); + expect(explainOpTransactionDTOOrUndefined(undefined)).toEqual('OK'); + }); + }); + +}); diff --git a/op/dto/OpTransactionDTO.ts b/op/dto/OpTransactionDTO.ts new file mode 100644 index 0000000..1a30efe --- /dev/null +++ b/op/dto/OpTransactionDTO.ts @@ -0,0 +1,338 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrNull, isString, isStringOrNull } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainNumber, isNumber } from "../../types/Number"; + +/** + * OP bank account transaction information inside a response list. + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/accounts + * + * @example + * { + * "amount": "-12.35", + * "balanceBefore": "100.00", + * "balanceAfter": "87.65", + * "message": "More money, more problems", + * "currency": "EUR", + * "objectId": "50009420112088_2019-05-21_20190521599956999617_9", + * "archiveId": 20190521599957000000, + * "debtorBic": "OKOYFIHH", + * "reference": "00000000000000001245", + * "rfReference": "RF481245", + * "valueDate": "2019-05-01", + * "debtorName": "Debtor", + * "bookingDate": "2019-05-01", + * "creditorBic": "OKOYFIHH", + * "paymentDate": "2019-05-01", + * "creditorName": "Creditor", + * "debtorAccount": "FI7450009420999999", + * "creditorAccount": "FI7450009420999999", + * "endToEndId": "544652-end2end", + * "timestamp": 1556139630605000, + * "transactionTypeCode": 101, + * "transactionTypeDescription": "NOSTO", + * "uetr": "97ed4827-7b6f-4491-a06f-b548d5a7512d" + * } + */ +export interface OpTransactionDTO { + + /** + * Amount transferred in the transaction. Debit transactions are marked with + * a minus sign. + */ + readonly amount: string; + + /** + * Account balance before the transaction. + */ + readonly balanceBefore: string; + + /** + * Account balance after the transaction. + */ + readonly balanceAfter: string; + + /** + * Message that the payer gave for this transaction + */ + readonly message: string | null; + + /** + * ISO currency code + */ + readonly currency: string | null; + + /** + * Transaction identifier, can be used as a basis of further transaction + * queries + */ + readonly objectId: string; + + /** + * Archive identifier + */ + readonly archiveId: string; + + /** + * Debtor account BIC + */ + readonly debtorBic: string; + + /** + * Finnish Creditor Reference for the transaction + */ + readonly reference: string | null; + + /** + * RF Creditor Reference for the transaction + */ + readonly rfReference: string | null; + + /** + * Value date + */ + readonly valueDate: string; + + /** + * Name of the owner of the debtor account + */ + readonly debtorName: string; + + /** + * Booking date + */ + readonly bookingDate: string; + + /** + * Creditor account BIC + */ + readonly creditorBic : string | null; + + /** + * Payment date + */ + readonly paymentDate: string; + + /** + * Name of the owner of the creditor account + */ + readonly creditorName: string; + + /** + * Debtor account IBAN + */ + readonly debtorAccount: string; + + /** + * Creditor account IBAN + */ + readonly creditorAccount: string | null; + + /** + * Unique identification, as assigned by the original initiating party, to + * unambiguously identify the original transaction. + */ + readonly endToEndId: string | null; + + /** + * The timestamp the transaction was registered with internally, given in + * microseconds. + */ + readonly timestamp: number; + + /** + * The transaction type code. + */ + readonly transactionTypeCode: string; + + /** + * The description of the transaction type code. + */ + readonly transactionTypeDescription: string; + + /** + * The unique end-to-end transaction reference. + */ + readonly uetr: string; + +} + +export function createOpTransactionDTO ( + amount: string, + balanceBefore: string, + balanceAfter: string, + message: string | null, + currency: string | null, + objectId: string, + archiveId: string, + debtorBic: string, + reference: string | null, + rfReference: string | null, + valueDate: string, + debtorName: string, + bookingDate: string, + creditorBic: string | null, + paymentDate: string, + creditorName: string, + debtorAccount: string, + creditorAccount: string | null, + endToEndId: string | null, + timestamp: number, + transactionTypeCode: string, + transactionTypeDescription: string, + uetr: string, +) : OpTransactionDTO { + return { + amount, + balanceBefore, + balanceAfter, + message, + currency, + objectId, + archiveId, + debtorBic, + reference, + rfReference, + valueDate, + debtorName, + bookingDate, + creditorBic, + paymentDate, + creditorName, + debtorAccount, + creditorAccount, + endToEndId, + timestamp, + transactionTypeCode, + transactionTypeDescription, + uetr, + }; +} + +export function isOpTransactionDTO (value: unknown) : value is OpTransactionDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'amount', + 'balanceBefore', + 'balanceAfter', + 'message', + 'currency', + 'objectId', + 'archiveId', + 'debtorBic', + 'reference', + 'rfReference', + 'valueDate', + 'debtorName', + 'bookingDate', + 'creditorBic', + 'paymentDate', + 'creditorName', + 'debtorAccount', + 'creditorAccount', + 'endToEndId', + 'timestamp', + 'transactionTypeCode', + 'transactionTypeDescription', + 'uetr', + ]) + && isString(value?.amount) + && isString(value?.balanceBefore) + && isString(value?.balanceAfter) + && isStringOrNull(value?.message) + && isStringOrNull(value?.currency) + && isString(value?.objectId) + && isString(value?.archiveId) + && isString(value?.debtorBic) + && isStringOrNull(value?.reference) + && isStringOrNull(value?.rfReference) + && isString(value?.valueDate) + && isString(value?.debtorName) + && isString(value?.bookingDate) + && isStringOrNull(value?.creditorBic) + && isString(value?.paymentDate) + && isString(value?.creditorName) + && isString(value?.debtorAccount) + && isStringOrNull(value?.creditorAccount) + && isStringOrNull(value?.endToEndId) + && isNumber(value?.timestamp) + && isString(value?.transactionTypeCode) + && isString(value?.transactionTypeDescription) + && isString(value?.uetr) + ); +} + +export function explainOpTransactionDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'amount', + 'balanceBefore', + 'balanceAfter', + 'message', + 'currency', + 'objectId', + 'archiveId', + 'debtorBic', + 'reference', + 'rfReference', + 'valueDate', + 'debtorName', + 'bookingDate', + 'creditorBic', + 'paymentDate', + 'creditorName', + 'debtorAccount', + 'creditorAccount', + 'endToEndId', + 'timestamp', + 'transactionTypeCode', + 'transactionTypeDescription', + 'uetr', + ]) + , explainProperty("amount", explainString(value?.amount)) + , explainProperty("balanceBefore", explainString(value?.balanceBefore)) + , explainProperty("balanceAfter", explainString(value?.balanceAfter)) + , explainProperty("message", explainStringOrNull(value?.message)) + , explainProperty("currency", explainStringOrNull(value?.currency)) + , explainProperty("objectId", explainString(value?.objectId)) + , explainProperty("archiveId", explainString(value?.archiveId)) + , explainProperty("debtorBic", explainString(value?.debtorBic)) + , explainProperty("reference", explainStringOrNull(value?.reference)) + , explainProperty("rfReference", explainStringOrNull(value?.rfReference)) + , explainProperty("valueDate", explainString(value?.valueDate)) + , explainProperty("debtorName", explainString(value?.debtorName)) + , explainProperty("bookingDate", explainString(value?.bookingDate)) + , explainProperty("creditorBic", explainStringOrNull(value?.creditorBic)) + , explainProperty("paymentDate", explainString(value?.paymentDate)) + , explainProperty("creditorName", explainString(value?.creditorName)) + , explainProperty("debtorAccount", explainString(value?.debtorAccount)) + , explainProperty("creditorAccount", explainStringOrNull(value?.creditorAccount)) + , explainProperty("endToEndId", explainStringOrNull(value?.endToEndId)) + , explainProperty("timestamp", explainNumber(value?.timestamp)) + , explainProperty("transactionTypeCode", explainString(value?.transactionTypeCode)) + , explainProperty("transactionTypeDescription", explainString(value?.transactionTypeDescription)) + , explainProperty("uetr", explainString(value?.uetr)) + ] + ); +} + +export function parseOpTransactionDTO (value: unknown) : OpTransactionDTO | undefined { + if (isOpTransactionDTO(value)) return value; + return undefined; +} + +export function isOpTransactionDTOOrUndefined (value: unknown): value is OpTransactionDTO | undefined { + return isUndefined(value) || isOpTransactionDTO(value); +} + +export function explainOpTransactionDTOOrUndefined (value: unknown): string { + return isOpTransactionDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpTransactionDTO', 'undefined'])); +} diff --git a/op/dto/OpTransactionListDTO.test.ts b/op/dto/OpTransactionListDTO.test.ts new file mode 100644 index 0000000..5f71e28 --- /dev/null +++ b/op/dto/OpTransactionListDTO.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpTransactionListDTO, + isOpTransactionListDTO, + explainOpTransactionListDTO, + parseOpTransactionListDTO, + isOpTransactionListDTOOrUndefined, + explainOpTransactionListDTOOrUndefined +} from "./OpTransactionListDTO"; +import { createOpTransactionDTO, OpTransactionDTO } from "./OpTransactionDTO"; + +describe('OpTransactionListDTO', () => { + const mockTransaction: OpTransactionDTO = createOpTransactionDTO( + '100.00', '200.00', '100.00', 'Test', 'EUR', '1234', '1234', 'OKOYFIHH', + '1234', 'RF1234', '2023-07-21', 'John Doe', '2023-07-21', 'OKOYFIHH', '2023-07-21', 'Jane Doe', + 'FI7450009420999999', 'FI7450009420999999', 'end2end', 1234567890, '101', 'Transaction', '1234-5678-9012-3456' + ); + const invalidTransaction = {...mockTransaction, amount: 100}; // Invalid because 'amount' is number instead of string. + const mockTransactionList = createOpTransactionListDTO([mockTransaction, mockTransaction]); + const invalidTransactionList = [...mockTransactionList, invalidTransaction]; + + describe('isOpTransactionListDTO', () => { + it('validates if a value is OpTransactionListDTO', () => { + expect(isOpTransactionListDTO(mockTransactionList)).toBeTruthy(); + expect(isOpTransactionListDTO(invalidTransactionList)).toBeFalsy(); + }); + }); + + describe('explainOpTransactionListDTO', () => { + it('provides explanation if a value is not OpTransactionListDTO', () => { + expect(explainOpTransactionListDTO(mockTransactionList)).toEqual('OK'); + expect(explainOpTransactionListDTO(invalidTransactionList)).toContain('OpTransactionDTO'); + }); + }); + + describe('parseOpTransactionListDTO', () => { + it('parses an OpTransactionListDTO or returns undefined', () => { + expect(parseOpTransactionListDTO(mockTransactionList)).toEqual(mockTransactionList); + expect(parseOpTransactionListDTO(invalidTransactionList)).toBeUndefined(); + }); + }); + + describe('isOpTransactionListDTOOrUndefined', () => { + it('validates if a value is OpTransactionListDTO or undefined', () => { + expect(isOpTransactionListDTOOrUndefined(mockTransactionList)).toBeTruthy(); + expect(isOpTransactionListDTOOrUndefined(invalidTransactionList)).toBeFalsy(); + expect(isOpTransactionListDTOOrUndefined(undefined)).toBeTruthy(); + }); + }); + + describe('explainOpTransactionListDTOOrUndefined', () => { + it('provides explanation if a value is not OpTransactionListDTO or undefined', () => { + expect(explainOpTransactionListDTOOrUndefined(mockTransactionList)).toEqual('OK'); + expect(explainOpTransactionListDTOOrUndefined(invalidTransactionList)).toContain('not OpTransactionListDTO or undefined'); + expect(explainOpTransactionListDTOOrUndefined(undefined)).toEqual('OK'); + }); + }); + +}); diff --git a/op/dto/OpTransactionListDTO.ts b/op/dto/OpTransactionListDTO.ts new file mode 100644 index 0000000..7731387 --- /dev/null +++ b/op/dto/OpTransactionListDTO.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpTransactionDTO, isOpTransactionDTO, OpTransactionDTO } from "./OpTransactionDTO"; +import { map } from "../../functions/map"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +/** + * OP Bank account transaction list response DTO. + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/accounts + * + * @example + * [ + * { + * "amount": "-12.35", + * "balanceBefore": "100.00", + * "balanceAfter": "87.65", + * "message": "More money, more problems", + * "currency": "EUR", + * "objectId": "50009420112088_2019-05-21_20190521599956999617_9", + * "archiveId": 20190521599957000000, + * "debtorBic": "OKOYFIHH", + * "reference": "00000000000000001245", + * "rfReference": "RF481245", + * "valueDate": "2019-05-01", + * "debtorName": "Debtor", + * "bookingDate": "2019-05-01", + * "creditorBic": "OKOYFIHH", + * "paymentDate": "2019-05-01", + * "creditorName": "Creditor", + * "debtorAccount": "FI7450009420999999", + * "creditorAccount": "FI7450009420999999", + * "endToEndId": "544652-end2end", + * "timestamp": 1556139630605000, + * "transactionTypeCode": 101, + * "transactionTypeDescription": "NOSTO", + * "uetr": "97ed4827-7b6f-4491-a06f-b548d5a7512d" + * } + * ] + */ +export type OpTransactionListDTO = readonly OpTransactionDTO[]; + +export function createOpTransactionListDTO ( + list : readonly OpTransactionDTO[] +) : OpTransactionListDTO { + return map( + list, + (item : OpTransactionDTO ) : OpTransactionDTO => item + ); +} + +export function isOpTransactionListDTO (value: unknown) : value is OpTransactionListDTO { + return isArrayOf(value, isOpTransactionDTO); +} + +export function explainOpTransactionListDTO (value: any) : string { + return explainArrayOf("OpTransactionDTO", explainOpTransactionDTO, value, isOpTransactionDTO); +} + +export function parseOpTransactionListDTO (value: unknown) : OpTransactionListDTO | undefined { + if (isOpTransactionListDTO(value)) return value; + return undefined; +} + +export function isOpTransactionListDTOOrUndefined (value: unknown): value is OpTransactionListDTO | undefined { + return isUndefined(value) || isOpTransactionListDTO(value); +} + +export function explainOpTransactionListDTOOrUndefined (value: unknown): string { + return isOpTransactionListDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpTransactionListDTO', 'undefined'])); +} diff --git a/op/mocks/MockOpAuthClient.ts b/op/mocks/MockOpAuthClient.ts new file mode 100644 index 0000000..4fab7bc --- /dev/null +++ b/op/mocks/MockOpAuthClient.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpAuthClient } from "../OpAuthClient"; + +export class MockOpAuthClient implements OpAuthClient { + + public async authenticate (): Promise { + } + + public getAccessKey (): string { + return ""; + } + + public isAuthenticated (): boolean { + return false; + } + +} + diff --git a/op/op-constants.ts b/op/op-constants.ts new file mode 100644 index 0000000..6ef8202 --- /dev/null +++ b/op/op-constants.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export const OP_PRODUCTION_URL = 'https://corporate-api.apiauth.services.op.fi'; +export const OP_SANDBOX_URL = 'https://sandbox-api.apiauth.aws.op-palvelut.net'; + +export const OP_CREATE_SEPA_PAYMENT_PATH = "/corporate-payment/v1/sepa-payment"; +export const OP_CREATE_SEPA_INSTANT_PAYMENT_PATH = "/corporate-payment/v1/sepa-instant-payment"; +export const OP_SEPA_INSTANT_PAYMENT_STATUS_PATH = (instructionId: string) => `/corporate-payment/v1/sepa-instant-payment/${q(instructionId)}` + +export const OP_CREATE_SEPA_REFUND_PATH = "/corporate-payment/v2/payment-refund"; + +export const OP_ACCOUNT_DATA_GET_ACCOUNT_LIST_PATH = `/corporate-account-data/v1/accounts` +export const OP_ACCOUNT_DATA_GET_ACCOUNT_DETAILS_PATH = (surrogateId: string) => `/corporate-account-data/v1/accounts/${q(surrogateId)}` +export const OP_ACCOUNT_DATA_GET_TRANSACTION_LIST_PATH = (surrogateId: string) => `/corporate-account-data/v2/accounts/${q(surrogateId)}/transactions` + +function q (value: string) : string { + return encodeURIComponent(value); +} diff --git a/op/repository/account/OpAccountEntity.ts b/op/repository/account/OpAccountEntity.ts new file mode 100644 index 0000000..af4d6d1 --- /dev/null +++ b/op/repository/account/OpAccountEntity.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../../LogService"; +import { Table } from "../../../data/Table"; +import { Entity } from "../../../data/Entity"; +import type { OpAccountDTO } from "../../dto/OpAccountDTO"; +import { createOpAccountDTO, explainOpAccountDTO, isOpAccountDTO } from "../../dto/OpAccountDTO"; +import { Id } from "../../../data/Id"; +import { Column } from "../../../data/Column"; +import { ReadonlyJsonObject } from "../../../Json"; +import { Temporal } from "../../../data/Temporal"; +import { TemporalType } from "../../../data/types/TemporalType"; +import { UpdateTimestamp } from "../../../data/UpdateTimestamp"; +import { CreationTimestamp } from "../../../data/CreationTimestamp"; +import { createOpAccountDetailsDTO, explainOpAccountDetailsDTO, isOpAccountDetailsDTO, OpAccountDetailsDTO } from "../../dto/OpAccountDetailsDTO"; + +const LOG = LogService.createLogger('OpAccountEntity'); + +@Table("op_account") +export class OpAccountEntity extends Entity { + + // The constructor + + public constructor (); + public constructor (dto : OpAccountDTO & {details ?: OpAccountDetailsDTO}); + + public constructor (dto ?: OpAccountDTO & {details ?: OpAccountDetailsDTO}) { + + super(); + + this.bic = dto?.bic ?? ''; + this.iban = dto?.iban ?? ''; + this.name = dto?.name ?? ''; + this.balance = dto?.balance ?? ''; + this.currency = dto?.currency ?? ''; + this.surrogateId = dto?.surrogateId ?? ''; + this.productNames = dto?.productNames ?? null; + this.accountTypeCode = dto?.accountTypeCode ?? ''; + + this.dBic = dto?.details?.bic; + this.dIban = dto?.details?.iban; + this.dDueDate = dto?.details?.dueDate ?? null; + this.dOwnerId = dto?.details?.ownerId; + this.dCurrency = dto?.details?.currency; + this.dNetBalance = dto?.details?.netBalance; + this.dAccountName = dto?.details?.accountName ?? null; + this.dCreditLimit = dto?.details?.creditLimit; + this.dAccountOwner = dto?.details?.accountOwner; + this.dCreationDate = dto?.details?.creationDate; + this.dGrossBalance = dto?.details?.grossBalance; + this.dIntraDayLimit = dto?.details?.intraDayLimit ?? null; + + } + + @Id() + @Column("op_account_id", 'BIGINT', { updatable : false, insertable: false }) + public opAccountId?: string; + + @UpdateTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column("updated", 'DATETIME', { updatable : false, insertable: false }) + public updated?: string; + + @CreationTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column("created", 'DATETIME', { updatable : false, insertable: false }) + public created?: string; + + @Column("bic") + public bic ?: string; + + @Column("iban") + public iban ?: string; + + @Column("name") + public name ?: string; + + /** + * Please note: This balance includes possible reserved funds (e.g. card + * transactions, etc.) which may not be included in the transaction list yet. + */ + @Column("balance") + public balance ?: string; + + @Column("currency") + public currency ?: string; + + @Column("surrogate_id") + public surrogateId ?: string; + + @Column("product_names", 'JSON') + public productNames ?: ReadonlyJsonObject | null; + + @Column("account_type_code") + public accountTypeCode ?: string; + + // Fields for the extra details query + + @Column("d_bic") + public dBic ?: string; + + @Column("d_iban") + public dIban ?: string; + + @Column("d_due_date") + public dDueDate ?: string | null; + + @Column("d_owner_id") + public dOwnerId ?: string; + + @Column("d_currency") + public dCurrency ?: string; + + /** + * Please note: This balance includes possible reserved funds (e.g. card + * transactions, etc.) which may not be included in the transaction list yet. + */ + @Column("d_net_balance") + public dNetBalance ?: string; + + @Column("d_account_name") + public dAccountName ?: string | null; + + @Column("d_credit_limit") + public dCreditLimit ?: number; + + @Column("d_account_owner") + public dAccountOwner ?: string; + + @Column("d_creation_date") + public dCreationDate ?: string; + + /** + * Please note: This balance DOES NOT include possible reserved funds (e.g. + * card transactions, etc.). + */ + @Column("d_gross_balance") + public dGrossBalance ?: string; + + @Column("d_intra_day_limit") + public dIntraDayLimit ?: string | null; + + + public static toDTO (entity: OpAccountEntity) : OpAccountDTO { + const dto : OpAccountDTO = createOpAccountDTO( + entity?.bic ?? '', + entity?.iban ?? '', + entity?.name ?? '', + entity?.balance ?? '', + entity?.currency ?? '', + entity?.surrogateId ?? '', + entity?.productNames ?? {}, + entity?.accountTypeCode ?? '', + ); + // Redundant fail safe + if (!isOpAccountDTO(dto)) { + LOG.debug(`toDTO: dto / entity = `, dto, entity); + throw new TypeError(`Failed to create valid OpAccountDTO: ${explainOpAccountDTO(dto)}`); + } + return dto; + } + + public static toDetailsDTO (entity: OpAccountEntity) : OpAccountDetailsDTO { + const dto : OpAccountDetailsDTO = createOpAccountDetailsDTO( + entity?.dBic ?? '', + entity?.dIban ?? '', + entity?.dDueDate ?? null, + entity?.dOwnerId ?? '', + entity?.dCurrency ?? '', + entity?.dNetBalance ?? '', + entity?.dAccountName ?? null, + entity?.dCreditLimit ?? 0, + entity?.surrogateId ?? '', + entity?.dAccountOwner ?? '', + entity?.dCreationDate ?? '', + entity?.dGrossBalance ?? '', + entity?.dIntraDayLimit ?? null, + ); + // Redundant fail safe + if (!isOpAccountDetailsDTO(dto)) { + LOG.debug(`toDTO: dto / entity = `, dto, entity); + throw new TypeError(`Failed to create valid OpAccountDetailsDTO: ${explainOpAccountDetailsDTO(dto)}`); + } + return dto; + } + +} diff --git a/op/repository/account/OpAccountRepository.ts b/op/repository/account/OpAccountRepository.ts new file mode 100644 index 0000000..a94381c --- /dev/null +++ b/op/repository/account/OpAccountRepository.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpAccountEntity } from "./OpAccountEntity"; +import { Repository } from "../../../data/types/Repository"; + +export interface OpAccountRepository extends Repository { + findBySurrogateId (surrogateId: string) : Promise; + findAllBySurrogateId (surrogateId: string) : Promise; + findAllByAccountTypeCode (accountTypeCode: string) : Promise; + findAllByBic (bic: string) : Promise; + findAllByIban (iban: string) : Promise; + findAllByName (name: string) : Promise; +} diff --git a/op/repository/payment/OpPaymentEntity.ts b/op/repository/payment/OpPaymentEntity.ts new file mode 100644 index 0000000..a7a79d5 --- /dev/null +++ b/op/repository/payment/OpPaymentEntity.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../../LogService"; +import { Table } from "../../../data/Table"; +import { Entity } from "../../../data/Entity"; +import { createOpPaymentDTO, explainOpPaymentDTO, isOpPaymentDTO } from "../../dto/OpPaymentDTO"; +import type { OpPaymentDTO } from "../../dto/OpPaymentDTO"; +import { Id } from "../../../data/Id"; +import { Column } from "../../../data/Column"; +import { OpPaymentType } from "../../types/OpPaymentType"; +import { OpPaymentStatus } from "../../types/OpPaymentStatus"; +import { Currency } from "../../../types/Currency"; +import { OpIdentificationScheme } from "../../types/OpIdentificationScheme"; +import { createOpPaymentRequestDTO } from "../../dto/OpPaymentRequestDTO"; +import { createOpPaymentResponseDTO } from "../../dto/OpPaymentResponseDTO"; +import { createOpPaymentCreditor } from "../../types/OpPaymentCreditor"; +import { createOpAddress } from "../../types/OpPaymentAddress"; +import { createOpPaymentDebtor } from "../../types/OpPaymentDebtor"; +import { createOpPaymentInstructedAmount } from "../../types/OpPaymentInstructedAmount"; +import { createOpUltimateDebtor } from "../../types/OpUltimateDebtor"; +import { createOpPaymentIdentification } from "../../types/OpPaymentIdentification"; +import { createOpUltimateCreditor } from "../../types/OpUltimateCreditor"; +import { CountryCode } from "../../../types/CountryCode"; +import { CreationTimestamp } from "../../../data/CreationTimestamp"; +import { Temporal } from "../../../data/Temporal"; +import { TemporalType } from "../../../data/types/TemporalType"; +import { UpdateTimestamp } from "../../../data/UpdateTimestamp"; + +const LOG = LogService.createLogger('OpPaymentEntity'); + +@Table("op_payment") +export class OpPaymentEntity extends Entity { + + // The constructor + + public constructor (); + public constructor (dto : OpPaymentDTO); + + public constructor (dto ?: OpPaymentDTO) { + super(); + + this.rInstructionId = dto?.request?.instructionId; + + this.rCreditorName = dto?.request?.creditor?.name; + this.rCreditorIban = dto?.request?.creditor?.iban; + this.rCreditorAddressCountry = dto?.request?.creditor?.address?.country; + this.rCreditorAddressLine1 = dto?.request?.creditor?.address?.addressLine[0]; + this.rCreditorAddressLine2 = dto?.request?.creditor?.address?.addressLine[1]; + + this.rDebtorName = dto?.request?.debtor?.name; + this.rDebtorIban = dto?.request?.debtor?.iban; + this.rDebtorAddressCountry = dto?.request?.debtor?.address?.country; + this.rDebtorAddressLine1 = dto?.request?.debtor?.address?.addressLine[0]; + this.rDebtorAddressLine2 = dto?.request?.debtor?.address?.addressLine[1]; + + this.rInstructedAmountValue = dto?.request?.instructedAmount?.amount; + this.rInstructedAmountCurrency = dto?.request?.instructedAmount?.currency; + this.rReference = dto?.request?.reference; + this.rMessage = dto?.request?.message; + this.rEndToEndId = dto?.request?.endToEndId; + + this.rUltimateDebtorName = dto?.request?.ultimateDebtor?.name; + this.rUltimateDebtorIdentificationId = dto?.request?.ultimateDebtor?.identification?.id; + this.rUltimateDebtorIdentificationSchemeName = dto?.request?.ultimateDebtor?.identification?.schemeName; + this.rUltimateDebtorIdentificationIssuer = dto?.request?.ultimateDebtor?.identification?.issuer; + this.rUltimateDebtorAddressCountry = dto?.request?.ultimateDebtor?.address?.country; + this.rUltimateDebtorAddressLine1 = dto?.request?.ultimateDebtor?.address?.addressLine[0]; + this.rUltimateDebtorAddressLine2 = dto?.request?.ultimateDebtor?.address?.addressLine[1]; + + this.rUltimateCreditorName = dto?.request?.ultimateCreditor?.name; + this.rUltimateCreditorIdentificationId = dto?.request?.ultimateCreditor?.identification?.id; + this.rUltimateCreditorIdentificationSchemeName = dto?.request?.ultimateCreditor?.identification?.schemeName; + this.rUltimateCreditorIdentificationIssuer = dto?.request?.ultimateCreditor?.identification?.issuer; + this.rUltimateCreditorAddressCountry = dto?.request?.ultimateCreditor?.address?.country; + this.rUltimateCreditorAddressLine1 = dto?.request?.ultimateCreditor?.address?.addressLine[0]; + this.rUltimateCreditorAddressLine2 = dto?.request?.ultimateCreditor?.address?.addressLine[1]; + + this.amount = dto?.response?.amount; + this.status = dto?.response?.status; + this.currency = dto?.response?.currency; + this.archiveId = dto?.response?.archiveId; + this.debtorIban = dto?.response?.debtorIban; + this.bookingDate = dto?.response?.bookingDate; + this.paymentType = dto?.response?.paymentType; + this.creditorIban = dto?.response?.creditorIban; + this.creditorName = dto?.response?.creditorName; + this.ultimateDebtorName = dto?.response?.ultimateDebtorName; + this.ultimateCreditorName = dto?.response?.ultimateCreditorName; + this.transactionId = dto?.response?.transactionId; + this.transactionDate = dto?.response?.transactionDate; + this.endToEndId = dto?.response?.endToEndId; + + } + + @Id() + @Column("op_payment_id", 'BIGINT', { updatable : false, insertable: false }) + public opPaymentId?: string; + + @Column("op_account_id", 'BIGINT') + public opAccountId?: string; + + @UpdateTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column("updated", 'DATETIME', { updatable : false, insertable: false }) + public updated?: string; + + @CreationTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column("created", 'DATETIME', { updatable : false, insertable: false }) + public created?: string; + + @Column("r_instruction_id") + public rInstructionId ?: string; + + @Column("r_creditor_name") + public rCreditorName ?: string; + + @Column("r_creditor_iban") + public rCreditorIban ?: string; + + @Column("r_creditor_address_country") + public rCreditorAddressCountry ?: CountryCode; + + @Column("r_creditor_address_line1") + public rCreditorAddressLine1 ?: string; + + @Column("r_creditor_address_line2") + public rCreditorAddressLine2 ?: string; + + @Column("r_debtor_name") + public rDebtorName ?: string; + + @Column("r_debtor_iban") + public rDebtorIban ?: string; + + @Column("r_debtor_address_country") + public rDebtorAddressCountry ?: CountryCode; + + @Column("r_debtor_address_line1") + public rDebtorAddressLine1 ?: string; + + @Column("r_debtor_address_line2") + public rDebtorAddressLine2 ?: string; + + @Column("r_instructed_amount_value") + public rInstructedAmountValue ?: string; + + @Column("r_instructed_amount_currency") + public rInstructedAmountCurrency ?: Currency; + + @Column("r_reference") + public rReference ?: string; + + @Column("r_message") + public rMessage ?: string; + + @Column("r_end_to_end_id") + public rEndToEndId ?: string; + + @Column("r_ultimate_debtor_name") + public rUltimateDebtorName ?: string; + + @Column("r_ultimate_debtor_identification_id") + public rUltimateDebtorIdentificationId ?: string; + + @Column("r_ultimate_debtor_identification_scheme_name") + public rUltimateDebtorIdentificationSchemeName ?: OpIdentificationScheme; + + @Column("r_ultimate_debtor_identification_issuer") + public rUltimateDebtorIdentificationIssuer ?: string; + + @Column("r_ultimate_debtor_address_country") + public rUltimateDebtorAddressCountry ?: CountryCode; + + @Column("r_ultimate_debtor_address_line1") + public rUltimateDebtorAddressLine1 ?: string; + + @Column("r_ultimate_debtor_address_line2") + public rUltimateDebtorAddressLine2 ?: string; + + @Column("r_ultimate_creditor_name") + public rUltimateCreditorName ?: string; + + @Column("r_ultimate_creditor_identification_id") + public rUltimateCreditorIdentificationId ?: string; + + @Column("r_ultimate_creditor_identification_scheme_name") + public rUltimateCreditorIdentificationSchemeName ?: OpIdentificationScheme; + + @Column("r_ultimate_creditor_identification_issuer") + public rUltimateCreditorIdentificationIssuer ?: string; + + @Column("r_ultimate_creditor_address_country") + public rUltimateCreditorAddressCountry ?: CountryCode; + + @Column("r_ultimate_creditor_address_line1") + public rUltimateCreditorAddressLine1 ?: string; + + @Column("r_ultimate_creditor_address_line2") + public rUltimateCreditorAddressLine2 ?: string; + + + @Column("amount") + public amount ?: string; + + @Column("status") + public status ?: OpPaymentStatus; + + @Column("currency") + public currency ?: Currency; + + @Column("archive_id") + public archiveId ?: string; + + @Column("debtor_iban") + public debtorIban ?: string; + + @Column("booking_date") + public bookingDate ?: string; + + @Column("payment_type") + public paymentType ?: OpPaymentType; + + @Column("creditor_iban") + public creditorIban ?: string; + + @Column("creditor_name") + public creditorName ?: string; + + @Column("ultimate_debtor_name") + public ultimateDebtorName ?: string; + + @Column("ultimate_creditor_name") + public ultimateCreditorName ?: string; + + @Column("transaction_id") + public transactionId ?: string; + + @Column("transaction_date") + public transactionDate ?: string; + + @Column("end_to_end_id") + public endToEndId ?: string; + + public static toDTO (entity: OpPaymentEntity) : OpPaymentDTO { + + if (!entity?.rInstructionId) throw new TypeError('entity.rInstructionId missing'); + if (!entity?.rCreditorName) throw new TypeError('entity.rCreditorName missing'); + if (!entity?.rCreditorAddressCountry) throw new TypeError('entity.rCreditorAddressCountry missing'); + if (!entity?.rCreditorAddressLine1) throw new TypeError('entity.rCreditorAddressLine1 missing'); + if (!entity?.rCreditorAddressLine2) throw new TypeError('entity.rCreditorAddressLine2 missing'); + if (!entity?.rDebtorName) throw new TypeError('entity.rDebtorName missing'); + if (!entity?.rDebtorAddressCountry) throw new TypeError('entity.rDebtorAddressCountry missing'); + if (!entity?.rDebtorAddressLine1) throw new TypeError('entity.rDebtorAddressLine1 missing'); + if (!entity?.rDebtorAddressLine2) throw new TypeError('entity.rDebtorAddressLine2 missing'); + if (!entity?.rInstructedAmountValue) throw new TypeError('entity.rInstructedAmountValue missing'); + if (!entity?.rInstructedAmountCurrency) throw new TypeError('entity.rInstructedAmountCurrency missing'); + + const dto : OpPaymentDTO = createOpPaymentDTO( + createOpPaymentRequestDTO( + entity?.rInstructionId, + createOpPaymentCreditor( + entity?.rCreditorName, + entity?.rCreditorIban, + createOpAddress( + entity?.rCreditorAddressCountry, + [ + entity?.rCreditorAddressLine1, + entity?.rCreditorAddressLine2, + ] + ), + ), + createOpPaymentDebtor( + entity?.rDebtorName, + entity?.rDebtorIban, + createOpAddress( + entity?.rDebtorAddressCountry, + [ + entity?.rDebtorAddressLine1, + entity?.rDebtorAddressLine2, + ] + ), + ), + createOpPaymentInstructedAmount( + entity?.rInstructedAmountValue, + entity?.rInstructedAmountCurrency, + ), + entity?.rReference, + entity?.rMessage, + entity?.rEndToEndId, + ( + entity?.rUltimateDebtorName + && entity?.rUltimateDebtorIdentificationId + && entity?.rUltimateDebtorIdentificationSchemeName + && entity?.rUltimateDebtorAddressCountry + && entity?.rUltimateDebtorAddressLine1 + && entity?.rUltimateDebtorAddressLine2 + ) ? createOpUltimateDebtor( + entity?.rUltimateDebtorName, + createOpPaymentIdentification( + entity?.rUltimateDebtorIdentificationId, + entity?.rUltimateDebtorIdentificationSchemeName, + entity?.rUltimateDebtorIdentificationIssuer, + ), + createOpAddress( + entity?.rUltimateDebtorAddressCountry, + [ + entity?.rUltimateDebtorAddressLine1, + entity?.rUltimateDebtorAddressLine2, + ] + ), + ) : undefined, + ( + entity?.rUltimateCreditorName + && entity?.rUltimateCreditorIdentificationId + && entity?.rUltimateCreditorIdentificationSchemeName + && entity?.rUltimateCreditorAddressCountry + && entity?.rUltimateCreditorAddressLine1 + && entity?.rUltimateCreditorAddressLine2 + ) ? createOpUltimateCreditor( + entity?.rUltimateCreditorName, + createOpPaymentIdentification( + entity?.rUltimateCreditorIdentificationId, + entity?.rUltimateCreditorIdentificationSchemeName, + entity?.rUltimateCreditorIdentificationIssuer, + ), + createOpAddress( + entity?.rUltimateCreditorAddressCountry, + [ + entity?.rUltimateCreditorAddressLine1, + entity?.rUltimateCreditorAddressLine2, + ] + ), + ) : undefined, + ), + ( + entity?.amount + && entity?.status + && entity?.currency + && entity?.archiveId + && entity?.debtorIban + && entity?.bookingDate + && entity?.paymentType + && entity?.creditorIban + && entity?.creditorName + && entity?.transactionId + && entity?.transactionDate + && entity?.endToEndId + ) ? createOpPaymentResponseDTO( + entity?.amount, + entity?.status, + entity?.currency, + entity?.archiveId, + entity?.debtorIban, + entity?.ultimateDebtorName, + entity?.bookingDate, + entity?.paymentType, + entity?.creditorIban, + entity?.creditorName, + entity?.ultimateCreditorName, + entity?.transactionId, + entity?.transactionDate, + entity?.endToEndId, + ) : undefined + ); + // Redundant fail safe + if (!isOpPaymentDTO(dto)) { + LOG.debug(`toDTO: dto / entity = `, dto, entity); + throw new TypeError(`Failed to create valid OpPaymentDTO: ${explainOpPaymentDTO(dto)}`); + } + return dto; + } + +} diff --git a/op/repository/payment/OpPaymentRepository.ts b/op/repository/payment/OpPaymentRepository.ts new file mode 100644 index 0000000..83994f8 --- /dev/null +++ b/op/repository/payment/OpPaymentRepository.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpPaymentEntity } from "./OpPaymentEntity"; +import { Repository } from "../../../data/types/Repository"; +import { OpPaymentStatus } from "../../types/OpPaymentStatus"; + +export interface OpPaymentRepository extends Repository { + findAllByOpAccountId (opAccountId: string) : Promise; + findAllByRInstructionId (reference: string) : Promise; + findAllByRReference (reference: string) : Promise; + findAllByReference (reference: string) : Promise; + findAllByREndToEndId (endToEndId: string) : Promise; + findAllByEndToEndId (endToEndId: string) : Promise; + findAllByStatus (status: OpPaymentStatus) : Promise; + findAllByArchiveId (archiveId: string) : Promise; + findAllByArchiveId (archiveId: string) : Promise; + findAllByDebtorIban (debtorIban: string) : Promise; + findAllByTransactionId (transactionId: string) : Promise; +} diff --git a/op/repository/transaction/OpTransactionEntity.ts b/op/repository/transaction/OpTransactionEntity.ts new file mode 100644 index 0000000..03ef935 --- /dev/null +++ b/op/repository/transaction/OpTransactionEntity.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogService } from "../../../LogService"; +import { Table } from "../../../data/Table"; +import { Entity } from "../../../data/Entity"; +import { parseInteger } from "../../../types/Number"; +import { createOpTransactionDTO, explainOpTransactionDTO, isOpTransactionDTO, OpTransactionDTO } from "../../dto/OpTransactionDTO"; +import { Id } from "../../../data/Id"; +import { Column } from "../../../data/Column"; +import { UpdateTimestamp } from "../../../data/UpdateTimestamp"; +import { CreationTimestamp } from "../../../data/CreationTimestamp"; +import { Temporal } from "../../../data/Temporal"; +import { TemporalType } from "../../../data/types/TemporalType"; + +const LOG = LogService.createLogger('OpTransactionEntity'); + +@Table("op_transaction") +export class OpTransactionEntity extends Entity { + + // The constructor + public constructor (); + public constructor (dto : OpTransactionDTO & {opAccountId: string, opSurrogateId: string}); + + public constructor (dto ?: OpTransactionDTO & {opAccountId: string, opSurrogateId: string}) { + super(); + this.opAccountId = dto?.opAccountId; + this.opSurrogateId = dto?.opSurrogateId; + this.amount = dto?.amount; + this.balanceBefore = dto?.balanceBefore; + this.balanceAfter = dto?.balanceAfter; + this.message = dto?.message; + this.currency = dto?.currency; + this.objectId = dto?.objectId; + this.archiveId = dto?.archiveId; + this.debtorBic = dto?.debtorBic; + this.reference = dto?.reference; + this.rfReference = dto?.rfReference; + this.valueDate = dto?.valueDate; + this.debtorName = dto?.debtorName; + this.bookingDate = dto?.bookingDate; + this.creditorBic = dto?.creditorBic; + this.paymentDate = dto?.paymentDate; + this.creditorName = dto?.creditorName; + this.debtorAccount = dto?.debtorAccount; + this.creditorAccount = dto?.creditorAccount; + this.endToEndId = dto?.endToEndId; + this.timestamp = `${dto?.timestamp}`; + this.transactionTypeCode = dto?.transactionTypeCode; + this.transactionTypeDescription = dto?.transactionTypeDescription; + this.uetr = dto?.uetr; + } + + @Id() + @Column("op_transaction_id", 'BIGINT', { updatable : false, insertable: false }) + public opTransactionId?: string; + + @Column("op_account_id", 'BIGINT') + public opAccountId?: string; + + @Column("op_surrogate_id") + public opSurrogateId?: string; + + @UpdateTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column("updated", 'DATETIME', { updatable : false, insertable: false }) + public updated?: string; + + @CreationTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column("created", 'DATETIME', { updatable : false, insertable: false }) + public created?: string; + + @Column("amount") + public amount ?: string; + + @Column("balance_before") + public balanceBefore ?: string; + + @Column("balance_after") + public balanceAfter ?: string; + + @Column("message") + public message ?: string | null; + + @Column("currency") + public currency ?: string | null; + + @Column("object_id") + public objectId ?: string; + + @Column("archive_id") + public archiveId ?: string; + + @Column("debtor_bic") + public debtorBic ?: string; + + @Column("reference") + public reference ?: string | null; + + @Column("rf_reference") + public rfReference ?: string | null; + + @Column("value_date", 'DATETIME') + public valueDate ?: string; + + @Column("debtor_name") + public debtorName ?: string; + + @Column("booking_date", 'DATETIME') + public bookingDate ?: string; + + @Column("creditor_bic") + public creditorBic ? : string | null; + + @Column("payment_date", 'DATETIME') + public paymentDate ?: string; + + @Column("creditor_name") + public creditorName ?: string; + + @Column("debtor_account") + public debtorAccount ?: string; + + @Column("creditor_account") + public creditorAccount ?: string | null; + + @Column("end_to_end_id") + public endToEndId ?: string | null; + + @Column("timestamp", 'BIGINT') + public timestamp ?: string; + + @Column("transaction_type_code") + public transactionTypeCode ?: string; + + @Column("transaction_type_description") + public transactionTypeDescription ?: string; + + @Column("uetr") + public uetr ?: string; + + public static toDTO (entity: OpTransactionEntity) : OpTransactionDTO { + if (!entity.amount) throw new TypeError('entity.amount missing'); + if (!entity.balanceBefore) throw new TypeError('entity.balanceBefore missing'); + if (!entity.balanceAfter) throw new TypeError('entity.balanceAfter missing'); + if (!entity.objectId) throw new TypeError('entity.objectId missing'); + if (!entity.archiveId) throw new TypeError('entity.archiveId missing'); + if (!entity.debtorBic) throw new TypeError('entity.debtorBic missing'); + if (!entity.valueDate) throw new TypeError('entity.valueDate missing'); + if (!entity.debtorName) throw new TypeError('entity.debtorName missing'); + if (!entity.bookingDate) throw new TypeError('entity.bookingDate missing'); + if (!entity.paymentDate) throw new TypeError('entity.paymentDate missing'); + if (!entity.creditorName) throw new TypeError('entity.creditorName missing'); + if (!entity.debtorAccount) throw new TypeError('entity.debtorAccount missing'); + if (!entity.timestamp) throw new TypeError('entity.timestamp missing'); + if (!entity.transactionTypeCode) throw new TypeError('entity.transactionTypeCode missing'); + if (!entity.transactionTypeDescription) throw new TypeError('entity.transactionTypeDescription missing'); + if (!entity.uetr) throw new TypeError('entity.uetr missing'); + const timestamp = parseInteger(entity.timestamp); + if (!timestamp) throw new TypeError('timestamp invalid: '+ entity.timestamp); + const dto : OpTransactionDTO = createOpTransactionDTO( + entity.amount, + entity.balanceBefore, + entity.balanceAfter, + entity.message ?? null, + entity.currency ?? null, + entity.objectId, + entity.archiveId, + entity.debtorBic, + entity.reference ?? null, + entity.rfReference ?? null, + entity.valueDate, + entity.debtorName, + entity.bookingDate, + entity.creditorBic ?? null, + entity.paymentDate, + entity.creditorName, + entity.debtorAccount, + entity.creditorAccount ?? null, + entity.endToEndId ?? null, + timestamp, + entity.transactionTypeCode, + entity.transactionTypeDescription, + entity.uetr, + ); + // Redundant fail safe + if (!isOpTransactionDTO(dto)) { + LOG.debug(`toDTO: dto / entity = `, dto, entity); + throw new TypeError(`Failed to create valid OpTransactionDTO: ${explainOpTransactionDTO(dto)}`); + } + return dto; + } + +} diff --git a/op/repository/transaction/OpTransactionRepository.ts b/op/repository/transaction/OpTransactionRepository.ts new file mode 100644 index 0000000..b2d07b9 --- /dev/null +++ b/op/repository/transaction/OpTransactionRepository.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Where } from "../../../data/Where"; +import { OpTransactionEntity } from "./OpTransactionEntity"; +import { Repository } from "../../../data/types/Repository"; +import { Sort } from "../../../data/Sort"; + +export interface OpTransactionRepository extends Repository { + + findAllByUpdatedBetween (startDate: string, endDate: string, sort?: Sort, where ?: Where) : Promise; + findAllByCreatedBetween (startDate: string, endDate: string, sort?: Sort, where ?: Where) : Promise; + findAllByValueDateBetween (startDate: string, endDate: string, sort?: Sort, where ?: Where) : Promise; + findAllByBookingDateBetween (startDate: string, endDate: string, sort?: Sort, where ?: Where) : Promise; + findAllByPaymentDateBetween (startDate: string, endDate: string, sort?: Sort, where ?: Where) : Promise; + + findAllByUpdatedAfter (date: string, sort?: Sort, where ?: Where) : Promise; + findAllByCreatedAfter (date: string, sort?: Sort, where ?: Where) : Promise; + findAllByValueDateAfter (date: string, sort?: Sort, where ?: Where) : Promise; + findAllByBookingDateAfter (date: string, sort?: Sort, where ?: Where) : Promise; + findAllByPaymentDateAfter (date: string, sort?: Sort, where ?: Where) : Promise; + + findAllByUpdatedBefore (date: string, sort?: Sort, where ?: Where) : Promise; + findAllByCreatedBefore (date: string, sort?: Sort, where ?: Where) : Promise; + findAllByValueDateBefore (date: string, sort?: Sort, where ?: Where) : Promise; + findAllByBookingDateBefore (date: string, sort?: Sort, where ?: Where) : Promise; + findAllByPaymentDateBefore (date: string, sort?: Sort, where ?: Where) : Promise; + + findByOpSurrogateId (surrogateId: string, sort?: Sort) : Promise; + + findAllByOpAccountId (opAccountId: string, sort?: Sort) : Promise; + findAllByOpSurrogateId (surrogateId: string, sort?: Sort) : Promise; + findAllByOpAccountId (opAccountId: string, sort?: Sort) : Promise; + findAllByArchiveId (archiveId: string, sort?: Sort) : Promise; + findAllByObjectId (objectId: string, sort?: Sort) : Promise; + findAllByDebtorBic (debtorBic: string, sort?: Sort) : Promise; + findAllByDebtorName (debtorName: string, sort?: Sort) : Promise; + findAllByValueDate (valueDate: string, sort?: Sort) : Promise; + findAllByBookingDate (bookingDate: string, sort?: Sort) : Promise; + findAllByCreditorBic (creditorBic: string, sort?: Sort) : Promise; + findAllByCreditorName (creditorName: string, sort?: Sort) : Promise; + findAllByPaymentDate (paymentDate: string, sort?: Sort) : Promise; + findAllByDebtorAccount (debtorAccount: string, sort?: Sort) : Promise; + findAllByCreditorAccount (creditorAccount: string, sort?: Sort) : Promise; + findAllByMessage (message: string, sort?: Sort) : Promise; + findAllByReference (reference: string, sort?: Sort) : Promise; + findAllByRfReference (rfReference: string, sort?: Sort) : Promise; + findAllByEndToEndId (endToEndId: string, sort?: Sort) : Promise; +} diff --git a/op/types/OpIdentificationScheme.test.ts b/op/types/OpIdentificationScheme.test.ts new file mode 100644 index 0000000..4e709fa --- /dev/null +++ b/op/types/OpIdentificationScheme.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpIdentificationScheme, explainOpIdentificationSchemeOrUndefined, isOpIdentificationScheme, isOpIdentificationSchemeOrUndefined, OpIdentificationScheme, parseOpIdentificationScheme, stringifyOpIdentificationScheme } from "./OpIdentificationScheme"; + +describe('OpIdentificationScheme', () => { + + describe('isOpIdentificationScheme', () => { + it('should correctly identify OpIdentificationScheme', () => { + expect(isOpIdentificationScheme(OpIdentificationScheme.BIC)).toBe(true); + expect(isOpIdentificationScheme('NotValidEnum')).toBe(false); + }); + }); + + describe('explainOpIdentificationScheme', () => { + it('should provide correct explanation for OpIdentificationScheme', () => { + expect(explainOpIdentificationScheme(OpIdentificationScheme.BIC)).toEqual('OK'); + expect(explainOpIdentificationScheme('NotValidEnum')).toEqual('incorrect enum value "NotValidEnum" for OpIdentificationScheme: Accepted values BIC, COID, TXID, SOSE, UNSTRUCTURED_ORG, UNSTRUCTURED_PERSON'); + }); + }); + + describe('stringifyOpIdentificationScheme', () => { + it('should correctly stringify OpIdentificationScheme', () => { + expect(stringifyOpIdentificationScheme(OpIdentificationScheme.BIC)).toEqual('BIC'); + // @ts-ignore + expect(() => stringifyOpIdentificationScheme('NotValidEnum')).toThrowError(); // Assuming the stringifyEnum function throws error for invalid enums + }); + }); + + describe('parseOpIdentificationScheme', () => { + it('should correctly parse string to OpIdentificationScheme', () => { + expect(parseOpIdentificationScheme('BIC')).toEqual(OpIdentificationScheme.BIC); + expect(parseOpIdentificationScheme('NotValidEnum')).toBeUndefined(); + }); + }); + + describe('isOpIdentificationSchemeOrUndefined', () => { + it('should correctly identify OpIdentificationScheme or undefined', () => { + expect(isOpIdentificationSchemeOrUndefined(OpIdentificationScheme.BIC)).toBe(true); + expect(isOpIdentificationSchemeOrUndefined(undefined)).toBe(true); + expect(isOpIdentificationSchemeOrUndefined('NotValidEnum')).toBe(false); + }); + }); + + describe('explainOpIdentificationSchemeOrUndefined', () => { + it('should provide correct explanation for OpIdentificationScheme or undefined', () => { + expect(explainOpIdentificationSchemeOrUndefined(OpIdentificationScheme.BIC)).toEqual('OK'); + expect(explainOpIdentificationSchemeOrUndefined(undefined)).toEqual('OK'); + expect(explainOpIdentificationSchemeOrUndefined('NotValidEnum')).toEqual('not OpIdentificationScheme or undefined'); + }); + }); + +}); diff --git a/op/types/OpIdentificationScheme.ts b/op/types/OpIdentificationScheme.ts new file mode 100644 index 0000000..aeb1738 --- /dev/null +++ b/op/types/OpIdentificationScheme.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; + +/** + * Type of identification used for identifying the ultimate creditor. + */ +export enum OpIdentificationScheme { + + /** + * BIC - Bank Identification Code + */ + BIC = "BIC", + + /** + * COID - Country Identifier Code for an organization (e.g. Business ID) + */ + COID = "COID", + + /** + * TXID - Tax Identification Number for an organization (e.g. VAT code) + */ + TXID = "TXID", + + /** + * SOSE - Personal identity code (social security number) for a person + */ + SOSE = "SOSE", + + /** + * UNSTRUCTURED_ORG - Unstructured identifier for an organization + */ + UNSTRUCTURED_ORG = "UNSTRUCTURED_ORG", + + /** + * UNSTRUCTURED_PERSON - Unstructured identifier for a person + */ + UNSTRUCTURED_PERSON = "UNSTRUCTURED_PERSON", + +} + +export function isOpIdentificationScheme (value: unknown) : value is OpIdentificationScheme { + return isEnum(OpIdentificationScheme, value); +} + +export function explainOpIdentificationScheme (value : unknown) : string { + return explainEnum("OpIdentificationScheme", OpIdentificationScheme, isOpIdentificationScheme, value); +} + +export function stringifyOpIdentificationScheme (value : OpIdentificationScheme) : string { + return stringifyEnum(OpIdentificationScheme, value); +} + +export function parseOpIdentificationScheme (value: any) : OpIdentificationScheme | undefined { + return parseEnum(OpIdentificationScheme, value) as OpIdentificationScheme | undefined; +} + +export function isOpIdentificationSchemeOrUndefined (value: unknown): value is OpIdentificationScheme | undefined { + return isUndefined(value) || isOpIdentificationScheme(value); +} + +export function explainOpIdentificationSchemeOrUndefined (value: unknown): string { + return isOpIdentificationSchemeOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpIdentificationScheme', 'undefined'])); +} diff --git a/op/types/OpPaymentAddress.test.ts b/op/types/OpPaymentAddress.test.ts new file mode 100644 index 0000000..de78ce7 --- /dev/null +++ b/op/types/OpPaymentAddress.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpAddress, explainOpAddress, explainOpAddressOrUndefined, isOpAddress, isOpAddressOrUndefined, OpAddress, parseOpAddress } from "./OpPaymentAddress"; +import { CountryCode } from "../../types/CountryCode"; + +describe('OpAddress', () => { + const validAddress: OpAddress = { + country: 'FI' as CountryCode, + addressLine: ['Street 1', 'Apartment 2'] + }; + + const invalidAddress = { + country: 'Not a country', + addressLine: 'Not an address line' + }; + + describe('createOpAddress', () => { + it('should create a valid OpAddress', () => { + const address = createOpAddress('FI' as CountryCode, ['Street 1', 'Apartment 2']); + expect(address).toEqual(validAddress); + }); + }); + + describe('isOpAddress', () => { + it('should return true for valid OpAddress', () => { + expect(isOpAddress(validAddress)).toBe(true); + }); + + it('should return false for invalid OpAddress', () => { + expect(isOpAddress(invalidAddress)).toBe(false); + }); + }); + + describe('explainOpAddress', () => { + it('should return explanation for a valid OpAddress', () => { + // replace 'OK' with what you actually expect + expect(explainOpAddress(validAddress)).toBe('OK'); + }); + }); + + describe('parseOpAddress', () => { + it('should return OpAddress for a valid OpAddress', () => { + expect(parseOpAddress(validAddress)).toEqual(validAddress); + }); + + it('should return undefined for an invalid OpAddress', () => { + expect(parseOpAddress(invalidAddress)).toBeUndefined(); + }); + }); + + describe('isOpAddressOrUndefined', () => { + it('should return true for valid OpAddress', () => { + expect(isOpAddressOrUndefined(validAddress)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpAddressOrUndefined(undefined)).toBe(true); + }); + + it('should return false for invalid OpAddress', () => { + expect(isOpAddressOrUndefined(invalidAddress)).toBe(false); + }); + }); + + describe('explainOpAddressOrUndefined', () => { + it('should return explanation for a valid OpAddress', () => { + // replace 'OK' with what you actually expect + expect(explainOpAddressOrUndefined(validAddress)).toBe('OK'); + }); + + it('should return explanation for undefined', () => { + // replace 'OK' with what you actually expect + expect(explainOpAddressOrUndefined(undefined)).toBe('OK'); + }); + }); + +}); diff --git a/op/types/OpPaymentAddress.ts b/op/types/OpPaymentAddress.ts new file mode 100644 index 0000000..21ee135 --- /dev/null +++ b/op/types/OpPaymentAddress.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { CountryCode, explainCountryCode, isCountryCode } from "../../types/CountryCode"; +import { explainStringArray, isStringArray } from "../../types/StringArray"; + +/** + * @example + * { + * "addressLine":["a1","a2"], + * "country":"FI" + * } + */ +export interface OpAddress { + readonly country : CountryCode; + readonly addressLine : readonly string[]; +} + +export function createOpAddress ( + country : CountryCode, + addressLine : readonly string[], +) : OpAddress { + return { + country, + addressLine + }; +} + +export function isOpAddress (value: unknown) : value is OpAddress { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'country', + 'addressLine', + ]) + && isCountryCode(value?.country) + && isStringArray(value?.addressLine) + ); +} + +export function explainOpAddress (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'country', + 'addressLine', + ]) + , explainProperty("country", explainCountryCode(value?.country)) + , explainProperty("addressLine", explainStringArray(value?.addressLine)) + ] + ); +} + +export function parseOpAddress (value: unknown) : OpAddress | undefined { + if (isOpAddress(value)) return value; + return undefined; +} + +export function isOpAddressOrUndefined (value: unknown): value is OpAddress | undefined { + return isUndefined(value) || isOpAddress(value); +} + +export function explainOpAddressOrUndefined (value: unknown): string { + return isOpAddressOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpAddress', 'undefined'])); +} diff --git a/op/types/OpPaymentCreditor.test.ts b/op/types/OpPaymentCreditor.test.ts new file mode 100644 index 0000000..3432ec2 --- /dev/null +++ b/op/types/OpPaymentCreditor.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpPaymentCreditor, explainOpPaymentCreditor, explainOpPaymentCreditorOrUndefined, isOpPaymentCreditor, isOpPaymentCreditorOrUndefined, OpPaymentCreditor, parseOpPaymentCreditor } from "./OpPaymentCreditor"; +import { CountryCode } from "../../types/CountryCode"; + +describe('OpPaymentCreditor', () => { + + const validCreditor: OpPaymentCreditor = { + name: 'Test Name', + iban: 'FI3859991620004143', + address: { + country: 'FI' as CountryCode, + addressLine: ['a1', 'a2'] + } + }; + + const invalidCreditor = { + name: 123, // not a string + iban: true, // not a string + address: 'Not a valid address' // not an OpAddress + }; + + describe('createOpPaymentCreditor', () => { + it('should create a valid OpPaymentCreditor', () => { + const creditor = createOpPaymentCreditor('Test Name', 'FI3859991620004143', validCreditor.address); + expect(creditor).toEqual(validCreditor); + }); + }); + + describe('isOpPaymentCreditor', () => { + it('should return true for valid OpPaymentCreditor', () => { + expect(isOpPaymentCreditor(validCreditor)).toBe(true); + }); + + it('should return false for invalid OpPaymentCreditor', () => { + expect(isOpPaymentCreditor(invalidCreditor)).toBe(false); + }); + }); + + describe('explainOpPaymentCreditor', () => { + it('should return explanation for a valid OpPaymentCreditor', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentCreditor(validCreditor)).toBe('OK'); + }); + }); + + describe('parseOpPaymentCreditor', () => { + it('should return OpPaymentCreditor for a valid OpPaymentCreditor', () => { + expect(parseOpPaymentCreditor(validCreditor)).toEqual(validCreditor); + }); + + it('should return undefined for an invalid OpPaymentCreditor', () => { + expect(parseOpPaymentCreditor(invalidCreditor)).toBeUndefined(); + }); + }); + + describe('isOpPaymentCreditorOrUndefined', () => { + it('should return true for valid OpPaymentCreditor', () => { + expect(isOpPaymentCreditorOrUndefined(validCreditor)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpPaymentCreditorOrUndefined(undefined)).toBe(true); + }); + + it('should return false for invalid OpPaymentCreditor', () => { + expect(isOpPaymentCreditorOrUndefined(invalidCreditor)).toBe(false); + }); + }); + + describe('explainOpPaymentCreditorOrUndefined', () => { + it('should return explanation for a valid OpPaymentCreditor', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentCreditorOrUndefined(validCreditor)).toBe('OK'); + }); + + it('should return explanation for undefined', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentCreditorOrUndefined(undefined)).toBe('OK'); + }); + }); + +}); diff --git a/op/types/OpPaymentCreditor.ts b/op/types/OpPaymentCreditor.ts new file mode 100644 index 0000000..a38c6c9 --- /dev/null +++ b/op/types/OpPaymentCreditor.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainOpAddressOrUndefined, isOpAddressOrUndefined, OpAddress } from "./OpPaymentAddress"; + +/** + * @example + * { + * "name":"Creditor Name", + * "iban":"FI3859991620004143", + * "address":{ + * "addressLine":["a1","a2"], + * "country":"FI" + * } + * } + */ + +export interface OpPaymentCreditor { + + /** + * Size: 1..70 characters + */ + readonly name : string; + + /** + * Site: 1..34 characteds + */ + readonly iban ?: string; + readonly address ?: OpAddress; + +} + +export function createOpPaymentCreditor ( + name : string, + iban ?: string, + address ?: OpAddress, +) : OpPaymentCreditor { + return { + name, + iban, + address, + }; +} + +export function isOpPaymentCreditor (value: unknown) : value is OpPaymentCreditor { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'name', + 'iban', + 'address', + ]) + && isString(value?.name) + && isStringOrUndefined(value?.iban) + && isOpAddressOrUndefined(value?.address) + ); +} + +export function explainOpPaymentCreditor (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'name', + 'iban', + 'address', + ]) + , explainProperty("name", explainString(value?.name)) + , explainProperty("iban", explainStringOrUndefined(value?.iban)) + , explainProperty("address", explainOpAddressOrUndefined(value?.address)) + ] + ); +} + +export function parseOpPaymentCreditor (value: unknown) : OpPaymentCreditor | undefined { + if (isOpPaymentCreditor(value)) return value; + return undefined; +} + +export function isOpPaymentCreditorOrUndefined (value: unknown): value is OpPaymentCreditor | undefined { + return isUndefined(value) || isOpPaymentCreditor(value); +} + +export function explainOpPaymentCreditorOrUndefined (value: unknown): string { + return isOpPaymentCreditorOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentCreditor', 'undefined'])); +} diff --git a/op/types/OpPaymentDebtor.test.ts b/op/types/OpPaymentDebtor.test.ts new file mode 100644 index 0000000..8ae6237 --- /dev/null +++ b/op/types/OpPaymentDebtor.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpPaymentDebtor, explainOpPaymentDebtor, explainOpPaymentDebtorOrUndefined, isOpPaymentDebtor, isOpPaymentDebtorOrUndefined, OpPaymentDebtor, parseOpPaymentDebtor } from "./OpPaymentDebtor"; +import { CountryCode } from "../../types/CountryCode"; + +describe('OpPaymentDebtor', () => { + + const validDebtor: OpPaymentDebtor = { + name: 'Test Name', + iban: 'FI3859991620004143', + address: { + country: 'FI' as CountryCode, + addressLine: ['a1', 'a2'] + } + }; + + const invalidDebtor = { + name: 123, // not a string + iban: true, // not a string + address: 'Not a valid address' // not an OpAddress + }; + + describe('createOpPaymentDebtor', () => { + it('should create a valid OpPaymentDebtor', () => { + const creditor = createOpPaymentDebtor('Test Name', 'FI3859991620004143', validDebtor.address); + expect(creditor).toEqual(validDebtor); + }); + }); + + describe('isOpPaymentDebtor', () => { + it('should return true for valid OpPaymentDebtor', () => { + expect(isOpPaymentDebtor(validDebtor)).toBe(true); + }); + + it('should return false for invalid OpPaymentDebtor', () => { + expect(isOpPaymentDebtor(invalidDebtor)).toBe(false); + }); + }); + + describe('explainOpPaymentDebtor', () => { + it('should return explanation for a valid OpPaymentDebtor', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentDebtor(validDebtor)).toBe('OK'); + }); + }); + + describe('parseOpPaymentDebtor', () => { + it('should return OpPaymentDebtor for a valid OpPaymentDebtor', () => { + expect(parseOpPaymentDebtor(validDebtor)).toEqual(validDebtor); + }); + + it('should return undefined for an invalid OpPaymentDebtor', () => { + expect(parseOpPaymentDebtor(invalidDebtor)).toBeUndefined(); + }); + }); + + describe('isOpPaymentDebtorOrUndefined', () => { + it('should return true for valid OpPaymentDebtor', () => { + expect(isOpPaymentDebtorOrUndefined(validDebtor)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpPaymentDebtorOrUndefined(undefined)).toBe(true); + }); + + it('should return false for invalid OpPaymentDebtor', () => { + expect(isOpPaymentDebtorOrUndefined(invalidDebtor)).toBe(false); + }); + }); + + describe('explainOpPaymentDebtorOrUndefined', () => { + it('should return explanation for a valid OpPaymentDebtor', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentDebtorOrUndefined(validDebtor)).toBe('OK'); + }); + + it('should return explanation for undefined', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentDebtorOrUndefined(undefined)).toBe('OK'); + }); + }); + +}); diff --git a/op/types/OpPaymentDebtor.ts b/op/types/OpPaymentDebtor.ts new file mode 100644 index 0000000..c0265de --- /dev/null +++ b/op/types/OpPaymentDebtor.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainOpAddressOrUndefined, isOpAddressOrUndefined, OpAddress } from "./OpPaymentAddress"; + +/** + * @example + * { + * "name":"Debtor Name", + * "iban":"FI3859991620004143", + * "address":{ + * "addressLine":["a1","a2"], + * "country":"FI" + * } + * } + */ + +export interface OpPaymentDebtor { + + /** + * Size: 1..70 characters + */ + readonly name : string; + + /** + * Site: 1..34 characteds + */ + readonly iban ?: string; + + readonly address ?: OpAddress; + +} + +export function createOpPaymentDebtor ( + name : string, + iban ?: string, + address ?: OpAddress, +) : OpPaymentDebtor { + return { + name, + iban, + address, + }; +} + +export function isOpPaymentDebtor (value: unknown) : value is OpPaymentDebtor { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'name', + 'iban', + 'address', + ]) + && isString(value?.name) + && isStringOrUndefined(value?.iban) + && isOpAddressOrUndefined(value?.address) + ); +} + +export function explainOpPaymentDebtor (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'name', + 'iban', + 'address', + ]) + , explainProperty("name", explainString(value?.name)) + , explainProperty("iban", explainStringOrUndefined(value?.iban)) + , explainProperty("address", explainOpAddressOrUndefined(value?.address)) + ] + ); +} + +export function parseOpPaymentDebtor (value: unknown) : OpPaymentDebtor | undefined { + if (isOpPaymentDebtor(value)) return value; + return undefined; +} + +export function isOpPaymentDebtorOrUndefined (value: unknown): value is OpPaymentDebtor | undefined { + return isUndefined(value) || isOpPaymentDebtor(value); +} + +export function explainOpPaymentDebtorOrUndefined (value: unknown): string { + return isOpPaymentDebtorOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentDebtor', 'undefined'])); +} diff --git a/op/types/OpPaymentIdentification.test.ts b/op/types/OpPaymentIdentification.test.ts new file mode 100644 index 0000000..232f473 --- /dev/null +++ b/op/types/OpPaymentIdentification.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpPaymentIdentification, explainOpPaymentIdentification, explainOpPaymentIdentificationOrUndefined, isOpPaymentIdentification, isOpPaymentIdentificationOrUndefined, OpPaymentIdentification, parseOpPaymentIdentification } from "./OpPaymentIdentification"; +import { OpIdentificationScheme } from "./OpIdentificationScheme"; + +describe('OpPaymentIdentification', () => { + let testObject: OpPaymentIdentification; + let wrongObject: any; + + beforeEach(() => { + testObject = createOpPaymentIdentification( + "123456789", + OpIdentificationScheme.BIC, + "TestIssuer" + ); + + wrongObject = { + id: 123456789, // Should be a string, not a number + schemeName: "WrongScheme", + issuer: 123 // Should be a string or undefined + }; + }); + + describe('createOpPaymentIdentification', () => { + it('should correctly create an OpPaymentIdentification object', () => { + expect(testObject.id).toBe("123456789"); + expect(testObject.schemeName).toBe(OpIdentificationScheme.BIC); + expect(testObject.issuer).toBe("TestIssuer"); + }); + }); + + describe('isOpPaymentIdentification', () => { + it('should correctly identify OpPaymentIdentification objects', () => { + expect(isOpPaymentIdentification(testObject)).toBe(true); + expect(isOpPaymentIdentification(wrongObject)).toBe(false); + }); + }); + + describe('explainOpPaymentIdentification', () => { + it('should provide correct explanation for OpPaymentIdentification objects', () => { + expect(explainOpPaymentIdentification(testObject)).toEqual('OK'); + expect(explainOpPaymentIdentification(wrongObject)).toContain('property "id" not string'); + expect(explainOpPaymentIdentification(wrongObject)).toContain('property "schemeName" incorrect enum value "WrongScheme" for OpIdentificationScheme: Accepted values BIC, COID, TXID, SOSE, UNSTRUCTURED_ORG, UNSTRUCTURED_PERSON'); + expect(explainOpPaymentIdentification(wrongObject)).toContain('property "issuer" not string or undefined'); + }); + }); + + describe('parseOpPaymentIdentification', () => { + it('should correctly parse valid objects to OpPaymentIdentification', () => { + expect(parseOpPaymentIdentification(testObject)).toEqual(testObject); + expect(parseOpPaymentIdentification(wrongObject)).toBeUndefined(); + }); + }); + + describe('isOpPaymentIdentificationOrUndefined', () => { + it('should correctly identify OpPaymentIdentification or undefined', () => { + expect(isOpPaymentIdentificationOrUndefined(testObject)).toBe(true); + expect(isOpPaymentIdentificationOrUndefined(undefined)).toBe(true); + expect(isOpPaymentIdentificationOrUndefined(wrongObject)).toBe(false); + }); + }); + + describe('explainOpPaymentIdentificationOrUndefined', () => { + it('should provide correct explanation for OpPaymentIdentification or undefined', () => { + expect(explainOpPaymentIdentificationOrUndefined(testObject)).toEqual('OK'); + expect(explainOpPaymentIdentificationOrUndefined(undefined)).toEqual('OK'); + expect(explainOpPaymentIdentificationOrUndefined(wrongObject)).toEqual('not OpPaymentIdentification or undefined'); + }); + }); +}); diff --git a/op/types/OpPaymentIdentification.ts b/op/types/OpPaymentIdentification.ts new file mode 100644 index 0000000..710d475 --- /dev/null +++ b/op/types/OpPaymentIdentification.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainOpIdentificationScheme, isOpIdentificationScheme, OpIdentificationScheme } from "./OpIdentificationScheme"; + +export interface OpPaymentIdentification { + + /** + * An identifier for the ultimate creditor, e.g. Business ID (Y-tunnus) or + * personal identity code (social security number). To be interpreted in the + * context of schemeName and issuer. + */ + readonly id: string; + + /** + * Type of identification used for identifying the ultimate creditor. + */ + readonly schemeName: OpIdentificationScheme; + + /** + * Name of the id issuer. + */ + readonly issuer ?: string; + +} + +export function createOpPaymentIdentification ( + id : string, + schemeName : OpIdentificationScheme, + issuer ?: string, +) : OpPaymentIdentification { + return { + id, + schemeName, + ...(issuer !== undefined ? {issuer} : {}), + }; +} + +export function isOpPaymentIdentification (value: unknown) : value is OpPaymentIdentification { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'id', + 'schemeName', + 'issuer', + ]) + && isString(value?.id) + && isOpIdentificationScheme(value?.schemeName) + && isStringOrUndefined(value?.issuer) + ); +} + +export function explainOpPaymentIdentification (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'id', + 'schemeName', + 'issuer', + ]) + , explainProperty("id", explainString(value?.id)) + , explainProperty("schemeName", explainOpIdentificationScheme(value?.schemeName)) + , explainProperty("issuer", explainStringOrUndefined(value?.issuer)) + ] + ); +} + +export function parseOpPaymentIdentification (value: unknown) : OpPaymentIdentification | undefined { + if (isOpPaymentIdentification(value)) return value; + return undefined; +} + +export function isOpPaymentIdentificationOrUndefined (value: unknown): value is OpPaymentIdentification | undefined { + return isUndefined(value) || isOpPaymentIdentification(value); +} + +export function explainOpPaymentIdentificationOrUndefined (value: unknown): string { + return isOpPaymentIdentificationOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentIdentification', 'undefined'])); +} diff --git a/op/types/OpPaymentInstructedAmount.test.ts b/op/types/OpPaymentInstructedAmount.test.ts new file mode 100644 index 0000000..abcfde4 --- /dev/null +++ b/op/types/OpPaymentInstructedAmount.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpPaymentInstructedAmount, explainOpPaymentInstructedAmount, explainOpPaymentInstructedAmountOrUndefined, isOpPaymentInstructedAmount, isOpPaymentInstructedAmountOrUndefined, OpPaymentInstructedAmount, parseOpPaymentInstructedAmount } from "./OpPaymentInstructedAmount"; +import { Currency } from "../../types/Currency"; + +describe('OpPaymentInstructedAmount', () => { + const validPaymentAmount: OpPaymentInstructedAmount = { + amount: '100.00', + currency: 'USD' as Currency + }; + + const invalidPaymentAmount = { + amount: 'Not a valid amount', + currency: 'Not a valid currency' + }; + + describe('createOpPaymentInstructedAmount', () => { + it('should create a valid OpPaymentInstructedAmount', () => { + const paymentAmount = createOpPaymentInstructedAmount('100.00', 'USD' as Currency); + expect(paymentAmount).toEqual(validPaymentAmount); + }); + }); + + describe('isOpPaymentInstructedAmount', () => { + it('should return true for valid OpPaymentInstructedAmount', () => { + expect(isOpPaymentInstructedAmount(validPaymentAmount)).toBe(true); + }); + + it('should return false for invalid OpPaymentInstructedAmount', () => { + expect(isOpPaymentInstructedAmount(invalidPaymentAmount)).toBe(false); + }); + }); + + describe('explainOpPaymentInstructedAmount', () => { + it('should return explanation for a valid OpPaymentInstructedAmount', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentInstructedAmount(validPaymentAmount)).toBe('OK'); + }); + }); + + describe('parseOpPaymentInstructedAmount', () => { + it('should return OpPaymentInstructedAmount for a valid OpPaymentInstructedAmount', () => { + expect(parseOpPaymentInstructedAmount(validPaymentAmount)).toEqual(validPaymentAmount); + }); + + it('should return undefined for an invalid OpPaymentInstructedAmount', () => { + expect(parseOpPaymentInstructedAmount(invalidPaymentAmount)).toBeUndefined(); + }); + }); + + describe('isOpPaymentInstructedAmountOrUndefined', () => { + it('should return true for valid OpPaymentInstructedAmount', () => { + expect(isOpPaymentInstructedAmountOrUndefined(validPaymentAmount)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpPaymentInstructedAmountOrUndefined(undefined)).toBe(true); + }); + + it('should return false for invalid OpPaymentInstructedAmount', () => { + expect(isOpPaymentInstructedAmountOrUndefined(invalidPaymentAmount)).toBe(false); + }); + }); + + describe('explainOpPaymentInstructedAmountOrUndefined', () => { + it('should return explanation for a valid OpPaymentInstructedAmount', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentInstructedAmountOrUndefined(validPaymentAmount)).toBe('OK'); + }); + + it('should return explanation for undefined', () => { + // replace 'OK' with what you actually expect + expect(explainOpPaymentInstructedAmountOrUndefined(undefined)).toBe('OK'); + }); + }); + +}); diff --git a/op/types/OpPaymentInstructedAmount.ts b/op/types/OpPaymentInstructedAmount.ts new file mode 100644 index 0000000..d3a4283 --- /dev/null +++ b/op/types/OpPaymentInstructedAmount.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, isString } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { Currency, explainCurrency, isCurrency } from "../../types/Currency"; + +export interface OpPaymentInstructedAmount { + + /** + * Payment amount, ^\d{1,13}\.\d{2}$ + */ + readonly amount: string; + + /** + * ISO 4217 code for currencies + */ + readonly currency: Currency; + +} + +export function createOpPaymentInstructedAmount ( + amount : string, + currency : Currency, +) : OpPaymentInstructedAmount { + return { + amount, + currency, + }; +} + +export function isOpPaymentInstructedAmount (value: unknown) : value is OpPaymentInstructedAmount { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'amount', + 'currency', + ]) + && isString(value?.amount) + && isCurrency(value?.currency) + ); +} + +export function explainOpPaymentInstructedAmount (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'amount', + 'currency', + ]) + , explainProperty("amount", explainString(value?.amount)) + , explainProperty("currency", explainCurrency(value?.currency)) + ] + ); +} + +export function parseOpPaymentInstructedAmount (value: unknown) : OpPaymentInstructedAmount | undefined { + if (isOpPaymentInstructedAmount(value)) return value; + return undefined; +} + +export function isOpPaymentInstructedAmountOrUndefined (value: unknown): value is OpPaymentInstructedAmount | undefined { + return isUndefined(value) || isOpPaymentInstructedAmount(value); +} + +export function explainOpPaymentInstructedAmountOrUndefined (value: unknown): string { + return isOpPaymentInstructedAmountOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentInstructedAmount', 'undefined'])); +} diff --git a/op/types/OpPaymentStatus.test.ts b/op/types/OpPaymentStatus.test.ts new file mode 100644 index 0000000..f0bf1c4 --- /dev/null +++ b/op/types/OpPaymentStatus.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpPaymentStatus, explainOpPaymentStatusOrUndefined, isOpPaymentStatus, isOpPaymentStatusOrUndefined, OpPaymentStatus, parseOpPaymentStatus, stringifyOpPaymentStatus } from "./OpPaymentStatus"; + +describe('OpPaymentStatus', () => { + + const validStatuses = [OpPaymentStatus.PROCESSING, OpPaymentStatus.PROCESSED]; + const invalidStatus = 'UNKNOWN'; + + describe('isOpPaymentStatus', () => { + it('should return true for valid OpPaymentStatus', () => { + validStatuses.forEach((status) => { + expect(isOpPaymentStatus(status)).toBe(true); + }); + }); + + it('should return false for invalid OpPaymentStatus', () => { + expect(isOpPaymentStatus(invalidStatus)).toBe(false); + }); + }); + + describe('explainOpPaymentStatus', () => { + it('should return expected explanation for valid and invalid statuses', () => { + validStatuses.forEach((status) => { + // replace 'OK' with the actual expected explanation + expect(explainOpPaymentStatus(status)).toBe('OK'); + }); + + // replace 'OK' with the actual expected explanation for invalid case + expect(explainOpPaymentStatus(invalidStatus)).toBe('incorrect enum value "UNKNOWN" for OpPaymentStatus: Accepted values PROCESSING, PROCESSED'); + }); + }); + + describe('stringifyOpPaymentStatus', () => { + it('should return correct string representation for valid OpPaymentStatus', () => { + validStatuses.forEach((status) => { + expect(stringifyOpPaymentStatus(status)).toBe(status); + }); + }); + }); + + describe('parseOpPaymentStatus', () => { + it('should parse valid string representation to corresponding OpPaymentStatus', () => { + validStatuses.forEach((status) => { + expect(parseOpPaymentStatus(status)).toBe(status); + }); + }); + + it('should return undefined for invalid string representation', () => { + expect(parseOpPaymentStatus(invalidStatus)).toBeUndefined(); + }); + }); + + describe('isOpPaymentStatusOrUndefined', () => { + it('should return true for valid OpPaymentStatus or undefined', () => { + validStatuses.forEach((status) => { + expect(isOpPaymentStatusOrUndefined(status)).toBe(true); + }); + expect(isOpPaymentStatusOrUndefined(undefined)).toBe(true); + }); + + it('should return false for invalid OpPaymentStatus', () => { + expect(isOpPaymentStatusOrUndefined(invalidStatus)).toBe(false); + }); + }); + + describe('explainOpPaymentStatusOrUndefined', () => { + it('should return expected explanation for valid, invalid, and undefined statuses', () => { + validStatuses.forEach((status) => { + // replace 'OK' with the actual expected explanation + expect(explainOpPaymentStatusOrUndefined(status)).toBe('OK'); + }); + + // replace 'OK' with the actual expected explanation for invalid case + expect(explainOpPaymentStatusOrUndefined(invalidStatus)).toBe('not OpPaymentStatus or undefined'); + + // replace 'OK' with the actual expected explanation for undefined case + expect(explainOpPaymentStatusOrUndefined(undefined)).toBe('OK'); + }); + }); +}); diff --git a/op/types/OpPaymentStatus.ts b/op/types/OpPaymentStatus.ts new file mode 100644 index 0000000..58181f2 --- /dev/null +++ b/op/types/OpPaymentStatus.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +export enum OpPaymentStatus { + + PROCESSING = "PROCESSING", + + /** + * PROCESSED if the creditor has received the money instantly. + */ + PROCESSED = "PROCESSED", + +} + +export function isOpPaymentStatus (value: unknown) : value is OpPaymentStatus { + return isEnum(OpPaymentStatus, value); +} + +export function explainOpPaymentStatus (value : unknown) : string { + return explainEnum("OpPaymentStatus", OpPaymentStatus, isOpPaymentStatus, value); +} + +export function stringifyOpPaymentStatus (value : OpPaymentStatus) : string { + return stringifyEnum(OpPaymentStatus, value); +} + +export function parseOpPaymentStatus (value: any) : OpPaymentStatus | undefined { + return parseEnum(OpPaymentStatus, value) as OpPaymentStatus | undefined; +} + +export function isOpPaymentStatusOrUndefined (value: unknown): value is OpPaymentStatus | undefined { + return isUndefined(value) || isOpPaymentStatus(value); +} + +export function explainOpPaymentStatusOrUndefined (value: unknown): string { + return isOpPaymentStatusOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentStatus', 'undefined'])); +} diff --git a/op/types/OpPaymentType.test.ts b/op/types/OpPaymentType.test.ts new file mode 100644 index 0000000..f098e8d --- /dev/null +++ b/op/types/OpPaymentType.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpPaymentType, explainOpPaymentTypeOrUndefined, isOpPaymentType, isOpPaymentTypeOrUndefined, OpPaymentType, parseOpPaymentType, stringifyOpPaymentType } from "./OpPaymentType"; + +describe('OpPaymentType', () => { + const validTypes = [OpPaymentType.SEPA_CREDIT_TRANSFER, OpPaymentType.SCT_INST]; + const invalidType = 'UNKNOWN'; + + describe('isOpPaymentType', () => { + it('should return true for valid OpPaymentType', () => { + validTypes.forEach((type) => { + expect(isOpPaymentType(type)).toBe(true); + }); + }); + + it('should return false for invalid OpPaymentType', () => { + expect(isOpPaymentType(invalidType)).toBe(false); + }); + }); + + describe('explainOpPaymentType', () => { + it('should return expected explanation for valid and invalid types', () => { + validTypes.forEach((type) => { + // replace 'OK' with the actual expected explanation + expect(explainOpPaymentType(type)).toBe('OK'); + }); + + // replace 'OK' with the actual expected explanation for invalid case + expect(explainOpPaymentType(invalidType)).toBe('incorrect enum value "UNKNOWN" for OpPaymentType: Accepted values SEPA_CREDIT_TRANSFER, SCT_INST'); + }); + }); + + describe('stringifyOpPaymentType', () => { + it('should return correct string representation for valid OpPaymentType', () => { + validTypes.forEach((type) => { + expect(stringifyOpPaymentType(type)).toBe(type); + }); + }); + }); + + describe('parseOpPaymentType', () => { + it('should parse valid string representation to corresponding OpPaymentType', () => { + validTypes.forEach((type) => { + expect(parseOpPaymentType(type)).toBe(type); + }); + }); + + it('should return undefined for invalid string representation', () => { + expect(parseOpPaymentType(invalidType)).toBeUndefined(); + }); + }); + + describe('isOpPaymentTypeOrUndefined', () => { + it('should return true for valid OpPaymentType or undefined', () => { + validTypes.forEach((type) => { + expect(isOpPaymentTypeOrUndefined(type)).toBe(true); + }); + expect(isOpPaymentTypeOrUndefined(undefined)).toBe(true); + }); + + it('should return false for invalid OpPaymentType', () => { + expect(isOpPaymentTypeOrUndefined(invalidType)).toBe(false); + }); + }); + + describe('explainOpPaymentTypeOrUndefined', () => { + it('should return expected explanation for valid, invalid, and undefined types', () => { + + validTypes.forEach((type) => { + // replace 'OK' with the actual expected explanation + expect(explainOpPaymentTypeOrUndefined(type)).toBe('OK'); + }); + + // replace 'OK' with the actual expected explanation for invalid case + expect(explainOpPaymentTypeOrUndefined(invalidType)).toBe('not OpPaymentType or undefined'); + + // replace 'OK' with the actual expected explanation for undefined case + expect(explainOpPaymentTypeOrUndefined(undefined)).toBe('OK'); + }); + }); +}); diff --git a/op/types/OpPaymentType.ts b/op/types/OpPaymentType.ts new file mode 100644 index 0000000..5514f85 --- /dev/null +++ b/op/types/OpPaymentType.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +export enum OpPaymentType { + + /** + * SEPA_CREDIT_TRANSFER if the payment was processed as a SEPA payment. + */ + SEPA_CREDIT_TRANSFER = "SEPA_CREDIT_TRANSFER", + + /** + * SCT_INST if the payment was processed as a SEPA Instant payment. + */ + SCT_INST = "SCT_INST", + +} + +export function isOpPaymentType (value: unknown) : value is OpPaymentType { + return isEnum(OpPaymentType, value); +} + +export function explainOpPaymentType (value : unknown) : string { + return explainEnum("OpPaymentType", OpPaymentType, isOpPaymentType, value); +} + +export function stringifyOpPaymentType (value : OpPaymentType) : string { + return stringifyEnum(OpPaymentType, value); +} + +export function parseOpPaymentType (value: any) : OpPaymentType | undefined { + return parseEnum(OpPaymentType, value) as OpPaymentType | undefined; +} + +export function isOpPaymentTypeOrUndefined (value: unknown): value is OpPaymentType | undefined { + return isUndefined(value) || isOpPaymentType(value); +} + +export function explainOpPaymentTypeOrUndefined (value: unknown): string { + return isOpPaymentTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpPaymentType', 'undefined'])); +} diff --git a/op/types/OpRefundOriginalObject.test.ts b/op/types/OpRefundOriginalObject.test.ts new file mode 100644 index 0000000..ad69459 --- /dev/null +++ b/op/types/OpRefundOriginalObject.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { EXPLAIN_OK } from "../../types/explain"; +import { createOpRefundOriginalObject, explainOpRefundOriginalObject, explainOpRefundOriginalObjectOrUndefined, isOpRefundOriginalObject, isOpRefundOriginalObjectOrUndefined, OpRefundOriginalObject, parseOpRefundOriginalObject } from "./OpRefundOriginalObject"; + +describe('OpRefundOriginalObject', () => { + + const validObject: OpRefundOriginalObject = { + archiveId: "20190524593156999999999999999999999", + message: "Less money, fewer problems", + reference: "00000000000000482738", + amount: "12.35", + bookingDate: "2019-05-12", + debtorName: "Debbie Debtor" + }; + + describe('createOpRefundOriginalObject', () => { + it('creates a valid object', () => { + const result = createOpRefundOriginalObject( + "20190524593156999999999999999999999", + "Less money, fewer problems", + "00000000000000482738", + "12.35", + "2019-05-12", + "Debbie Debtor" + ); + expect(result).toEqual(validObject); + }); + }); + + describe('isOpRefundOriginalObject', () => { + it('returns true for valid object', () => { + expect(isOpRefundOriginalObject(validObject)).toBeTruthy(); + }); + + it('returns false for invalid object', () => { + const invalidObject = { ...validObject, archiveId: 12345 }; // made archiveId a number + expect(isOpRefundOriginalObject(invalidObject)).toBeFalsy(); + }); + }); + + describe('explainOpRefundOriginalObject', () => { + it('explains valid object as OK', () => { + expect(explainOpRefundOriginalObject(validObject)).toBe(EXPLAIN_OK); + }); + + it('provides explanations for invalid properties', () => { + const invalidObject = { ...validObject, amount: 100 }; // made amount a number + expect(explainOpRefundOriginalObject(invalidObject)).toContain('property "amount"'); + }); + }); + + describe('parseOpRefundOriginalObject', () => { + it('parses and returns valid object', () => { + expect(parseOpRefundOriginalObject(validObject)).toEqual(validObject); + }); + + it('returns undefined for invalid object', () => { + const invalidObject = { ...validObject, archiveId: 12345 }; // made archiveId a number + expect(parseOpRefundOriginalObject(invalidObject)).toBeUndefined(); + }); + }); + + describe('isOpRefundOriginalObjectOrUndefined', () => { + it('returns true for valid object', () => { + expect(isOpRefundOriginalObjectOrUndefined(validObject)).toBeTruthy(); + }); + + it('returns true for undefined', () => { + expect(isOpRefundOriginalObjectOrUndefined(undefined)).toBeTruthy(); + }); + + it('returns false for invalid object', () => { + const invalidObject = { ...validObject, archiveId: 12345 }; // made archiveId a number + expect(isOpRefundOriginalObjectOrUndefined(invalidObject)).toBeFalsy(); + }); + }); + + describe('explainOpRefundOriginalObjectOrUndefined', () => { + it('explains valid object as OK', () => { + expect(explainOpRefundOriginalObjectOrUndefined(validObject)).toBe(EXPLAIN_OK); + }); + + it('explains undefined as OK', () => { + expect(explainOpRefundOriginalObjectOrUndefined(undefined)).toBe(EXPLAIN_OK); + }); + + it('provides explanations for invalid properties', () => { + const invalidObject = { ...validObject, amount: 100 }; // made amount a number + expect(explainOpRefundOriginalObjectOrUndefined(invalidObject)).toContain('OpRefundOriginalObject'); + }); + }); + +}); diff --git a/op/types/OpRefundOriginalObject.ts b/op/types/OpRefundOriginalObject.ts new file mode 100644 index 0000000..16a13d8 --- /dev/null +++ b/op/types/OpRefundOriginalObject.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainString, explainStringOrNull, isString, isStringOrNull } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; + +/** + * @example + * { + * "archiveId": "20190524593156999999999999999999999", + * "message": "Less money, fewer problems", + * "reference": "00000000000000482738", + * "amount": "12.35", + * "bookingDate": "2019-05-12", + * "debtorName": "Debbie Debtor" + * } + */ +export interface OpRefundOriginalObject { + readonly archiveId: string; + readonly message: string; + readonly reference: string | null; + readonly amount: string; + readonly bookingDate: string; + readonly debtorName: string; +} + +export function createOpRefundOriginalObject ( + archiveId : string, + message : string, + reference : string | null, + amount : string, + bookingDate : string, + debtorName : string, +) : OpRefundOriginalObject { + return { + archiveId, + message, + reference, + amount, + bookingDate, + debtorName, + }; +} + +export function isOpRefundOriginalObject (value: unknown) : value is OpRefundOriginalObject { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'archiveId', + 'message', + 'reference', + 'amount', + 'bookingDate', + 'debtorName', + ]) + && isString(value?.archiveId) + && isString(value?.message) + && isStringOrNull(value?.reference) + && isString(value?.amount) + && isString(value?.bookingDate) + && isString(value?.debtorName) + ); +} + +export function explainOpRefundOriginalObject (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'archiveId', + 'message', + 'reference', + 'amount', + 'bookingDate', + 'debtorName', + ]) + , explainProperty("archiveId", explainString(value?.archiveId)) + , explainProperty("message", explainString(value?.message)) + , explainProperty("reference", explainStringOrNull(value?.reference)) + , explainProperty("amount", explainString(value?.amount)) + , explainProperty("bookingDate", explainString(value?.bookingDate)) + , explainProperty("debtorName", explainString(value?.debtorName)) + ] + ); +} + +export function parseOpRefundOriginalObject (value: unknown) : OpRefundOriginalObject | undefined { + if (isOpRefundOriginalObject(value)) return value; + return undefined; +} + +export function isOpRefundOriginalObjectOrUndefined (value: unknown): value is OpRefundOriginalObject | undefined { + return isUndefined(value) || isOpRefundOriginalObject(value); +} + +export function explainOpRefundOriginalObjectOrUndefined (value: unknown): string { + return isOpRefundOriginalObjectOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpRefundOriginalObject', 'undefined'])); +} diff --git a/op/types/OpRefundPaymentType.test.ts b/op/types/OpRefundPaymentType.test.ts new file mode 100644 index 0000000..923db27 --- /dev/null +++ b/op/types/OpRefundPaymentType.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpRefundPaymentType, explainOpRefundPaymentTypeOrUndefined, isOpRefundPaymentType, isOpRefundPaymentTypeOrUndefined, OpRefundPaymentType, parseOpRefundPaymentType, stringifyOpRefundPaymentType } from "./OpRefundPaymentType"; + +describe('OpRefundPaymentType functions', () => { + describe('isOpRefundPaymentType', () => { + it('should return true for valid payment types', () => { + expect(isOpRefundPaymentType(OpRefundPaymentType.SEPA_CREDIT_TRANSFER)).toBe(true); + expect(isOpRefundPaymentType(OpRefundPaymentType.SCT_INST)).toBe(true); + }); + + it('should return false for invalid payment types', () => { + expect(isOpRefundPaymentType("RANDOM_VALUE")).toBe(false); + expect(isOpRefundPaymentType(undefined)).toBe(false); + expect(isOpRefundPaymentType(null)).toBe(false); + }); + }); + + describe('explainOpRefundPaymentType', () => { + it('should explain valid payment types correctly', () => { + expect(explainOpRefundPaymentType(OpRefundPaymentType.SEPA_CREDIT_TRANSFER)).toBe('OK'); + }); + + it('should explain invalid payment types correctly', () => { + const invalidValue = "RANDOM_VALUE"; + expect(explainOpRefundPaymentType(invalidValue)).toBe(`incorrect enum value "${invalidValue}" for OpRefundPaymentType: Accepted values SEPA_CREDIT_TRANSFER, SCT_INST`); + }); + }); + + describe('stringifyOpRefundPaymentType', () => { + it('should stringify valid payment types correctly', () => { + expect(stringifyOpRefundPaymentType(OpRefundPaymentType.SEPA_CREDIT_TRANSFER)).toBe("SEPA_CREDIT_TRANSFER"); + }); + + it('should throw error for invalid payment types', () => { + expect(() => stringifyOpRefundPaymentType("RANDOM_VALUE" as any)).toThrow("Unsupported enum value: RANDOM_VALUE"); + }); + }); + + describe('parseOpRefundPaymentType', () => { + + it('should parse valid payment types correctly', () => { + expect(parseOpRefundPaymentType("SEPA_CREDIT_TRANSFER")).toBe(OpRefundPaymentType.SEPA_CREDIT_TRANSFER); + }); + + it('should return undefined for invalid payment types', () => { + expect(parseOpRefundPaymentType("RANDOM_VALUE")).toBeUndefined(); + }); + + it('should parse ignoring dashes', () => { + expect(parseOpRefundPaymentType("SEPA-CREDIT-TRANSFER")).toBe(OpRefundPaymentType.SEPA_CREDIT_TRANSFER); + }); + + }); + + describe('isOpRefundPaymentTypeOrUndefined', () => { + it('should return true for valid payment types or undefined', () => { + expect(isOpRefundPaymentTypeOrUndefined(OpRefundPaymentType.SEPA_CREDIT_TRANSFER)).toBe(true); + expect(isOpRefundPaymentTypeOrUndefined(undefined)).toBe(true); + }); + + it('should return false for other invalid types', () => { + expect(isOpRefundPaymentTypeOrUndefined("RANDOM_VALUE")).toBe(false); + expect(isOpRefundPaymentTypeOrUndefined(null)).toBe(false); + }); + }); + + describe('explainOpRefundPaymentTypeOrUndefined', () => { + + it('should explain valid payment type', () => { + expect(explainOpRefundPaymentTypeOrUndefined(OpRefundPaymentType.SEPA_CREDIT_TRANSFER)).toBe('OK'); + }); + + it('should explain valid payment type for undefined', () => { + expect(explainOpRefundPaymentTypeOrUndefined(undefined)).toBe('OK'); + }); + + it('should provide explanations for other invalid values', () => { + expect(explainOpRefundPaymentTypeOrUndefined("RANDOM_VALUE")).toBe("not OpRefundPaymentType or undefined"); + }); + + }); +}); diff --git a/op/types/OpRefundPaymentType.ts b/op/types/OpRefundPaymentType.ts new file mode 100644 index 0000000..7bd77a6 --- /dev/null +++ b/op/types/OpRefundPaymentType.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum OpRefundPaymentType { + SEPA_CREDIT_TRANSFER = "SEPA_CREDIT_TRANSFER", + SCT_INST = "SCT_INST", +} + +export function isOpRefundPaymentType (value: unknown) : value is OpRefundPaymentType { + return isEnum(OpRefundPaymentType, value); +} + +export function explainOpRefundPaymentType (value : unknown) : string { + return explainEnum("OpRefundPaymentType", OpRefundPaymentType, isOpRefundPaymentType, value); +} + +export function stringifyOpRefundPaymentType (value : OpRefundPaymentType) : string { + return stringifyEnum(OpRefundPaymentType, value); +} + +export function parseOpRefundPaymentType (value: any) : OpRefundPaymentType | undefined { + return parseEnum(OpRefundPaymentType, value, true, true) as OpRefundPaymentType | undefined; +} + +export function isOpRefundPaymentTypeOrUndefined (value: unknown): value is OpRefundPaymentType | undefined { + return isUndefined(value) || isOpRefundPaymentType(value); +} + +export function explainOpRefundPaymentTypeOrUndefined (value: unknown): string { + return isOpRefundPaymentTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpRefundPaymentType', 'undefined'])); +} diff --git a/op/types/OpRefundRefundObject.test.ts b/op/types/OpRefundRefundObject.test.ts new file mode 100644 index 0000000..f2f8f4d --- /dev/null +++ b/op/types/OpRefundRefundObject.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpRefundPaymentType } from "./OpRefundPaymentType"; +import { createOpRefundRefundObject, explainOpRefundRefundObject, explainOpRefundRefundObjectOrUndefined, isOpRefundRefundObject, isOpRefundRefundObjectOrUndefined, OpRefundRefundObject, parseOpRefundRefundObject } from "./OpRefundRefundObject"; +import { OpRefundStatus } from "./OpRefundStatus"; + +describe('OpRefundRefundObject functions', () => { + + const validObject: OpRefundRefundObject = { + amount: "3.45", + status: OpRefundStatus.PROCESSED, + message: "MAKSUN PALAUTUS. Maksun tiedot: 01.01.2020 Your own refund message", + currency: "EUR", + archiveId: "20190524593156999999", + debtorIban: "FI4550009420888888", + bookingDate: "2019-05-12", + paymentType: OpRefundPaymentType.SCT_INST, + creditorName: "Cedric Creditor", + transactionId: "A_50009420112088_2019-05-24_20190524593156999999_0", + transactionDate: "2019-05-11", + endToEndId: "544652-end2end" + }; + + describe('createOpRefundRefundObject', () => { + it('should create a valid object', () => { + const result = createOpRefundRefundObject( + "3.45", + OpRefundStatus.PROCESSED, + "MAKSUN PALAUTUS. Maksun tiedot: 01.01.2020 Your own refund message", + "EUR", + "20190524593156999999", + "FI4550009420888888", + "2019-05-12", + OpRefundPaymentType.SCT_INST, + "Cedric Creditor", + "A_50009420112088_2019-05-24_20190524593156999999_0", + "2019-05-11", + "544652-end2end" + ); + + expect(result).toEqual(validObject); + }); + }); + + describe('isOpRefundRefundObject', () => { + it('should return true for a valid object', () => { + expect(isOpRefundRefundObject(validObject)).toBe(true); + }); + + it('should return false for an invalid object', () => { + const invalidObject = { ...validObject, amount: 12345 }; // changed amount to a number + expect(isOpRefundRefundObject(invalidObject)).toBe(false); + }); + }); + + describe('explainOpRefundRefundObject', () => { + + it('should explain a valid object correctly', () => { + expect(explainOpRefundRefundObject(validObject)).toContain('OK'); // Assuming "OK" is part of a valid explanation. + }); + + it('should provide explanations for invalid properties', () => { + const invalidObject = { ...validObject, amount: 100 }; // changed amount to a number + expect(explainOpRefundRefundObject(invalidObject)).toContain('property "amount"'); + }); + + }); + + describe('parseOpRefundRefundObject', () => { + it('should parse and return a valid object', () => { + expect(parseOpRefundRefundObject(validObject)).toEqual(validObject); + }); + + it('should return undefined for an invalid object', () => { + const invalidObject = { ...validObject, archiveId: 12345 }; // changed archiveId to a number + expect(parseOpRefundRefundObject(invalidObject)).toBeUndefined(); + }); + }); + + describe('isOpRefundRefundObjectOrUndefined', () => { + it('should return true for a valid object', () => { + expect(isOpRefundRefundObjectOrUndefined(validObject)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isOpRefundRefundObjectOrUndefined(undefined)).toBe(true); + }); + + it('should return false for an invalid object', () => { + const invalidObject = { ...validObject, archiveId: 12345 }; // changed archiveId to a number + expect(isOpRefundRefundObjectOrUndefined(invalidObject)).toBe(false); + }); + }); + + describe('explainOpRefundRefundObjectOrUndefined', () => { + it('should explain a valid object correctly', () => { + expect(explainOpRefundRefundObjectOrUndefined(validObject)).toContain('OK'); // Assuming "OK" is part of a valid explanation. + }); + + it('should explain undefined correctly', () => { + expect(explainOpRefundRefundObjectOrUndefined(undefined)).toContain('OK'); + }); + + it('should provide explanations for invalid properties', () => { + const invalidObject = { ...validObject, amount: 100 }; // changed amount to a number + expect(explainOpRefundRefundObjectOrUndefined(invalidObject)).toContain('OpRefundRefundObject'); + }); + }); + +}); + diff --git a/op/types/OpRefundRefundObject.ts b/op/types/OpRefundRefundObject.ts new file mode 100644 index 0000000..8a7c833 --- /dev/null +++ b/op/types/OpRefundRefundObject.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainString, explainStringOrNull, isString, isStringOrNull } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; +import { isOpPaymentType } from "./OpPaymentType"; +import { explainOpRefundPaymentType, OpRefundPaymentType } from "./OpRefundPaymentType"; +import { explainOpRefundStatus, isOpRefundStatus, OpRefundStatus } from "./OpRefundStatus"; + +/** + * @example + * { + * "amount": "3.45", + * "status": "PROCESSED", + * "message": "MAKSUN PALAUTUS. Maksun tiedot: 01.01.2020 Your own refund message", + * "currency": "EUR", + * "archiveId": "20190524593156999999", + * "debtorIban": "FI4550009420888888", + * "bookingDate": "2019-05-12", + * "paymentType": "SCT_INST", + * "creditorName": "Cedric Creditor", + * "transactionId": "A_50009420112088_2019-05-24_20190524593156999999_0", + * "transactionDate": "2019-05-11", + * "endToEndId": "544652-end2end" + * } + */ +export interface OpRefundRefundObject { + readonly amount: string; + readonly status: OpRefundStatus; + readonly message: string; + readonly currency: string; + readonly archiveId: string; + readonly debtorIban: string; + readonly bookingDate: string; + readonly paymentType: OpRefundPaymentType; + readonly creditorName: string; + readonly transactionId: string; + readonly transactionDate: string; + readonly endToEndId: string | null; +} + +export function createOpRefundRefundObject ( + amount : string, + status : OpRefundStatus, + message : string, + currency : string, + archiveId : string, + debtorIban : string, + bookingDate : string, + paymentType : OpRefundPaymentType, + creditorName : string, + transactionId : string, + transactionDate : string, + endToEndId : string | null, +) : OpRefundRefundObject { + return { + amount, + status, + message, + currency, + archiveId, + debtorIban, + bookingDate, + paymentType, + creditorName, + transactionId, + transactionDate, + endToEndId, + }; +} + +export function isOpRefundRefundObject (value: unknown) : value is OpRefundRefundObject { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'amount', + 'status', + 'message', + 'currency', + 'archiveId', + 'debtorIban', + 'bookingDate', + 'paymentType', + 'creditorName', + 'transactionId', + 'transactionDate', + 'endToEndId', + ]) + && isString(value?.amount) + && isOpRefundStatus(value?.status) + && isString(value?.message) + && isString(value?.currency) + && isString(value?.archiveId) + && isString(value?.debtorIban) + && isString(value?.bookingDate) + && isOpPaymentType(value?.paymentType) + && isString(value?.creditorName) + && isString(value?.transactionId) + && isString(value?.transactionDate) + && isStringOrNull(value?.endToEndId) + ); +} + +export function explainOpRefundRefundObject (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'amount', + 'status', + 'message', + 'currency', + 'archiveId', + 'debtorIban', + 'bookingDate', + 'paymentType', + 'creditorName', + 'transactionId', + 'transactionDate', + 'endToEndId', + ]) + , explainProperty("amount", explainString(value?.amount)) + , explainProperty("status", explainOpRefundStatus(value?.status)) + , explainProperty("message", explainString(value?.message)) + , explainProperty("currency", explainString(value?.currency)) + , explainProperty("archiveId", explainString(value?.archiveId)) + , explainProperty("debtorIban", explainString(value?.debtorIban)) + , explainProperty("bookingDate", explainString(value?.bookingDate)) + , explainProperty("paymentType", explainOpRefundPaymentType(value?.paymentType)) + , explainProperty("creditorName", explainString(value?.creditorName)) + , explainProperty("transactionId", explainString(value?.transactionId)) + , explainProperty("transactionDate", explainString(value?.transactionDate)) + , explainProperty("endToEndId", explainStringOrNull(value?.endToEndId)) + ] + ); +} + +export function parseOpRefundRefundObject (value: unknown) : OpRefundRefundObject | undefined { + if (isOpRefundRefundObject(value)) return value; + return undefined; +} + +export function isOpRefundRefundObjectOrUndefined (value: unknown): value is OpRefundRefundObject | undefined { + return isUndefined(value) || isOpRefundRefundObject(value); +} + +export function explainOpRefundRefundObjectOrUndefined (value: unknown): string { + return isOpRefundRefundObjectOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpRefundRefundObject', 'undefined'])); +} diff --git a/op/types/OpRefundStatus.test.ts b/op/types/OpRefundStatus.test.ts new file mode 100644 index 0000000..b910505 --- /dev/null +++ b/op/types/OpRefundStatus.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpRefundStatus, explainOpRefundStatusOrUndefined, isOpRefundStatus, isOpRefundStatusOrUndefined, OpRefundStatus, parseOpRefundStatus, stringifyOpRefundStatus } from "./OpRefundStatus"; + +describe('OpRefundStatus functions', () => { + describe('isOpRefundStatus', () => { + it('should return true for valid statuses', () => { + expect(isOpRefundStatus(OpRefundStatus.PROCESSING)).toBe(true); + expect(isOpRefundStatus(OpRefundStatus.PROCESSED)).toBe(true); + }); + + it('should return false for invalid statuses', () => { + expect(isOpRefundStatus("RANDOM_VALUE")).toBe(false); + expect(isOpRefundStatus(undefined)).toBe(false); + expect(isOpRefundStatus(null)).toBe(false); + }); + }); + + describe('explainOpRefundStatus', () => { + it('should explain valid statuses correctly', () => { + expect(explainOpRefundStatus(OpRefundStatus.PROCESSING)).toBe('OK'); + }); + + it('should explain invalid statuses correctly', () => { + const invalidValue = "RANDOM_VALUE"; + expect(explainOpRefundStatus(invalidValue)).toBe(`incorrect enum value "${invalidValue}" for RefundStatus: Accepted values PROCESSING, PROCESSED`); + }); + }); + + describe('stringifyOpRefundStatus', () => { + it('should stringify valid statuses correctly', () => { + expect(stringifyOpRefundStatus(OpRefundStatus.PROCESSING)).toBe("PROCESSING"); + }); + + it('should throw error for invalid statuses', () => { + expect(() => stringifyOpRefundStatus("RANDOM_VALUE" as any)).toThrow("Unsupported enum value: RANDOM_VALUE"); + }); + }); + + describe('parseOpRefundStatus', () => { + it('should parse valid statuses correctly', () => { + expect(parseOpRefundStatus("PROCESSING")).toBe(OpRefundStatus.PROCESSING); + }); + + it('should return undefined for invalid statuses', () => { + expect(parseOpRefundStatus("RANDOM_VALUE")).toBeUndefined(); + }); + + it('should parse ignoring spaces and dashes', () => { + expect(parseOpRefundStatus("PRO CES SING")).toBe(OpRefundStatus.PROCESSING); + expect(parseOpRefundStatus("PRO-CES-SING")).toBe(OpRefundStatus.PROCESSING); + }); + }); + + describe('isOpRefundStatusOrUndefined', () => { + it('should return true for valid statuses or undefined', () => { + expect(isOpRefundStatusOrUndefined(OpRefundStatus.PROCESSING)).toBe(true); + expect(isOpRefundStatusOrUndefined(undefined)).toBe(true); + }); + + it('should return false for other invalid types', () => { + expect(isOpRefundStatusOrUndefined("RANDOM_VALUE")).toBe(false); + expect(isOpRefundStatusOrUndefined(null)).toBe(false); + }); + }); + + describe('explainOpRefundStatusOrUndefined', () => { + it('should explain valid statuses or undefined correctly', () => { + expect(explainOpRefundStatusOrUndefined(OpRefundStatus.PROCESSING)).toBe('OK'); + expect(explainOpRefundStatusOrUndefined(undefined)).toBe('OK'); + }); + + it('should provide explanations for other invalid values', () => { + expect(explainOpRefundStatusOrUndefined("RANDOM_VALUE")).toBe("not RefundStatus or undefined"); + }); + }); + +}); diff --git a/op/types/OpRefundStatus.ts b/op/types/OpRefundStatus.ts new file mode 100644 index 0000000..b0b782c --- /dev/null +++ b/op/types/OpRefundStatus.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +export enum OpRefundStatus { + PROCESSING = "PROCESSING", + PROCESSED = "PROCESSED", +} + +export function isOpRefundStatus (value: unknown) : value is OpRefundStatus { + return isEnum(OpRefundStatus, value); +} + +export function explainOpRefundStatus (value : unknown) : string { + return explainEnum("RefundStatus", OpRefundStatus, isOpRefundStatus, value); +} + +export function stringifyOpRefundStatus (value : OpRefundStatus) : string { + return stringifyEnum(OpRefundStatus, value); +} + +export function parseOpRefundStatus (value: any) : OpRefundStatus | undefined { + return parseEnum(OpRefundStatus, value, true, true) as OpRefundStatus | undefined; +} + +export function isOpRefundStatusOrUndefined (value: unknown): value is OpRefundStatus | undefined { + return isUndefined(value) || isOpRefundStatus(value); +} + +export function explainOpRefundStatusOrUndefined (value: unknown): string { + return isOpRefundStatusOrUndefined(value) ? explainOk() : explainNot(explainOr(['RefundStatus', 'undefined'])); +} diff --git a/op/types/OpUltimateCreditor.test.ts b/op/types/OpUltimateCreditor.test.ts new file mode 100644 index 0000000..f1d3437 --- /dev/null +++ b/op/types/OpUltimateCreditor.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + + +import { createOpUltimateCreditor, explainOpUltimateCreditor, explainOpUltimateCreditorOrUndefined, isOpUltimateCreditor, isOpUltimateCreditorOrUndefined, OpUltimateCreditor, parseOpUltimateCreditor } from "./OpUltimateCreditor"; +import { createOpPaymentIdentification, OpPaymentIdentification } from "./OpPaymentIdentification"; +import { createOpAddress, OpAddress } from "./OpPaymentAddress"; +import { OpIdentificationScheme } from "./OpIdentificationScheme"; +import { CountryCode } from "../../types/CountryCode"; + +describe('OpUltimateCreditor functions', () => { + let testObject: OpUltimateCreditor; + let wrongObject: any; + let identification: OpPaymentIdentification; + let address: OpAddress; + + beforeEach(() => { + identification = createOpPaymentIdentification( + "123456789", + OpIdentificationScheme.BIC, + "TestIssuer" + ); + + address = createOpAddress( + CountryCode.FI, + [ + "TestStreet 12345", + "TestCity" + ] + ); + + testObject = createOpUltimateCreditor( + "TestName", + identification, + address + ); + + wrongObject = { + name: 12345, // Should be a string + identification: {id: "123456789", schemeName: "WrongScheme", issuer: "TestIssuer"}, // Wrong schemeName + address: "TestAddress" // Should be an OpAddress object + }; + }); + + describe('createOpUltimateCreditor', () => { + it('should correctly create an OpUltimateCreditor object', () => { + expect(testObject.name).toBe("TestName"); + expect(testObject.identification).toEqual(identification); + expect(testObject.address).toEqual(address); + }); + }); + + describe('isOpUltimateCreditor', () => { + it('should correctly identify OpUltimateCreditor objects', () => { + expect(isOpUltimateCreditor(testObject)).toBe(true); + expect(isOpUltimateCreditor(wrongObject)).toBe(false); + }); + }); + + describe('explainOpUltimateCreditor', () => { + it('should provide correct explanation for OpUltimateCreditor objects', () => { + expect(explainOpUltimateCreditor(testObject)).toEqual('OK'); + const result = explainOpUltimateCreditor(wrongObject); + expect(result).toContain('property "name" not string'); + expect(result).toContain('property "identification"'); + expect(result).toContain('property "schemeName" incorrect enum value "WrongScheme" for OpIdentificationScheme: Accepted values BIC, COID, TXID, SOSE, UNSTRUCTURED_ORG, UNSTRUCTURED_PERSON'); + expect(result).toContain('property "address" not regular object, Value had extra properties: ,'); + expect(result).toContain('property "country" incorrect enum value "undefined" for CountryCode: Accepted values AF, AX, AL, DZ, AS, AD, AO, AI, AQ, AG, AR, AM, AW, AU, AT, AZ, BS, BH, BD, BB, BY, BE, BZ, BJ, BM, BT, BO, BQ, BA, BW, BV, BR, IO, BN, BG, BF, BI, CV, KH, CM, CA, KY, CF, TD, CL, CN, CX, CC, CO, KM, CD, CG, CK, CR, CI, HR, CU, CW, CY, CZ, DK, DJ, DM, DO, EC, EG, SV, GQ, ER, EE, SZ, ET, FK, FO, FJ, FI, FR, GF, PF, TF, GA, GM, GE, DE, GH, GI, GR, GL, GD, GP, GU, GT, GG, GN, GW, GY, HT, HM, VA, HN, HK, HU, IS, IN, ID, IR, IQ, IE, IM, IL, IT, JM, JP, JE, JO, KZ, KE, KI, KP, KR, KW, KG, LA, LV, LB, LS, LR, LY, LI, LT, LU, MO, MK, MG, MW, MY, MV, ML, MT, MH, MQ, MR, MU, YT, MX, FM, MD, MC, MN, ME, MS, MA, MZ, MM, NA, NR, NP, NL, NC, NZ, NI, NE, NG, NU, NF, MP, NO, OM, PK, PW, PS, PA, PG, PY, PE, PH, PN, PL, PT, PR, QA, RE, RO, RU, RW, BL, SH, KN, LC, MF, PM, VC, WS, SM, ST, SA, SN, RS, SC, SL, SG, SX, SK, SI, SB, SO, ZA, GS, SS, ES, LK, SD, SR, SJ, SE, CH, SY, TW, TJ, TZ, TH, TL, TG, TK, TO, TT, TN, TR, TM, TC, TV, UG, UA, AE, GB, UM, US, UY, UZ, VU, VE, VN, VG, VI, WF, EH, YE, ZM, ZW'); + expect(result).toContain('property "addressLine" not string[]'); + }); + }); + + describe('parseOpUltimateCreditor', () => { + it('should correctly parse valid objects to OpUltimateCreditor', () => { + expect(parseOpUltimateCreditor(testObject)).toEqual(testObject); + expect(parseOpUltimateCreditor(wrongObject)).toBeUndefined(); + }); + }); + + describe('isOpUltimateCreditorOrUndefined', () => { + it('should correctly identify OpUltimateCreditor or undefined', () => { + expect(isOpUltimateCreditorOrUndefined(testObject)).toBe(true); + expect(isOpUltimateCreditorOrUndefined(undefined)).toBe(true); + expect(isOpUltimateCreditorOrUndefined(wrongObject)).toBe(false); + }); + }); + + describe('explainOpUltimateCreditorOrUndefined', () => { + it('should provide correct explanation for OpUltimateCreditor or undefined', () => { + expect(explainOpUltimateCreditorOrUndefined(testObject)).toEqual('OK'); + expect(explainOpUltimateCreditorOrUndefined(undefined)).toEqual('OK'); + expect(explainOpUltimateCreditorOrUndefined(wrongObject)).toEqual('not OpUltimateCreditor or undefined'); + }); + }); + +}); diff --git a/op/types/OpUltimateCreditor.ts b/op/types/OpUltimateCreditor.ts new file mode 100644 index 0000000..7f9fb95 --- /dev/null +++ b/op/types/OpUltimateCreditor.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, isString } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainOpPaymentIdentification, isOpPaymentIdentification, OpPaymentIdentification } from "./OpPaymentIdentification"; +import { explainOpAddress, isOpAddress, OpAddress } from "./OpPaymentAddress"; + +export interface OpUltimateCreditor { + readonly name: string; + readonly identification: OpPaymentIdentification; + readonly address: OpAddress; +} + +export function createOpUltimateCreditor ( + name : string, + identification : OpPaymentIdentification, + address : OpAddress, +) : OpUltimateCreditor { + return { + name, + identification, + address, + }; +} + +export function isOpUltimateCreditor (value: unknown) : value is OpUltimateCreditor { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'name', + 'identification', + 'address', + ]) + && isString(value?.name) + && isOpPaymentIdentification(value?.identification) + && isOpAddress(value?.address) + ); +} + +export function explainOpUltimateCreditor (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'name', + 'identification', + 'address', + ]) + , explainProperty("name", explainString(value?.name)) + , explainProperty("identification", explainOpPaymentIdentification(value?.identification)) + , explainProperty("address", explainOpAddress(value?.address)) + ] + ); +} + +export function parseOpUltimateCreditor (value: unknown) : OpUltimateCreditor | undefined { + if (isOpUltimateCreditor(value)) return value; + return undefined; +} + +export function isOpUltimateCreditorOrUndefined (value: unknown): value is OpUltimateCreditor | undefined { + return isUndefined(value) || isOpUltimateCreditor(value); +} + +export function explainOpUltimateCreditorOrUndefined (value: unknown): string { + return isOpUltimateCreditorOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpUltimateCreditor', 'undefined'])); +} diff --git a/op/types/OpUltimateDebtor.test.ts b/op/types/OpUltimateDebtor.test.ts new file mode 100644 index 0000000..dd98fcc --- /dev/null +++ b/op/types/OpUltimateDebtor.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + + +import { createOpUltimateDebtor, explainOpUltimateDebtor, explainOpUltimateDebtorOrUndefined, isOpUltimateDebtor, isOpUltimateDebtorOrUndefined, OpUltimateDebtor, parseOpUltimateDebtor } from "./OpUltimateDebtor"; +import { createOpPaymentIdentification, OpPaymentIdentification } from "./OpPaymentIdentification"; +import { createOpAddress, OpAddress } from "./OpPaymentAddress"; +import { OpIdentificationScheme } from "./OpIdentificationScheme"; +import { CountryCode } from "../../types/CountryCode"; + +describe('OpUltimateDebtor functions', () => { + let testObject: OpUltimateDebtor; + let wrongObject: any; + let identification: OpPaymentIdentification; + let address: OpAddress; + + beforeEach(() => { + identification = createOpPaymentIdentification( + "123456789", + OpIdentificationScheme.BIC, + "TestIssuer" + ); + + address = createOpAddress( + CountryCode.FI, + [ + "TestStreet 12345", + "TestCity" + ] + ); + + testObject = createOpUltimateDebtor( + "TestName", + identification, + address + ); + + wrongObject = { + name: 12345, // Should be a string + identification: {id: "123456789", schemeName: "WrongScheme", issuer: "TestIssuer"}, // Wrong schemeName + address: "TestAddress" // Should be an OpAddress object + }; + }); + + describe('createOpUltimateDebtor', () => { + it('should correctly create an OpUltimateDebtor object', () => { + expect(testObject.name).toBe("TestName"); + expect(testObject.identification).toEqual(identification); + expect(testObject.address).toEqual(address); + }); + }); + + describe('isOpUltimateDebtor', () => { + it('should correctly identify OpUltimateDebtor objects', () => { + expect(isOpUltimateDebtor(testObject)).toBe(true); + expect(isOpUltimateDebtor(wrongObject)).toBe(false); + }); + }); + + describe('explainOpUltimateDebtor', () => { + it('should provide correct explanation for OpUltimateDebtor objects', () => { + expect(explainOpUltimateDebtor(testObject)).toEqual('OK'); + + const result = explainOpUltimateDebtor(wrongObject); + + expect(result).toContain('property "name" not string'); + expect(result).toContain('property "identification"'); + expect(result).toContain('property "schemeName" incorrect enum value "WrongScheme" for OpIdentificationScheme: Accepted values BIC, COID, TXID, SOSE, UNSTRUCTURED_ORG, UNSTRUCTURED_PERSON'); + expect(result).toContain('property "address" not regular object, Value had extra properties: ,'); + expect(result).toContain('property "country" incorrect enum value "undefined" for CountryCode: Accepted values AF, AX, AL, DZ, AS, AD, AO, AI, AQ, AG, AR, AM, AW, AU, AT, AZ, BS, BH, BD, BB, BY, BE, BZ, BJ, BM, BT, BO, BQ, BA, BW, BV, BR, IO, BN, BG, BF, BI, CV, KH, CM, CA, KY, CF, TD, CL, CN, CX, CC, CO, KM, CD, CG, CK, CR, CI, HR, CU, CW, CY, CZ, DK, DJ, DM, DO, EC, EG, SV, GQ, ER, EE, SZ, ET, FK, FO, FJ, FI, FR, GF, PF, TF, GA, GM, GE, DE, GH, GI, GR, GL, GD,' + + ' GP,' + + ' GU, GT, GG, GN, GW, GY, HT, HM, VA, HN, HK, HU, IS, IN, ID, IR, IQ, IE, IM, IL, IT, JM, JP, JE, JO, KZ, KE, KI, KP, KR, KW, KG, LA, LV, LB, LS, LR, LY, LI, LT, LU, MO, MK, MG, MW, MY, MV, ML, MT, MH, MQ, MR, MU, YT, MX, FM, MD, MC, MN, ME, MS, MA, MZ, MM, NA, NR, NP, NL, NC, NZ, NI, NE, NG, NU, NF, MP, NO, OM, PK, PW, PS, PA, PG, PY, PE, PH, PN, PL, PT, PR, QA, RE, RO, RU, RW, BL, SH, KN, LC, MF, PM, VC, WS, SM, ST, SA, SN, RS, SC, SL, SG, SX, SK, SI, SB, SO, ZA, GS, SS, ES, LK, SD, SR, SJ, SE, CH, SY, TW, TJ, TZ, TH, TL, TG, TK, TO, TT, TN, TR, TM, TC, TV, UG, UA, AE, GB, UM, US, UY, UZ, VU, VE, VN, VG, VI, WF, EH, YE, ZM, ZW'); + expect(result).toContain('property "addressLine" not string[]'); + }); + }); + + describe('parseOpUltimateDebtor', () => { + it('should correctly parse valid objects to OpUltimateDebtor', () => { + expect(parseOpUltimateDebtor(testObject)).toEqual(testObject); + expect(parseOpUltimateDebtor(wrongObject)).toBeUndefined(); + }); + }); + + describe('isOpUltimateDebtorOrUndefined', () => { + it('should correctly identify OpUltimateDebtor or undefined', () => { + expect(isOpUltimateDebtorOrUndefined(testObject)).toBe(true); + expect(isOpUltimateDebtorOrUndefined(undefined)).toBe(true); + expect(isOpUltimateDebtorOrUndefined(wrongObject)).toBe(false); + }); + }); + + describe('explainOpUltimateDebtorOrUndefined', () => { + it('should provide correct explanation for OpUltimateDebtor or undefined', () => { + expect(explainOpUltimateDebtorOrUndefined(testObject)).toEqual('OK'); + expect(explainOpUltimateDebtorOrUndefined(undefined)).toEqual('OK'); + expect(explainOpUltimateDebtorOrUndefined(wrongObject)).toEqual('not OpUltimateDebtor or undefined'); + }); + }); + +}); diff --git a/op/types/OpUltimateDebtor.ts b/op/types/OpUltimateDebtor.ts new file mode 100644 index 0000000..97a3895 --- /dev/null +++ b/op/types/OpUltimateDebtor.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, isString } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainOpPaymentIdentification, isOpPaymentIdentification, OpPaymentIdentification } from "./OpPaymentIdentification"; +import { explainOpAddress, isOpAddress, OpAddress } from "./OpPaymentAddress"; + +export interface OpUltimateDebtor { + readonly name: string; + readonly identification: OpPaymentIdentification; + readonly address: OpAddress; +} + +export function createOpUltimateDebtor ( + name : string, + identification : OpPaymentIdentification, + address : OpAddress, +) : OpUltimateDebtor { + return { + name, + identification, + address, + }; +} + +export function isOpUltimateDebtor (value: unknown) : value is OpUltimateDebtor { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'name', + 'identification', + 'address', + ]) + && isString(value?.name) + && isOpPaymentIdentification(value?.identification) + && isOpAddress(value?.address) + ); +} + +export function explainOpUltimateDebtor (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'name', + 'identification', + 'address', + ]) + , explainProperty("name", explainString(value?.name)) + , explainProperty("identification", explainOpPaymentIdentification(value?.identification)) + , explainProperty("address", explainOpAddress(value?.address)) + ] + ); +} + +export function parseOpUltimateDebtor (value: unknown) : OpUltimateDebtor | undefined { + if (isOpUltimateDebtor(value)) return value; + return undefined; +} + +export function isOpUltimateDebtorOrUndefined (value: unknown): value is OpUltimateDebtor | undefined { + return isUndefined(value) || isOpUltimateDebtor(value); +} + +export function explainOpUltimateDebtorOrUndefined (value: unknown): string { + return isOpUltimateDebtorOrUndefined(value) ? explainOk() : explainNot(explainOr(['OpUltimateDebtor', 'undefined'])); +} diff --git a/openai/HttpOpenAiClient.system.test.ts b/openai/HttpOpenAiClient.system.test.ts new file mode 100644 index 0000000..efd7fd4 --- /dev/null +++ b/openai/HttpOpenAiClient.system.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +// NOTE! +// +// These tests calls the real OpenAI API if the `OPENAI_API_KEY` is provided. +// +// Make sure your system testing environment runs `HgNode.initialize()` +// + +import { HttpOpenAiClient } from './HttpOpenAiClient'; +import { LogLevel } from "../types/LogLevel"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { HttpService } from "../HttpService"; +import { OpenAiModel } from "./types/OpenAiModel"; +import { isNumber } from "../types/Number"; +import { isArray } from "../types/Array"; + +const OPENAI_API_KEY = process?.env?.OPENAI_API_KEY ?? ''; +const OPENAI_BASE_URL = process?.env?.OPENAI_BASE_URL ?? 'https://api.openai.com'; + +RequestClientImpl.setLogLevel(LogLevel.NONE); +HttpService.setLogLevel(LogLevel.NONE); + +(OPENAI_API_KEY ? describe : describe.skip)('system', () => { + + describe('HttpOpenAiClient', () => { + + let client: HttpOpenAiClient; + let prevDefaultUrl: string; + + beforeEach(() => { + prevDefaultUrl = HttpOpenAiClient.getDefaultUrl(); + client = HttpOpenAiClient.create(OPENAI_API_KEY, OPENAI_BASE_URL); + }); + + afterEach(() => { + HttpOpenAiClient.setDefaultUrl(prevDefaultUrl); + }); + + describe('create', () => { + + it('method should return a new instance of OpenAiClient', () => { + expect(client).toBeInstanceOf(HttpOpenAiClient); + }); + + }); + + describe('getUrl', () => { + + it('returns the correct URL', () => { + expect(client.getUrl()).toBe('https://api.openai.com'); + }); + + }); + + describe('setDefaultUrl', () => { + it('should set the default URL for the OpenAI API', () => { + const newDefaultUrl = 'https://new-api.openai.com'; + HttpOpenAiClient.setDefaultUrl(newDefaultUrl); + expect(HttpOpenAiClient.getDefaultUrl()).toBe(newDefaultUrl); + }); + }); + + describe('getCompletion', () => { + + it('returns a promise that resolves to a valid response', async () => { + const response = await client.getCompletion('Hello, world!'); + // console.log(`response: `, response); + expect(response).toBeDefined(); + expect(response).toHaveProperty('choices'); + expect(response.choices).toBeInstanceOf(Array); + }); + + }); + + describe("getEdit", () => { + it("should make a request to the OpenAI API's text edit endpoint and return the response", async () => { + + // Set up test data + const input = "What day of the wek is it?"; + const instruction = "Fix the spelling mistakes"; + const model: OpenAiModel = OpenAiModel.DAVINCI_EDIT_TEXT; + + // Call the getEdit method + const response = await client.getEdit(instruction, input, model); + + // console.log(`response = ${JSON.stringify(response)}`); + + /** + * { + * "object": "edit", + * "created": 1589478378, + * "choices": [ + * { + * "text": "What day of the week is it?", + * "index": 0, + * } + * ], + * "usage": { + * "prompt_tokens": 25, + * "completion_tokens": 32, + * "total_tokens": 57 + * } + * } + */ + + // Verify that the response is correct + expect(response?.object).toStrictEqual("edit"); + expect(isNumber( response?.created )).toBe(true); + expect(isArray(response?.choices)).toBe(true); + expect((response?.choices[0] as any)?.text).toMatch(/What day of the week is it?/); + expect(isNumber(response?.usage?.prompt_tokens)).toBe(true); + expect(isNumber(response?.usage?.completion_tokens)).toBe(true); + expect(isNumber(response?.usage?.total_tokens)).toBe(true); + + }); + }); + + }); + +}); diff --git a/openai/HttpOpenAiClient.test.ts b/openai/HttpOpenAiClient.test.ts new file mode 100644 index 0000000..ec4e70f --- /dev/null +++ b/openai/HttpOpenAiClient.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from "@jest/globals"; +import { HttpOpenAiClient, OPENAI_API_POST_COMPLETIONS_PATH, OPENAI_API_POST_EDITS_PATH } from './HttpOpenAiClient'; +import { OpenAiModel } from "./types/OpenAiModel"; +import { HttpService } from "../HttpService"; +import { createOpenAiCompletionResponseDTO } from "./dto/OpenAiCompletionResponseDTO"; +import { createOpenAiCompletionResponseChoice } from "./dto/OpenAiCompletionResponseChoice"; +import { ReadonlyJsonAny } from "../Json"; +import { createOpenAiCompletionResponseUsage } from "./dto/OpenAiCompletionResponseUsage"; +import { createOpenAiEditRequestDTO } from "./dto/OpenAiEditRequestDTO"; +import { LogLevel } from "../types/LogLevel"; + +describe('HttpOpenAiClient', () => { + + const apiKey = '1234'; + const baseUrl = 'https://api.openai.com'; + let client: HttpOpenAiClient; + let prevDefaultUrl: string; + let postJsonSpy: jest.SpiedFunction<(...args: any) => any>; + + beforeAll(() => { + + HttpOpenAiClient.setLogLevel(LogLevel.NONE); + + postJsonSpy = jest.spyOn(HttpService, 'postJson').mockResolvedValue( + createOpenAiCompletionResponseDTO( + "response-id", + "text", + 12345678, + OpenAiModel.DAVINCI, + [ + createOpenAiCompletionResponseChoice('hello world', 0, null, 'length') + ], + createOpenAiCompletionResponseUsage( + 1, + 2, + 3 + ) + ) as unknown as ReadonlyJsonAny + ); + }); + + afterAll(() => { + postJsonSpy.mockRestore(); + }); + + beforeEach(() => { + prevDefaultUrl = HttpOpenAiClient.getDefaultUrl(); + client = HttpOpenAiClient.create(apiKey, baseUrl); + }); + + afterEach(() => { + HttpOpenAiClient.setDefaultUrl(prevDefaultUrl); + postJsonSpy.mockClear(); + }); + + describe('create', () => { + it('method should return a new instance of OpenAiClient', () => { + expect(client).toBeInstanceOf(HttpOpenAiClient); + }); + }); + + describe('getUrl', () => { + it('returns the correct URL', () => { + expect(client.getUrl()).toBe('https://api.openai.com'); + }); + }); + + describe('setDefaultUrl', () => { + it('should set the default URL for the OpenAI API', () => { + const newDefaultUrl = 'https://new-api.openai.com'; + HttpOpenAiClient.setDefaultUrl(newDefaultUrl); + expect(HttpOpenAiClient.getDefaultUrl()).toBe(newDefaultUrl); + }); + }); + + describe('getCompletion', () => { + it('returns a promise that resolves to a valid response', async () => { + + const response = await client.getCompletion("What's the weather like today?"); + + expect(postJsonSpy).toHaveBeenCalledWith( + `${baseUrl}${OPENAI_API_POST_COMPLETIONS_PATH}`, + { + "model": "text-davinci-003", + "prompt": "What's the weather like today?" + }, + { + "Authorization": "Bearer "+apiKey + } + ); + expect(response).toBeDefined(); + expect(response).toHaveProperty('choices'); + expect(response.choices).toBeInstanceOf(Array); + }); + }); + + describe("getEdit", () => { + it("should make a request to the OpenAI API's text edit endpoint and return the response", async () => { + // Set up test data + const input = "This is some text."; + const instruction = "Change 'some' to 'an'."; + const model: OpenAiModel = OpenAiModel.DAVINCI; + const n = 1; + const temperature = 0.5; + const topP = 1; + + // Mock the response from the OpenAI API + const mockResponse: ReadonlyJsonAny = { + "object": "edit", + "created": 1589478378, + "choices": [ + { + "text": "What day of the week is it?", + "index": 0, + } + ], + "usage": { + "prompt_tokens": 25, + "completion_tokens": 32, + "total_tokens": 57 + } + }; + + // Set up the mock for HttpService.post + jest.spyOn(HttpService, "postJson").mockImplementation( + (_url: string, _body?: ReadonlyJsonAny | undefined, _headers?: ReadonlyJsonAny | undefined) : Promise => { + return Promise.resolve(mockResponse); + } + ); + + // Call the getEdit method + const response = await client.getEdit(instruction, input, model, n, temperature, topP); + + // Verify that the response is correct + expect(response).toEqual(mockResponse); + + // Verify that HttpService.post was called with the correct arguments + expect(HttpService.postJson).toHaveBeenCalledWith( + `${client.getUrl()}${OPENAI_API_POST_EDITS_PATH}`, + createOpenAiEditRequestDTO( + instruction, + input, + model, + n, + temperature, + topP + ), + { + "Authorization": `Bearer ${apiKey}` + } + ); + }); + }); + + +}); diff --git a/openai/HttpOpenAiClient.ts b/openai/HttpOpenAiClient.ts new file mode 100644 index 0000000..ee61676 --- /dev/null +++ b/openai/HttpOpenAiClient.ts @@ -0,0 +1,365 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +/** + * @module + * @overview + * + * A zero dep TypeScript client library for interacting with the OpenAI API. + * + * This client provides methods for calling the OpenAI API's endpoints for text completion, + * language generation, and other tasks. + * + * To create an `OpenAiClient`, use the `create` method. + * + * The `OpenAiClient`'s methods return Promises that resolve to the JSON response + * returned by the OpenAI API. If the OpenAI API returns an error, the Promise will be rejected + * with an instance of an `HttpError` that includes the error message and HTTP status code. + * + * ```typescript + * const openai = OpenAiClient.create(); + * + * openai.getCompletion(...) + * .then((response) => { + * console.log(response); + * }) + * .catch((error) => { + * console.error(error); + * }); + * ``` + * + * @see https://beta.openai.com/docs/quickstart + */ + +import { LogLevel } from "../types/LogLevel"; +import { LogService } from "../LogService"; +import { HttpService } from "../HttpService"; +import { createOpenAiCompletionRequestDTO } from "./dto/OpenAiCompletionRequestDTO"; +import { explainOpenAiCompletionResponseDTO, isOpenAiCompletionResponseDTO, OpenAiCompletionResponseDTO } from "./dto/OpenAiCompletionResponseDTO"; +import { AuthorizationUtils } from "../AuthorizationUtils"; +import { ReadonlyJsonAny } from "../Json"; +import { OpenAiModel } from "./types/OpenAiModel"; +import { OpenAiClient } from "./OpenAiClient"; +import { explainOpenAiEditResponseDTO, isOpenAiEditResponseDTO, OpenAiEditResponseDTO } from "./dto/OpenAiEditResponseDTO"; +import { createOpenAiEditRequestDTO } from "./dto/OpenAiEditRequestDTO"; +import {createOpenAiChatCompletionRequestDTO} from "./dto/chatDTO/OpenAiChatCompletionRequestDTO"; +import { + explainOpenAiChatCompletionResponseDTO, + isOpenAiChatCompletionResponseDTO, OpenAiChatCompletionResponseDTO +} from "./dto/chatDTO/OpenAiChatCompletionResponseDTO"; +import {OpenAiChatCompletionMessage} from "./dto/chatDTO/OpenAiChatCompletionMessage"; + +const LOG = LogService.createLogger('HttpOpenAiClient'); + +/** + * The HTTP header name for authorization + */ +export const OPENAPI_AUTHORIZATION_HEADER = "Authorization"; + +/** + * The base URL of the OpenAI API (`https://api.openai.com`). + * @constant + * @type {string} + * @default + */ +export const OPENAPI_API_URL = 'https://api.openai.com'; + +/** + * @constant {string} + * @default '/v1/edits' + * + * The path for the OpenAI API's `GET /v1/edits` endpoint. + */ +export const OPENAI_API_POST_EDITS_PATH = '/v1/edits'; + +/** + * @constant {string} + * @default '/v1/completions' + * + * The path for the OpenAI API's `GET /v1/completions` endpoint. + */ +export const OPENAI_API_POST_COMPLETIONS_PATH = '/v1/completions'; + +/** + * @constant {string} + * @default '/v1/chat/completions' + * + * The path for the OpenAI API's `GET /v1/chat/completions` endpoint. + */ +export const OPENAI_API_POST_CHAT_COMPLETIONS_PATH = '/v1/chat/completions'; + +/** + * A client for interacting with the OpenAI API. + * + * @see https://beta.openai.com/docs/quickstart + * + * @remarks + * This client provides methods for calling the OpenAI API's endpoints for text completion, + * language generation, and other tasks. + * + * To create an `OpenAiClient`, use the `create` method. + * + * The `OpenAiClient`'s methods return Promises that resolve to the JSON response + * returned by the OpenAI API. If the OpenAI API returns an error, the Promise will be rejected + * with an instance of an `HttpError` that includes the error message and HTTP status code. + * + * ```typescript + * const openai = OpenAiClient.create(); + * + * openai.getCompletion(...) + * .then((response) => { + * console.log(response); + * }) + * .catch((error) => { + * console.error(error); + * }); + * ``` + * + * @TODO Add support for organizations https://beta.openai.com/docs/api-reference/requesting-organization + */ +export class HttpOpenAiClient implements OpenAiClient { + + /** + * Sets the log level for the OpenAI client and the underlying HTTP service. + * + * @param {LogLevel} level - The log level to set. + */ + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + HttpService.setLogLevel(level); + } + + /** + * The default URL to use for the OpenAI API if none is provided when creating a new `OpenAiClient` instance. + */ + public static _defaultUrl : string = OPENAPI_API_URL; + + /** + * The URL of the OpenAI API that the client is configured to use. + */ + private readonly _url : string; + + /** + * The API key to use when making requests to the OpenAI API. + */ + private readonly _apiKey : string; + + /** + * Sets the default URL to use for the OpenAI API when creating new `OpenAiClient` instances. + * + * @param {string} url - The URL to set as the default. + */ + public static setDefaultUrl (url : string) { + this._defaultUrl = url; + } + + /** + * Gets the default URL to use for the OpenAI API when creating new `OpenAiClient` instances. + * + * @returns {string} The default URL. + */ + public static getDefaultUrl () : string { + return this._defaultUrl; + } + + /** + * Factory method for creating an instance of OpenAiClient. + * + * @param {string} apiKey - The API key to use when making requests to the OpenAI API. + * @param {string} [url=OpenAiClient._defaultUrl] - The base URL for the OpenAI API. + * @returns {HttpOpenAiClient} - A new instance of OpenAiClient. + */ + public static create ( + apiKey : string, + url : string = HttpOpenAiClient._defaultUrl + ) : HttpOpenAiClient { + return new HttpOpenAiClient(apiKey, url); + } + + /** + * Creates a new instance of OpenAiClient. + * + * @param {string} apiKey - The API key to use when making requests to the OpenAI API. + * @param {string} [url=OpenAiClient._defaultUrl] - The base URL for the OpenAI API. + */ + public constructor ( + apiKey : string, + url : string = HttpOpenAiClient._defaultUrl + ) { + this._url = url; + this._apiKey = apiKey; + } + + /** + * Returns the URL of the OpenAI API that the client is configured to use. + * + * @returns {string} The URL. + */ + public getUrl () : string { + return this._url; + } + + /** + * Calls the OpenAI APIs text completion endpoint to generate text based on + * the given prompt. + * + * Default values for the optional parameters are selected based on the model. + * + * @param {string} prompt - The prompt to use for text completion. + * @param {OpenAiModel} [model=OpenAiApiModel.DAVINCI] - The OpenAI API + * model to use for text completion. + * @param {number} [max_tokens] - The maximum number of tokens (words and + * punctuation) to generate in the completion. + * @param {number} [temperature] - Controls the "creativity" of the + * completion. A higher value means the model + * will take more risks. + * @param {number} [top_p] - Controls the "confidence" of the completion. + * A lower value means the model will be more + * confident in its words. + * @param {number} [frequency_penalty] - Controls the "diversity" of the + * completion. A higher value means + * the model will avoid repetition of + * words. + * @param {number} [presence_penalty] - Controls the "relevance" of the + * completion. A higher value means the + * model will try to match the prompt + * more closely. + * @returns {Promise} - A promise that resolves + * to the response from the OpenAI API. + * @throws {HttpError} - If the OpenAI API returns an error. + * @throws {TypeError} - If the OpenAI API returns a response in an + * unexpected format. + */ + public async getCompletion ( + prompt : string, + model ?: OpenAiModel | string, + max_tokens ?: number, + temperature ?: number, + top_p ?: number, + frequency_penalty ?: number, + presence_penalty ?: number + ) : Promise { + const body = createOpenAiCompletionRequestDTO( + prompt, + model, + max_tokens, + temperature, + top_p, + frequency_penalty, + presence_penalty + ); + const headers = HttpOpenAiClient._getHeaders(this._apiKey); + LOG.debug(`getCompletion: body = `, body); + const result = await HttpService.postJson( + `${this._url}${OPENAI_API_POST_COMPLETIONS_PATH}`, + body as unknown as ReadonlyJsonAny, + headers + ); + if (!isOpenAiCompletionResponseDTO(result)) { + LOG.error(`getCompletion: result = `, result); + throw new TypeError(`Result was not OpenAiCompletionResponseDTO: ` + explainOpenAiCompletionResponseDTO(result)); + } + LOG.debug(`getCompletion: result = `, result); + return result; + } + + public async getChatCompletion ( + messages : OpenAiChatCompletionMessage, + model : OpenAiModel | string, + max_tokens ?: number, + temperature ?: number, + top_p ?: number, + frequency_penalty ?: number, + presence_penalty ?: number + ) : Promise { + const body = createOpenAiChatCompletionRequestDTO( + [messages], + model, + undefined, + max_tokens, + temperature, + top_p, + frequency_penalty, + presence_penalty + ); + const headers = HttpOpenAiClient._getHeaders(this._apiKey); + LOG.debug(`getChatCompletion: body = `, body); + const result = await HttpService.postJson( + `${this._url}${OPENAI_API_POST_CHAT_COMPLETIONS_PATH}`, + body as unknown as ReadonlyJsonAny, + headers + ); + if (!isOpenAiChatCompletionResponseDTO(result)) { + LOG.error(`getChatCompletion: result = `, result); + throw new TypeError(`Result was not OpenAiChatCompletionResponseDTO: ` + explainOpenAiChatCompletionResponseDTO(result)); + } + LOG.debug(`getChatCompletion: result = `, result); + return result; + } + + /** + * Calls the OpenAI APIs text edit endpoint to generate text based on + * the given input and instruction. + * + * Default values for the optional parameters are selected based on the model. + * + * @param {string} instruction - The instruction to use for text editing. + * @param {string} input - The input to use for text editing. + * @param {OpenAiModel} [model=OpenAiApiModel.DAVINCI] - The OpenAI API + * model to use for text completion. + * @param {number} [n] - The maximum number of tokens (words and + * punctuation) to generate in the completion. + * @param {number} [temperature] - Controls the "creativity" of the + * completion. A higher value means the model + * will take more risks. + * @param {number} [top_p] - Controls the "confidence" of the completion. + * A lower value means the model will be more + * confident in its words. + * @returns {Promise} - A promise that resolves + * to the response from the OpenAI API. + * @throws {HttpError} - If the OpenAI API returns an error. + * @throws {TypeError} - If the OpenAI API returns a response in an + * unexpected format. + */ + public async getEdit ( + instruction : string, + input ?: string, + model ?: OpenAiModel | string, + n ?: number, + temperature ?: number, + top_p ?: number + ) : Promise { + const body = createOpenAiEditRequestDTO( + instruction, + input, + model, + n, + temperature, + top_p + ); + LOG.debug(`getEdit: body = `, body); + const headers = HttpOpenAiClient._getHeaders(this._apiKey); + const result = await HttpService.postJson( + `${this._url}${OPENAI_API_POST_EDITS_PATH}`, + body as unknown as ReadonlyJsonAny, + headers + ); + if (!isOpenAiEditResponseDTO(result)) { + LOG.error(`getEdit: result = `, result); + throw new TypeError(`Result was not OpenAiEditResponseDTO: ` + explainOpenAiEditResponseDTO(result)); + } + LOG.debug(`getEdit: result = `, result); + return result; + } + + /** + * + * @param apiKey + * @private + */ + private static _getHeaders (apiKey: string) { + return { + [OPENAPI_AUTHORIZATION_HEADER]: AuthorizationUtils.createBearerHeader(apiKey) + } + } + +} diff --git a/openai/OpenAiClient.ts b/openai/OpenAiClient.ts new file mode 100644 index 0000000..f7ef60f --- /dev/null +++ b/openai/OpenAiClient.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpenAiModel } from "./types/OpenAiModel"; +import { OpenAiCompletionResponseDTO } from "./dto/OpenAiCompletionResponseDTO"; +import { OpenAiEditResponseDTO } from "./dto/OpenAiEditResponseDTO"; +import {OpenAiChatCompletionRequestDTO} from "./dto/chatDTO/OpenAiChatCompletionRequestDTO"; +import {OpenAiChatCompletionMessage} from "./dto/chatDTO/OpenAiChatCompletionMessage"; + +/** + * A client for interacting with the OpenAI API. + * + * @see https://beta.openai.com/docs/quickstart + * + * @remarks + * This client provides methods for calling the OpenAI API's endpoints for text completion, + * language generation, and other tasks. + * + * To create an `OpenAiClient`, use the `HttpOpenAiClient.create` method. + * + * The `OpenAiClient`'s methods return Promises that resolve to the JSON response + * returned by the OpenAI API. If the OpenAI API returns an error, the Promise will be rejected + * with an instance of an `HttpError` that includes the error message and HTTP status code. + * + * ```typescript + * openai.getCompletion(...) + * .then((response) => { + * console.log(response); + * }) + * .catch((error) => { + * console.error(error); + * }); + * ``` + */ +export interface OpenAiClient { + + /** + * Returns the URL of the OpenAI API that the client is configured to use. + * + * @returns {string} The URL. + */ + getUrl () : string; + + /** + * Calls the OpenAI APIs text completion endpoint to generate text based on + * the given prompt. + * + * Default values for the optional parameters are selected based on the model. + * + * @param {string} prompt - The prompt to use for text completion. + * @param {OpenAiModel} [model=OpenAiApiModel.DAVINCI] - The OpenAI API + * model to use for text completion. + * @param {number} [max_tokens] - The maximum number of tokens (words and + * punctuation) to generate in the completion. + * @param {number} [temperature] - Controls the "creativity" of the + * completion. A higher value means the model + * will take more risks. + * @param {number} [top_p] - Controls the "confidence" of the completion. + * A lower value means the model will be more + * confident in its words. + * @param {number} [frequency_penalty] - Controls the "diversity" of the + * completion. A higher value means + * the model will avoid repetition of + * words. + * @param {number} [presence_penalty] - Controls the "relevance" of the + * completion. A higher value means the + * model will try to match the prompt + * more closely. + * @returns {Promise} - A promise that resolves + * to the response from the OpenAI API. + * @throws {HttpError} - If the OpenAI API returns an error. + * @throws {TypeError} - If the OpenAI API returns a response in an + * unexpected format. + */ + getCompletion ( + prompt : string, + model ?: OpenAiModel | string | undefined, + max_tokens ?: number | undefined, + temperature ?: number | undefined, + top_p ?: number | undefined, + frequency_penalty ?: number | undefined, + presence_penalty ?: number | undefined + ) : Promise; + + /** + * Calls the OpenAI APIs text completion endpoint to generate text based on + * the given prompt. + * + * Default values for the optional parameters are selected based on the model. + * + * @param {OpenAiCompletionRequestMessageDTO} messages - The object to use for chat text completion. + * @param {OpenAiModel} [model=OpenAiApiModel.DAVINCI] - The OpenAI API + * model to use for text completion. + * @param {number} [max_tokens] - The maximum number of tokens (words and + * punctuation) to generate in the completion. + * @param {number} [temperature] - Controls the "creativity" of the + * completion. A higher value means the model + * will take more risks. + * @param {number} [top_p] - Controls the "confidence" of the completion. + * A lower value means the model will be more + * confident in its words. + * @param {number} [frequency_penalty] - Controls the "diversity" of the + * completion. A higher value means + * the model will avoid repetition of + * words. + * @param {number} [presence_penalty] - Controls the "relevance" of the + * completion. A higher value means the + * model will try to match the prompt + * more closely. + * @returns {Promise} - A promise that resolves + * to the response from the OpenAI API. + * @throws {HttpError} - If the OpenAI API returns an error. + * @throws {TypeError} - If the OpenAI API returns a response in an + * unexpected format. + */ + getChatCompletion? ( + messages : OpenAiChatCompletionMessage, + model ?: OpenAiModel | string | undefined, + max_tokens ?: number | undefined, + temperature ?: number | undefined, + top_p ?: number | undefined, + frequency_penalty ?: number | undefined, + presence_penalty ?: number | undefined + ) : Promise; + + /** + * Calls the OpenAI APIs text edit endpoint to generate text based on + * the given input and instruction. + * + * Default values for the optional parameters are selected based on the model. + * + * @param {string} input - The input to use for text editing. + * @param {string} instruction - The instruction to use for text editing. + * @param {OpenAiModel} [model=OpenAiApiModel.DAVINCI] - The OpenAI API + * model to use for text completion. + * @param {number} [n] - + * @param {number} [temperature] - Controls the "creativity" of the + * completion. A higher value means the model + * will take more risks. + * @param {number} [top_p] - Controls the "confidence" of the completion. + * A lower value means the model will be more + * confident in its words. + * @returns {Promise} - A promise that resolves + * to the response from the OpenAI API. + * @throws {HttpError} - If the OpenAI API returns an error. + * @throws {TypeError} - If the OpenAI API returns a response in an + * unexpected format. + */ + getEdit ( + instruction : string, + input ?: string | undefined, + model ?: OpenAiModel | string | undefined, + n ?: number | undefined, + temperature ?: number | undefined, + top_p ?: number | undefined + ) : Promise; + +} + diff --git a/openai/OpenAiModelUtils.test.ts b/openai/OpenAiModelUtils.test.ts new file mode 100644 index 0000000..0077d18 --- /dev/null +++ b/openai/OpenAiModelUtils.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpenAiModelUtils } from "./OpenAiModelUtils"; +import { OpenAiModel } from "./types/OpenAiModel"; + +describe("OpenAiModelUtils", () => { + + describe("#getDefaultMaxTokensForModel", () => { + it("should return the correct default max tokens value for the given model", () => { + expect(OpenAiModelUtils.getDefaultMaxTokensForModel(OpenAiModel.DAVINCI)).toBe(4000); + expect(OpenAiModelUtils.getDefaultMaxTokensForModel(OpenAiModel.CURIE)).toBe(2048); + expect(OpenAiModelUtils.getDefaultMaxTokensForModel(OpenAiModel.BABBAGE)).toBe(2048); + expect(OpenAiModelUtils.getDefaultMaxTokensForModel(OpenAiModel.ADA)).toBe(2048); + expect(OpenAiModelUtils.getDefaultMaxTokensForModel(OpenAiModel.CONTENT_FILTER)).toBe(1); + expect(OpenAiModelUtils.getDefaultMaxTokensForModel(OpenAiModel.CODEX)).toBe(8000); + // @ts-ignore + expect(OpenAiModelUtils.getDefaultMaxTokensForModel("invalid-model")).toBe(2048); + }); + }); + + describe("#getDefaultTemperatureForModel", () => { + it("should return the correct default temperature for each model in the OpenAiApiModel enum", () => { + expect(OpenAiModelUtils.getDefaultTemperatureForModel(OpenAiModel.DAVINCI)).toBe(0.5); + expect(OpenAiModelUtils.getDefaultTemperatureForModel(OpenAiModel.CURIE)).toBe(0.5); + expect(OpenAiModelUtils.getDefaultTemperatureForModel(OpenAiModel.BABBAGE)).toBe(0.5); + expect(OpenAiModelUtils.getDefaultTemperatureForModel(OpenAiModel.ADA)).toBe(0.5); + expect(OpenAiModelUtils.getDefaultTemperatureForModel(OpenAiModel.CONTENT_FILTER)).toBe(0.0); + expect(OpenAiModelUtils.getDefaultTemperatureForModel(OpenAiModel.CODEX)).toBe(0.5); + }); + }); + + describe("getDefaultTopPForModel", () => { + it("returns the correct default top_p value for each model", () => { + expect(OpenAiModelUtils.getDefaultTopPForModel(OpenAiModel.DAVINCI)).toBe(1); + expect(OpenAiModelUtils.getDefaultTopPForModel(OpenAiModel.CURIE)).toBe(1); + expect(OpenAiModelUtils.getDefaultTopPForModel(OpenAiModel.BABBAGE)).toBe(1); + expect(OpenAiModelUtils.getDefaultTopPForModel(OpenAiModel.ADA)).toBe(1); + expect(OpenAiModelUtils.getDefaultTopPForModel(OpenAiModel.CONTENT_FILTER)).toBe(0); + expect(OpenAiModelUtils.getDefaultTopPForModel(OpenAiModel.CODEX)).toBe(1); + // @ts-ignore + expect(OpenAiModelUtils.getDefaultTopPForModel("unknown")).toBe(1); + }); + }); + + describe("getDefaultFrequencyPenaltyForModel", () => { + it("returns the correct default frequency penalty for each OpenAI API model", () => { + // Check that each model returns the expected frequency penalty + expect(OpenAiModelUtils.getDefaultFrequencyPenaltyForModel(OpenAiModel.DAVINCI)).toBe(0); + expect(OpenAiModelUtils.getDefaultFrequencyPenaltyForModel(OpenAiModel.CURIE)).toBe(0); + expect(OpenAiModelUtils.getDefaultFrequencyPenaltyForModel(OpenAiModel.BABBAGE)).toBe(0); + expect(OpenAiModelUtils.getDefaultFrequencyPenaltyForModel(OpenAiModel.ADA)).toBe(0); + expect(OpenAiModelUtils.getDefaultFrequencyPenaltyForModel(OpenAiModel.CONTENT_FILTER)).toBe(0); + expect(OpenAiModelUtils.getDefaultFrequencyPenaltyForModel(OpenAiModel.CODEX)).toBe(0); + // @ts-ignore + expect(OpenAiModelUtils.getDefaultFrequencyPenaltyForModel("some-other-model")).toBe(0); + }); + }); + + describe("getDefaultPresencePenaltyForModel", () => { + it("returns the expected default presence penalty for each model", () => { + expect(OpenAiModelUtils.getDefaultPresencePenaltyForModel(OpenAiModel.DAVINCI)).toEqual(0); + expect(OpenAiModelUtils.getDefaultPresencePenaltyForModel(OpenAiModel.CURIE)).toEqual(0); + expect(OpenAiModelUtils.getDefaultPresencePenaltyForModel(OpenAiModel.BABBAGE)).toEqual(0); + expect(OpenAiModelUtils.getDefaultPresencePenaltyForModel(OpenAiModel.ADA)).toEqual(0); + expect(OpenAiModelUtils.getDefaultPresencePenaltyForModel(OpenAiModel.CONTENT_FILTER)).toEqual(0); + expect(OpenAiModelUtils.getDefaultPresencePenaltyForModel(OpenAiModel.CODEX)).toEqual(0); + // @ts-ignore + expect(OpenAiModelUtils.getDefaultPresencePenaltyForModel("unknown")).toEqual(0); + }); + }); + +}); diff --git a/openai/OpenAiModelUtils.ts b/openai/OpenAiModelUtils.ts new file mode 100644 index 0000000..e37c850 --- /dev/null +++ b/openai/OpenAiModelUtils.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpenAiModel } from "./types/OpenAiModel"; + +/** + * A utility class for working with OpenAI models. + */ +export class OpenAiModelUtils { + + /** + * Returns the default maximum number of tokens to generate for the given + * model. + * + * @param {OpenAiModel} model - The OpenAI API model. + * @returns {number} The default maximum number of tokens to generate. + */ + public static getDefaultMaxTokensForModel (model: OpenAiModel) : number { + switch(model) { + case OpenAiModel.DAVINCI: return 4000; + case OpenAiModel.CURIE: return 2048; + case OpenAiModel.BABBAGE: return 2048; + case OpenAiModel.ADA: return 2048; + case OpenAiModel.CONTENT_FILTER: return 1; + case OpenAiModel.CODEX: return 8000; + default : return 2048; + } + } + + /** + * Returns the default temperature to use for the given model. + * + * @param {OpenAiModel} model - The OpenAI API model. + * @returns {number} The default temperature to use. + */ + public static getDefaultTemperatureForModel (model: OpenAiModel) : number { + switch(model) { + case OpenAiModel.DAVINCI: return 0.5; + case OpenAiModel.CURIE: return 0.5; + case OpenAiModel.BABBAGE: return 0.5; + case OpenAiModel.ADA: return 0.5; + case OpenAiModel.CONTENT_FILTER: return 0.0; + case OpenAiModel.CODEX: return 0.5; + default : return 0.5; + } + } + + /** + * Returns the default top p to use for the given model. + * + * @param {OpenAiModel} model - The OpenAI API model. + * @returns {number} The default top p to use. + */ + public static getDefaultTopPForModel (model: OpenAiModel) : number { + switch(model) { + case OpenAiModel.DAVINCI: return 1; + case OpenAiModel.CURIE: return 1; + case OpenAiModel.BABBAGE: return 1; + case OpenAiModel.ADA: return 1; + case OpenAiModel.CONTENT_FILTER: return 0; + case OpenAiModel.CODEX: return 1; + default : return 1; + } + } + + /** + * Returns the default frequency_penalty value to use for the given model. + * + * @param {OpenAiModel} model - The OpenAI API model. + * @returns {number} The default frequency_penalty value. + */ + public static getDefaultFrequencyPenaltyForModel (model: OpenAiModel) : number { + switch(model) { + case OpenAiModel.DAVINCI: return 0; + case OpenAiModel.CURIE: return 0; + case OpenAiModel.BABBAGE: return 0; + case OpenAiModel.ADA: return 0; + case OpenAiModel.CONTENT_FILTER: return 0; + case OpenAiModel.CODEX: return 0; + default : return 0; + } + } + + /** + * Returns the default presence_penalty value to use for the given model. + * + * @param {OpenAiModel} model - The OpenAI API model. + * @returns {number} The default presence_penalty value. + */ + public static getDefaultPresencePenaltyForModel (model: OpenAiModel) : number { + switch(model) { + case OpenAiModel.DAVINCI: return 0; + case OpenAiModel.CURIE: return 0; + case OpenAiModel.BABBAGE: return 0; + case OpenAiModel.ADA: return 0; + case OpenAiModel.CONTENT_FILTER: return 0; + case OpenAiModel.CODEX: return 0; + default : return 0; + } + } + +} diff --git a/openai/dto/OpenAiCompletionRequestDTO.test.ts b/openai/dto/OpenAiCompletionRequestDTO.test.ts new file mode 100644 index 0000000..253a283 --- /dev/null +++ b/openai/dto/OpenAiCompletionRequestDTO.test.ts @@ -0,0 +1,509 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpenAiCompletionRequestDTO, createOpenAiCompletionRequestDTO, isOpenAiCompletionRequestDTO, explainOpenAiCompletionRequestDTO, stringifyOpenAiCompletionRequestDTO, parseOpenAiCompletionRequestDTO } from "./OpenAiCompletionRequestDTO"; +import { OpenAiModel } from "../types/OpenAiModel"; + +describe("OpenAiCompletionRequestDTO", () => { + + describe("createOpenAiCompletionRequestDTO", () => { + + it("creates valid OpenAiCompletionRequestDTO objects", () => { + const request1: OpenAiCompletionRequestDTO = createOpenAiCompletionRequestDTO( + "What is the weather like today?", + OpenAiModel.DAVINCI, + 10, + 0.5, + 0.9, + 0.5, + 0.5 + ); + expect(request1).toEqual({ + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + }); + + const request2: OpenAiCompletionRequestDTO = createOpenAiCompletionRequestDTO( + "What is the capital city of France?", + OpenAiModel.DAVINCI, + 15, + 0.7, + 0.8, + 0.6, + 0.7 + ); + expect(request2).toEqual({ + prompt: "What is the capital city of France?", + model: OpenAiModel.DAVINCI, + max_tokens: 15, + temperature: 0.7, + top_p: 0.8, + frequency_penalty: 0.6, + presence_penalty: 0.7 + }); + }); + + it("throws an error if prompt is not a string", () => { + expect(() => createOpenAiCompletionRequestDTO( + // @ts-ignore + undefined, // this should throw an error + OpenAiModel.DAVINCI, + 10, + 0.5, + 0.9, + 0.5, + 0.5 + )).toThrowError(); + + expect(() => createOpenAiCompletionRequestDTO( + // @ts-ignore + 123, // this should throw an error + OpenAiModel.DAVINCI, + 10, + 0.5, + 0.9, + 0.5, + 0.5 + )).toThrowError(); + }); + + it("does not throw an error if model is not a valid OpenAiApiModel", () => { + expect(() => createOpenAiCompletionRequestDTO( + "What is the weather like today?", + "invalid-model" as OpenAiModel, // this should not throw an error + 10, + 0.5, + 0.9, + 0.5, + 0.5 + )).not.toThrowError(); + }); + + it("throws an error if max_tokens is not a number", () => { + expect(() => createOpenAiCompletionRequestDTO( + "What is the weather like today?", + OpenAiModel.DAVINCI, + "10" as any, // this should throw an error + 0.5, + 0.9, + 0.5, + 0.5 + )).toThrowError(); + }); + + it("throws an error if temperature is not a number", () => { + expect(() => createOpenAiCompletionRequestDTO( + "What is the weather like today?", + OpenAiModel.DAVINCI, + 10, + "0.5" as any, // this should throw an error + 0.9, + 0.5, + 0.5 + )).toThrowError(); + }); + + it("throws an error if top_p is not a number", () => { + expect(() => createOpenAiCompletionRequestDTO( + "What is the weather like today?", + OpenAiModel.DAVINCI, + 10, + 0.5, + "0.9" as any, // this should throw an error + 0.5, + 0.5 + )).toThrowError(); + }); + + it("createOpenAiCompletionRequestDTO throws an error if frequency_penalty is not a number", () => { + expect(() => createOpenAiCompletionRequestDTO( + "What is the weather like today?", + OpenAiModel.DAVINCI, + 10, + 0.5, + 0.9, + // @ts-ignore + "not a number", + 0.5 + )).toThrow(); + }); + + it("createOpenAiCompletionRequestDTO throws an error if presence_penalty is not a number", () => { + expect(() => createOpenAiCompletionRequestDTO( + "What is the weather like today?", + OpenAiModel.DAVINCI, + 10, + 0.5, + 0.9, + 0.5, + // @ts-ignore + "not a number" + )).toThrow(); + }); + + }); + + describe("isOpenAiCompletionRequestDTO", () => { + + it("returns true for valid OpenAiCompletionRequestDTO objects", () => { + const request1: OpenAiCompletionRequestDTO = { + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + }; + expect(isOpenAiCompletionRequestDTO(request1)).toBe(true); + + const request2: OpenAiCompletionRequestDTO = { + prompt: "What is the capital city of France?", + model: OpenAiModel.DAVINCI, + max_tokens: 15, + temperature: 0.7, + top_p: 0.8, + frequency_penalty: 0.6, + presence_penalty: 0.7 + }; + expect(isOpenAiCompletionRequestDTO(request2)).toBe(true); + }); + + it("returns false for non-object values", () => { + expect(isOpenAiCompletionRequestDTO(null)).toBe(false); + expect(isOpenAiCompletionRequestDTO(undefined)).toBe(false); + expect(isOpenAiCompletionRequestDTO(true)).toBe(false); + expect(isOpenAiCompletionRequestDTO(false)).toBe(false); + expect(isOpenAiCompletionRequestDTO(0)).toBe(false); + expect(isOpenAiCompletionRequestDTO(1)).toBe(false); + expect(isOpenAiCompletionRequestDTO("")).toBe(false); + expect(isOpenAiCompletionRequestDTO("hello")).toBe(false); + }); + + it("returns false for invalid OpenAiCompletionRequestDTO objects", () => { + expect(isOpenAiCompletionRequestDTO(undefined)).toBe(false); + expect(isOpenAiCompletionRequestDTO(null)).toBe(false); + expect(isOpenAiCompletionRequestDTO(false)).toBe(false); + expect(isOpenAiCompletionRequestDTO(true)).toBe(false); + expect(isOpenAiCompletionRequestDTO("string")).toBe(false); + expect(isOpenAiCompletionRequestDTO(123)).toBe(false); + expect(isOpenAiCompletionRequestDTO({})).toBe(false); + expect(isOpenAiCompletionRequestDTO({ prompt: 123 })).toBe(false); + expect(isOpenAiCompletionRequestDTO({ model: 123 })).toBe(false); + expect(isOpenAiCompletionRequestDTO({ max_tokens: "string" })).toBe(false); + expect(isOpenAiCompletionRequestDTO({ temperature: "string" })).toBe(false); + expect(isOpenAiCompletionRequestDTO({ top_p: "string" })).toBe(false); + expect(isOpenAiCompletionRequestDTO({ frequency_penalty: "string" })).toBe(false); + expect(isOpenAiCompletionRequestDTO({ presence_penalty: "string" })).toBe(false); + }); + + it("returns false for objects with extra keys", () => { + expect(isOpenAiCompletionRequestDTO({ + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5, + extraKey: "extra value" + })).toBe(false); + }); + + it("returns false for objects with non-string prompt value", () => { + expect(isOpenAiCompletionRequestDTO({ + prompt: 123, + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + })).toBe(false); + expect(isOpenAiCompletionRequestDTO({ + prompt: null, + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + })).toBe(false); + expect(isOpenAiCompletionRequestDTO({ + prompt: undefined, + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + })).toBe(false); + }); + + it("returns false for objects with non-OpenAiApiModel model value", () => { + const invalidModel: any = "invalid model"; + expect(isOpenAiCompletionRequestDTO({ + prompt: "What is the weather like today?", + model: invalidModel, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + })).toBe(false); + }); + + it("returns false for objects with non-number max_tokens value", () => { + expect(isOpenAiCompletionRequestDTO({ + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: "10", // invalid value + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + })).toBe(false); + }); + + it("returns false for objects with non-number temperature value", () => { + expect(isOpenAiCompletionRequestDTO({ + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: "0.5", + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + })).toBe(false); + }); + + it("returns false for objects with non-number top_p value", () => { + expect(isOpenAiCompletionRequestDTO({ + prompt: "What is the capital city of France?", + model: OpenAiModel.DAVINCI, + max_tokens: 15, + temperature: 0.7, + top_p: "0.8", + frequency_penalty: 0.6, + presence_penalty: 0.7 + })).toBe(false); + }); + + it("returns false for objects with non-number frequency_penalty value", () => { + const request: any = { + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: "0.5", + presence_penalty: 0.5 + }; + expect(isOpenAiCompletionRequestDTO(request)).toBe(false); + }); + + it("returns false for objects with non-number presence_penalty value", () => { + expect(isOpenAiCompletionRequestDTO({ + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: "0.5" + })).toBe(false); + }); + + }); + + describe("explainOpenAiCompletionRequestDTO", () => { + + it("returns a human-readable string explaining why the value is not a regular object", () => { + expect(explainOpenAiCompletionRequestDTO(undefined)).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO(null)).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO(false)).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO(true)).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO(0)).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO(1)).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO("")).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO("foo")).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO([])).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO([1, 2, 3])).toMatch(/not regular object/); + expect(explainOpenAiCompletionRequestDTO(() => {})).toMatch(/not regular object/); + }); + + it("returns a human-readable string explaining why the value has extra keys", () => { + expect(explainOpenAiCompletionRequestDTO({ + prompt: "What is the capital city of France?", + model: OpenAiModel.DAVINCI, + max_tokens: 15, + temperature: 0.7, + top_p: 0.8, + frequency_penalty: 0.6, + presence_penalty: 0.7, + extraKey: "this shouldn't be here" + })).toBe("Value had extra properties: extraKey"); + }); + + it("returns a human-readable string explaining why the value has a non-string prompt property", () => { + expect(explainOpenAiCompletionRequestDTO({ + prompt: 12345, + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + })).toEqual( + // TODO: 'value has a property "prompt" with invalid value: expected string, got number' + expect.stringContaining('property "prompt" not string') + ); + }); + + it("returns a human-readable string explaining why the value has a non-OpenAiApiModel model property", () => { + expect(explainOpenAiCompletionRequestDTO({ prompt: "What is the weather like today?", model: "not a model" })).toMatch( + // 'incorrect enum value "not a model" for model: Accepted values davinci, curie, babbage, ada, eliza, einstein' + /incorrect enum value/ + ); + }); + + it('returns a human-readable string explaining why the value has a non-number max_tokens property', () => { + const nonNumberMaxTokensValues = [null, true, false, '5', [], {}]; + for (const nonNumberMaxTokensValue of nonNumberMaxTokensValues) { + const value = { + prompt: 'What is the weather like today?', + model: OpenAiModel.DAVINCI, + max_tokens: nonNumberMaxTokensValue, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5, + }; + expect(explainOpenAiCompletionRequestDTO(value)).toMatch(/property "max_tokens" not number/); + } + }); + + it("returns a human-readable string explaining why the value has a non-number temperature property", () => { + const value = { + prompt: "What is the capital city of France?", + model: OpenAiModel.DAVINCI, + max_tokens: 15, + temperature: "0.7", // This value is a string, not a number + top_p: 0.8, + frequency_penalty: 0.6, + presence_penalty: 0.7 + }; + + expect(explainOpenAiCompletionRequestDTO(value)).toEqual( + // TODO: 'incorrect property value for temperature: expected a number, but got a string' + expect.stringContaining('property "temperature" not number') + ); + }); + + it("returns a human-readable string explaining why the value has a non-number top_p property", () => { + const value = { + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: "0.9", // top_p is a string, not a number + frequency_penalty: 0.5, + presence_penalty: 0.5 + }; + const result = explainOpenAiCompletionRequestDTO(value); + // TODO: const expected = `invalid OpenAiCompletionRequestDTO: top_p: expected a number, got string`; + const expected = `property "top_p" not number`; + expect(result).toEqual(expect.stringContaining(expected)); + }); + + it("returns a human-readable string explaining why the value has a non-number frequency_penalty property", () => { + const invalidValue = { + prompt: "What is the capital city of France?", + model: OpenAiModel.DAVINCI, + max_tokens: 15, + temperature: 0.7, + top_p: 0.8, + frequency_penalty: "not a number", + presence_penalty: 0.7 + }; + expect(explainOpenAiCompletionRequestDTO(invalidValue)).toEqual( + // TODO: 'incorrect property value "not a number" for frequency_penalty: must be a number' + expect.stringContaining('property "frequency_penalty" not number') + ); + }); + + it('returns a human-readable string explaining why the value has a non-number presence_penalty property', () => { + expect(explainOpenAiCompletionRequestDTO({ + prompt: 'What is the weather like today?', + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: '0.5' + })).toEqual( + // TODO: 'incorrect value "0.5" for presence_penalty: Value must be a number' + expect.stringContaining('property "presence_penalty" not number') + ); + }); + + }); + + describe("stringifyOpenAiCompletionRequestDTO", () => { + + it("returns a string representation of the OpenAiCompletionRequestDTO object", () => { + const request: OpenAiCompletionRequestDTO = { + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + }; + expect(stringifyOpenAiCompletionRequestDTO(request)).toEqual(`OpenAiCompletionRequestDTO(${JSON.stringify(request)})`); + }); + + }); + + describe("parseOpenAiCompletionRequestDTO", () => { + + it("parses a valid OpenAiCompletionRequestDTO string as an OpenAiCompletionRequestDTO object", () => { + const request: OpenAiCompletionRequestDTO = { + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + }; + const requestString: string = `OpenAiCompletionRequestDTO(${JSON.stringify(request)})`; + expect(parseOpenAiCompletionRequestDTO(requestString)).toEqual(request); + }); + + it("parses a valid JSON string as an OpenAiCompletionRequestDTO object", () => { + const request: OpenAiCompletionRequestDTO = { + prompt: "What is the weather like today?", + model: OpenAiModel.DAVINCI, + max_tokens: 10, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.5, + presence_penalty: 0.5 + }; + const requestString: string = `${JSON.stringify(request)}`; + expect(parseOpenAiCompletionRequestDTO(requestString)).toEqual(request); + }); + + it("returns undefined for invalid OpenAiCompletionRequestDTO strings", () => { + expect(parseOpenAiCompletionRequestDTO("invalid string")).toBeUndefined(); + expect(parseOpenAiCompletionRequestDTO("OpenAiCompletionRequestDTO(invalid json)")).toBeUndefined(); + }); + }); + +}); diff --git a/openai/dto/OpenAiCompletionRequestDTO.ts b/openai/dto/OpenAiCompletionRequestDTO.ts new file mode 100644 index 0000000..1d0c1fe --- /dev/null +++ b/openai/dto/OpenAiCompletionRequestDTO.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpenAiModel, isOpenAiModel, OpenAiModel } from "../types/OpenAiModel"; +import { explain, explainProperty } from "../../types/explain"; +import { explainString, isString } from "../../types/String"; +import { explainNumber, isNumber, isNumberOrUndefined } from "../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../types/OtherKeys"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson, ReadonlyJsonObject } from "../../Json"; +import { isUndefined } from "../../types/undefined"; + +/** + * Data Transfer Object for requesting a completion from the OpenAI API. + * + * @see https://beta.openai.com/docs/api-reference/completions/create + */ +export interface OpenAiCompletionRequestDTO { + + /** + * The model to use for completion. + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-model + */ + readonly model: OpenAiModel | string; + + /** + * The prompt to complete. + * + * Defaults to `"<|endoftext|>"` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-prompt + */ + readonly prompt ?: string | string[]; + + /** + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-suffix + */ + readonly suffix ?: string; + + /** + * The maximum number of tokens to generate in the completion. + * + * Defaults to 16 + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-max_tokens + */ + readonly max_tokens ?: number; + + /** + * The temperature to use for sampling. + * + * Defaults to 1 + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-temperature + */ + readonly temperature ?: number; + + /** + * The top probability to use for sampling. + * + * Defaults to 1 + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-top_p + */ + readonly top_p ?: number; + + /** + * How many completions to generate for each prompt. + * + * Defaults to 1 + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-n + */ + readonly n ?: number; + + /** + * Defaults to `false` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream + */ + readonly stream ?: boolean; + + /** + * Defaults to `null` + * + * @see https://beta.openai.com/docs/api-reference/completions/create + */ + readonly logprobs ?: number | null; + + /** + * Defaults to `false` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-echo + */ + readonly echo ?: boolean; + + /** + * Defaults to `false` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-stop + */ + readonly stop ?: boolean; + + /** + * The presence penalty to use for sampling. + * + * Defaults to `0` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-presence_penalty + */ + readonly presence_penalty ?: number; + + /** + * The frequency penalty to use for sampling. + * + * Defaults to `0` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-frequency_penalty + */ + readonly frequency_penalty ?: number; + + /** + * Defaults to `1` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-best_of + */ + readonly best_of ?: number; + + /** + * Detaults to `null` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-logit_bias + */ + readonly logit_bias ?: ReadonlyJsonObject; + + /** + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-user + */ + readonly user ?: string; + +} + +/** + * Create an `OpenAiCompletionRequestDTO` object with the given properties. + * + * @param {string} prompt - The prompt to complete. + * @param {OpenAiModel} model - The model to use for completion. + * @param {number} max_tokens - The maximum number of tokens to generate in the completion. + * @param {number} temperature - The temperature to use for sampling. + * @param {number} top_p - The top probability to use for sampling. + * @param {number} frequency_penalty - The frequency penalty to use for sampling. + * @param {number} presence_penalty - The presence penalty to use for sampling. + * @returns {OpenAiCompletionRequestDTO} An `OpenAiCompletionRequestDTO` object with the given properties. + */ +export function createOpenAiCompletionRequestDTO ( + prompt : string, + model ?: OpenAiModel | string, + max_tokens ?: number, + temperature ?: number, + top_p ?: number, + frequency_penalty ?: number, + presence_penalty ?: number, +) : OpenAiCompletionRequestDTO { + if (!isString(prompt)) throw new TypeError(`Invalid OpenAiCompletionRequestDTO.prompt: ${prompt}`); + if (!(isString(model) || isUndefined(model))) throw new TypeError(`Invalid OpenAiCompletionRequestDTO.model: ${model}`); + if (!isNumberOrUndefined(max_tokens)) throw new TypeError(`Invalid OpenAiCompletionRequestDTO.max_tokens: ${max_tokens}`); + if (!isNumberOrUndefined(temperature)) throw new TypeError(`Invalid OpenAiCompletionRequestDTO.temperature: ${temperature}`); + if (!isNumberOrUndefined(top_p)) throw new TypeError(`Invalid OpenAiCompletionRequestDTO.top_p: ${top_p}`); + if (!isNumberOrUndefined(frequency_penalty)) throw new TypeError(`Invalid OpenAiCompletionRequestDTO.frequency_penalty: ${frequency_penalty}`); + if (!isNumberOrUndefined(presence_penalty)) throw new TypeError(`Invalid OpenAiCompletionRequestDTO.presence_penalty: ${presence_penalty}`); + return { + prompt, + model: model ?? OpenAiModel.DAVINCI, + ...(isNumber(max_tokens) ? {max_tokens} : {}), + ...(isNumber(temperature) ? {temperature} : {}), + ...(isNumber(top_p) ? {top_p} : {}), + ...(isNumber(frequency_penalty) ? {frequency_penalty} : {}), + ...(isNumber(presence_penalty) ? {presence_penalty} : {}), + }; +} + +/** + * Test whether the given value is an `OpenAiCompletionRequestDTO` object. + * + * @param {unknown} value - The value to test. + * @returns {value is OpenAiCompletionRequestDTO} `true` if the value is an `OpenAiCompletionRequestDTO` object, `false` otherwise. + */ +export function isOpenAiCompletionRequestDTO (value: any) : value is OpenAiCompletionRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'prompt', + 'model', + 'max_tokens', + 'temperature', + 'top_p', + 'frequency_penalty', + 'presence_penalty' + ]) + && isString(value?.prompt) + && isOpenAiModel(value?.model) + && isNumber(value?.max_tokens) + && isNumber(value?.temperature) + && isNumber(value?.top_p) + && isNumber(value?.frequency_penalty) + && isNumber(value?.presence_penalty) + ); +} + +/** + * Explain why the given value is not an `OpenAiCompletionRequestDTO` object. + * + * @param {unknown} value - The value to test. + * @returns {string} A human-readable message explaining why the value is not an `OpenAiCompletionRequestDTO` object, or `'ok'` if it is. + */ +export function explainOpenAiCompletionRequestDTO (value: unknown) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'prompt', + 'model', + 'max_tokens', + 'temperature', + 'top_p', + 'frequency_penalty', + 'presence_penalty', + ]) + , explainProperty("prompt", explainString((value as any)?.prompt)) + , explainProperty("model", explainOpenAiModel((value as any)?.model)) + , explainProperty("max_tokens", explainNumber((value as any)?.max_tokens)) + , explainProperty("temperature", explainNumber((value as any)?.temperature)) + , explainProperty("top_p", explainNumber((value as any)?.top_p)) + , explainProperty("frequency_penalty", explainNumber((value as any)?.frequency_penalty)) + , explainProperty("presence_penalty", explainNumber((value as any)?.presence_penalty)) + ] + ); +} + +/** + * Convert the given `OpenAiCompletionRequestDTO` object to a string. + * + * @param {OpenAiCompletionRequestDTO} value - The value to convert. + * @returns {string} A string representation of the `OpenAiCompletionRequestDTO` object. + */ +export function stringifyOpenAiCompletionRequestDTO (value : OpenAiCompletionRequestDTO) : string { + return `OpenAiCompletionRequestDTO(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiCompletionRequestDTO` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiCompletionRequestDTO|undefined} The parsed `OpenAiCompletionRequestDTO` object, or `undefined` if the value is not a valid `OpenAiCompletionRequestDTO` object. + */ +export function parseOpenAiCompletionRequestDTO (value: unknown) : OpenAiCompletionRequestDTO | undefined { + + if (isString(value)) { + if (startsWith(value, "OpenAiCompletionRequestDTO(")) { + value = value.substring("OpenAiCompletionRequestDTO(".length, value.length -1 ); + } + value = parseJson(value); + } + + if (isOpenAiCompletionRequestDTO(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiCompletionResponseChoice.test.ts b/openai/dto/OpenAiCompletionResponseChoice.test.ts new file mode 100644 index 0000000..78eb1e0 --- /dev/null +++ b/openai/dto/OpenAiCompletionResponseChoice.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiCompletionResponseChoice, + explainOpenAiCompletionResponseChoice, + isOpenAiCompletionResponseChoice, + OpenAiCompletionResponseChoice, + parseOpenAiCompletionResponseChoice, + stringifyOpenAiCompletionResponseChoice +} from "./OpenAiCompletionResponseChoice"; + +describe("OpenAiCompletionResponseChoice", () => { + + describe("createOpenAiCompletionResponseChoice", () => { + it("creates a valid OpenAiCompletionResponseChoice object", () => { + const item1: OpenAiCompletionResponseChoice = createOpenAiCompletionResponseChoice( + "completed text", + 0, + null, + 'length' + ); + expect(item1).toEqual({ + text: "completed text", + index: 0, + logprobs: null, + finish_reason: 'length' + }); + + const item2: OpenAiCompletionResponseChoice = createOpenAiCompletionResponseChoice( + "more completed text", + 1, + 3, + 'length' + ); + expect(item2).toEqual({ + text: "more completed text", + index: 1, + logprobs: 3, + finish_reason: 'length' + }); + }); + }); + + describe('isOpenAiCompletionResponseChoice', () => { + it('returns true for valid OpenAiCompletionResponseChoice objects', () => { + expect(isOpenAiCompletionResponseChoice({ + text: 'This is a test', + index: 0, + logprobs: null, + finish_reason: 'length' + })).toBe(true); + }); + + it('returns false for invalid OpenAiCompletionResponseChoice objects', () => { + // not an object + expect(isOpenAiCompletionResponseChoice('invalid')).toBe(false); + // extra keys + expect(isOpenAiCompletionResponseChoice({ + text: 'This is a test', + index: 0, + logprobs: null, + finish_reason: 'length', + extraKey: 'extra value' + })).toBe(false); + // non-string text + expect(isOpenAiCompletionResponseChoice({ + text: ['This is a test'], + index: 0, + logprobs: null, + finish_reason: 'length' + })).toBe(false); + // non-number index + expect(isOpenAiCompletionResponseChoice({ + text: 'This is a test', + index: '0', + logprobs: null, + finish_reason: 'length' + })).toBe(false); + }); + }); + + describe("explainOpenAiCompletionResponseChoice", () => { + + it("returns a human-readable string explaining why the value has extra keys", () => { + const value = { + text: "This is a text", + index: 0, + logprobs: null, + finish_reason: 'length', + extraKey: "This is an extra key" + }; + expect(explainOpenAiCompletionResponseChoice(value)).toEqual( + "Value had extra properties: extraKey" + ); + }); + + it("returns a human-readable string explaining why the value has a non-string text property", () => { + expect(explainOpenAiCompletionResponseChoice({ + text: 5, + index: 0, + logprobs: null, + finish_reason: 'length' + })).toEqual( + // "Object has a non-string property 'text' (5)" + expect.stringContaining('property "text" not string') + ); + }); + + it("returns a human-readable string explaining why the value has a non-number index property", () => { + expect(explainOpenAiCompletionResponseChoice({ + text: "Some text", + index: "not a number", + logprobs: null, + finish_reason: 'length' + })).toEqual( + // "Expected property 'index' to be a number, but got string 'not a number' instead" + expect.stringContaining('property "index" not number') + ); + }); + + it("returns a human-readable string explaining why the value is not a valid OpenAiCompletionResponseChoice", () => { + expect(explainOpenAiCompletionResponseChoice({ extra: "key" })).toMatch(/Value had extra properties: extra/); + expect(explainOpenAiCompletionResponseChoice("foo")).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(undefined)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(null)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(true)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(false)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(0)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(1)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice("")).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice("test")).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice([])).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(["test"])).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(() => {})).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseChoice(Symbol())).toMatch(/not regular object/); + }); + + }); + + describe("stringifyOpenAiCompletionResponseChoice", () => { + it("should return the string 'OpenAiCompletionResponseChoice({...})'", () => { + const value: OpenAiCompletionResponseChoice = { + text: "This is a test", + index: 2, + logprobs: null, + finish_reason: 'length' + }; + const expected = "OpenAiCompletionResponseChoice({\"text\":\"This is a test\",\"index\":2,\"logprobs\":null,\"finish_reason\":\"length\"})"; + const result = stringifyOpenAiCompletionResponseChoice(value); + expect(result).toEqual(expected); + }); + }); + + describe("parseOpenAiCompletionResponseChoice", () => { + + it("parses a valid OpenAiCompletionResponseChoice string representation", () => { + const string = `OpenAiCompletionResponseChoice({"text":"text","index":1,"logprobs":null,"finish_reason":"length"})`; + const value = parseOpenAiCompletionResponseChoice(string); + expect(value).toEqual({ + text: "text", + index: 1, + logprobs: null, + finish_reason: 'length' + }); + }); + + it("parses a valid OpenAiCompletionResponseChoice object", () => { + const object = { + text: "text", + index: 1, + logprobs: null, + finish_reason: 'length' + }; + const value = parseOpenAiCompletionResponseChoice(object); + expect(value).toEqual(object); + }); + + it("returns undefined for an invalid OpenAiCompletionResponseChoice string representation", () => { + const string = `OpenAiCompletionResponseChoice({"invalid":true})`; + const value = parseOpenAiCompletionResponseChoice(string); + expect(value).toBeUndefined(); + }); + + it("returns undefined for a non-OpenAiCompletionResponseChoice object", () => { + const object = { + invalid: true + }; + const value = parseOpenAiCompletionResponseChoice(object); + expect(value).toBeUndefined(); + }); + + it("returns undefined for non-string, non-object values", () => { + expect(parseOpenAiCompletionResponseChoice(null)).toBeUndefined(); + expect(parseOpenAiCompletionResponseChoice(true)).toBeUndefined(); + expect(parseOpenAiCompletionResponseChoice(1)).toBeUndefined(); + expect(parseOpenAiCompletionResponseChoice([])).toBeUndefined(); + }); + + }); + +}); diff --git a/openai/dto/OpenAiCompletionResponseChoice.ts b/openai/dto/OpenAiCompletionResponseChoice.ts new file mode 100644 index 0000000..2eb70cf --- /dev/null +++ b/openai/dto/OpenAiCompletionResponseChoice.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { + explainNumber, + explainNumberOrNullOrUndefined, + isNumber, + isNumberOrNullOrUndefined +} from "../../types/Number"; +import { explainString, isString } from "../../types/String"; +import { explain, explainOk, explainProperty } from "../../types/explain"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; +import { isOpenAiError, OpenAiError } from "./OpenAiError"; + +/** + * @typedef {Object} OpenAiCompletionResponseChoice + * + * A completion response item returned by the OpenAI API. + */ +export interface OpenAiCompletionResponseChoice { + + /** + * The completed text. + */ + readonly text: string; + readonly index : number; + readonly logprobs: number | null; + readonly finish_reason: string; + +} + +/** + * Creates an `OpenAiCompletionResponseChoice` object. + * + * @param {string} text - The completed text. + * @param {number} index - + * @param {number|null} logprobs - + * @param {string} finish_reason - + * @returns {OpenAiCompletionResponseChoice} The created `OpenAiCompletionResponseChoice` object. + */ +export function createOpenAiCompletionResponseChoice ( + text: string, + index: number, + logprobs: number|null, + finish_reason: string +) : OpenAiCompletionResponseChoice { + return { + text, + index, + logprobs: logprobs ?? null, + finish_reason + }; +} + +/** + * Check if the given value is a valid `OpenAiCompletionResponseChoice` object. + * + * @param {unknown} value - The value to check. + * @returns {value is OpenAiCompletionResponseChoice} `true` if the value is a valid `OpenAiCompletionResponseChoice` object, `false` otherwise. + */ +export function isOpenAiCompletionResponseChoice (value: unknown) : value is OpenAiCompletionResponseChoice { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'text', + 'index', + 'logprobs', + 'finish_reason' + ]) + && isString(value?.text) + && isNumber(value?.index) + && isNumberOrNullOrUndefined(value?.logprobs) + && isString(value?.finish_reason) + ); +} + +/** + * Attempts to explain why the given value is not a valid OpenAiCompletionResponseChoice object. + * + * @param {unknown} value - The value to explain. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiCompletionResponseChoice object. + */ +export function explainOpenAiCompletionResponseChoice (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'text', + 'index', + 'logprobs', + 'finish_reason' + ]) + , explainProperty("text", explainString(value?.text)) + , explainProperty("index", explainNumber(value?.index)) + , explainProperty("logprobs", explainNumberOrNullOrUndefined(value?.logprobs)) + , explainProperty("finish_reason", explainString(value?.finish_reason)) + ] + ); +} + +/** + * Check if the given value is a valid `OpenAiCompletionResponseChoice` object or an OpenAiError. + * + * @param {unknown} value - The value to check. + * @returns {value is OpenAiCompletionResponseChoice| OpenAiError} `true` if the value is a valid `OpenAiCompletionResponseChoice` object, `false` otherwise. + */ +export function isOpenAiCompletionResponseChoiceOrError (value: unknown) : value is (OpenAiCompletionResponseChoice | OpenAiError) { + return isOpenAiCompletionResponseChoice(value) || isOpenAiError(value); +} + +/** + * Attempts to explain why the given value is not a valid OpenAiCompletionResponseChoice or OpenAiError object. + * + * @param {unknown} value - The value to explain. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiCompletionResponseChoice or an OpenAiError object. + */ +export function explainOpenAiCompletionResponseChoiceOrError (value: any) : string { + return isOpenAiCompletionResponseChoiceOrError(value) ? explainOk() : 'Not OpenAiError or OpenAiCompletionResponseChoice'; +} + +/** + * Convert the given `OpenAiCompletionResponseChoice` object to a string. + * + * @param {OpenAiCompletionResponseChoice} value - The value to convert. + * @returns {string} A string representation of the `OpenAiCompletionResponseChoice` object. + */ +export function stringifyOpenAiCompletionResponseChoice (value : OpenAiCompletionResponseChoice) : string { + return `OpenAiCompletionResponseChoice(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiCompletionResponseChoice` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiCompletionResponseChoice|undefined} The parsed `OpenAiCompletionResponseChoice` object, or `undefined` if the value is not a valid `OpenAiCompletionResponseChoice` object. + */ +export function parseOpenAiCompletionResponseChoice (value: unknown) : OpenAiCompletionResponseChoice | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiCompletionResponseChoice(")) { + value = value.substring("OpenAiCompletionResponseChoice(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiCompletionResponseChoice(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiCompletionResponseDTO.test.ts b/openai/dto/OpenAiCompletionResponseDTO.test.ts new file mode 100644 index 0000000..290d269 --- /dev/null +++ b/openai/dto/OpenAiCompletionResponseDTO.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpenAiCompletionResponseChoice } from "./OpenAiCompletionResponseChoice"; +import { createOpenAiCompletionResponseDTO, isOpenAiCompletionResponseDTO } from "./OpenAiCompletionResponseDTO"; +import { OpenAiModel } from "../types/OpenAiModel"; +import { createOpenAiCompletionResponseUsage } from "./OpenAiCompletionResponseUsage"; + +describe("OpenAiCompletionResponseDTO", () => { + + describe('createOpenAiCompletionResponseDTO', () => { + it('creates a valid OpenAiCompletionResponseDTO object', () => { + const id = 'abc123'; + const object = 'text_completion'; + const created = 1589478378; + const model = OpenAiModel.DAVINCI; + const choices = [ + createOpenAiCompletionResponseChoice('It', 0, null, 'length'), + createOpenAiCompletionResponseChoice('is', 1, null, 'length'), + createOpenAiCompletionResponseChoice('raining', 2, null, 'length'), + createOpenAiCompletionResponseChoice('.', 3, null, 'length') + ]; + const usage = createOpenAiCompletionResponseUsage( + 1, + 2, + 3 + ); + + const result = createOpenAiCompletionResponseDTO( + id, + object, + created, + model, + choices, + usage + ); + + expect(result).toEqual({ + id, + object, + created, + model, + choices, + usage + }); + }); + }); + + describe("isOpenAiCompletionResponseDTO", () => { + + it("returns true for valid OpenAiCompletionResponseDTO objects", () => { + const validOpenAiCompletionResponseDTO = createOpenAiCompletionResponseDTO( + "response-id", + "text_completion", + 1589478378, + OpenAiModel.DAVINCI, + [ + createOpenAiCompletionResponseChoice("It's raining today", 0, null, 'length') + ], + createOpenAiCompletionResponseUsage( + 1, + 2, + 3 + ), + ); + expect(isOpenAiCompletionResponseDTO(validOpenAiCompletionResponseDTO)).toBe(true); + }); + + it("returns false for objects with missing properties", () => { + const incompleteOpenAiCompletionResponseDTO = { + id: "response-id", + model: "text-davinci-002", + prompt: "What's the weather like today?", + completions: ["It's raining today"], + tokens: ["It's raining today"] + }; + expect(isOpenAiCompletionResponseDTO(incompleteOpenAiCompletionResponseDTO)).toBe(false); + }); + + it("returns false for a value with an extra key", () => { + expect(isOpenAiCompletionResponseDTO( + { + id: "some-id", + object: "some-id", + created: 1234, + model: "davinci", + choices: [ { + text: "The meaning of life is 42", + index: 0, + logprobs: null, + finish_reason: 'length' + } ], + usage: createOpenAiCompletionResponseUsage( + 1, + 2, + 3 + ), + extraKey: "this should not be here" + } + )).toBe(false); + }); + + it("returns false for a value with a non-string id property", () => { + expect(isOpenAiCompletionResponseDTO({ + id: 123, + object: "some-id", + created: 1234, + model: "davinci", + choices: [ { + text: "The meaning of life is 42", + index: 0, + logprobs: null, + finish_reason: 'length' + } ], + usage: createOpenAiCompletionResponseUsage( + 1, + 2, + 3 + ) + })).toBe(false); + }); + + it("returns false for a value with a non-OpenAiApiModel model property", () => { + expect(isOpenAiCompletionResponseDTO( + { + id: "some-id", + created: 1589478378, + model: "not-a-model", + choices: [ + createOpenAiCompletionResponseChoice("response 1", 0, null, 'length'), + createOpenAiCompletionResponseChoice("response 2", 1, null, 'length') + ], + usage: createOpenAiCompletionResponseUsage( + 1, + 2, + 3 + ), + } + )).toBe(false); + }); + + it("returns false for a value with a non-OpenAiCompletionResponseChoice array choices property", () => { + expect(isOpenAiCompletionResponseDTO({ + id: 'id', + object: "some-id", + created: 1234, + model: "davinci", + choices: [ + { text: 'text1', score: 0.5, choices: ['choice1', 'choice2'] }, + { text: 'text2' } + ], + usage: createOpenAiCompletionResponseUsage( + 1, + 2, + 3 + ) + })).toBe(false); + }); + + it("returns false for a non-object value", () => { + expect(isOpenAiCompletionResponseDTO(null)).toBe(false); + expect(isOpenAiCompletionResponseDTO(undefined)).toBe(false); + expect(isOpenAiCompletionResponseDTO(true)).toBe(false); + expect(isOpenAiCompletionResponseDTO(false)).toBe(false); + expect(isOpenAiCompletionResponseDTO(123)).toBe(false); + expect(isOpenAiCompletionResponseDTO("abc")).toBe(false); + }); + + }); + +}); diff --git a/openai/dto/OpenAiCompletionResponseDTO.ts b/openai/dto/OpenAiCompletionResponseDTO.ts new file mode 100644 index 0000000..6c73b05 --- /dev/null +++ b/openai/dto/OpenAiCompletionResponseDTO.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + explainOpenAiCompletionResponseChoiceOrError, + isOpenAiCompletionResponseChoiceOrError, + OpenAiCompletionResponseChoice +} from "./OpenAiCompletionResponseChoice"; +import { OpenAiModel } from "../types/OpenAiModel"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../types/OtherKeys"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; +import { explainOpenAiCompletionResponseUsage, isOpenAiCompletionResponseUsage, OpenAiCompletionResponseUsage } from "./OpenAiCompletionResponseUsage"; +import { explainNumber, isNumber } from "../../types/Number"; +import { OpenAiError } from "./OpenAiError"; + +/** + * @typedef {Object} OpenAiCompletionResponseDTO + * + * The response to an OpenAI completion request. + */ +export interface OpenAiCompletionResponseDTO { + + /** + * The ID of the response. + */ + readonly id: string; + + /** + * + */ + readonly object: string; + + /** + * + */ + readonly created: number; + + /** + * The name of the model used to generate the response. + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-model + */ + readonly model: OpenAiModel | string; + + /** + */ + readonly choices: readonly (OpenAiCompletionResponseChoice| OpenAiError)[]; + + /** + * + */ + readonly usage : OpenAiCompletionResponseUsage; + + readonly warning ?: string; + +} + +/** + * Create a new `OpenAiCompletionResponseDTO` object. + * + * @param {string} id - The ID of the response. + * @param {string} object - + * @param {number} created - + * @param {OpenAiModel} model - The name of the model used to generate the response. + * @param {readonly OpenAiCompletionResponseChoice[]} choices - + * @param {OpenAiCompletionResponseUsage} usage - + * @returns {OpenAiCompletionResponseDTO} The new `OpenAiCompletionResponseDTO` object. + */ +export function createOpenAiCompletionResponseDTO ( + id: string, + object: string, + created: number, + model: OpenAiModel | string, + choices: readonly (OpenAiCompletionResponseChoice | OpenAiError)[], + usage: OpenAiCompletionResponseUsage +) : OpenAiCompletionResponseDTO { + return { + id, + object, + created, + model, + choices, + usage + }; +} + +/** + * Check if the given value is an `OpenAiCompletionResponseDTO` object. + * + * @param value - The value to check. + * @returns `true` if the value is a valid `OpenAiCompletionResponseDTO` object, `false` otherwise. + */ +export function isOpenAiCompletionResponseDTO (value: unknown) : value is OpenAiCompletionResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'object', + 'created', + 'model', + 'choices', + 'usage', + 'warning' + ]) + && isString(value?.id) + && isString(value?.object) + && isNumber(value?.created) + && isString(value?.model) + && isArrayOf(value?.choices, isOpenAiCompletionResponseChoiceOrError) + && isOpenAiCompletionResponseUsage(value?.usage) + && isStringOrUndefined(value?.warning) + ); +} + +/** + * Explain why a value is not a valid OpenAiCompletionResponseDTO object. + * + * @param {any} value - The value to check. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiCompletionResponseDTO object. + */ +export function explainOpenAiCompletionResponseDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'id', + 'object', + 'created', + 'model', + 'choices', + 'usage', + 'warning' + ]) + , explainProperty("id", explainString(value?.id)) + , explainProperty("object", explainString(value?.object)) + , explainProperty("created", explainNumber(value?.created)) + , explainProperty("model", explainString(value?.model)) + , explainProperty("choices", explainArrayOf( + "OpenAiCompletionResponseChoice|OpenAiError", + explainOpenAiCompletionResponseChoiceOrError, + value?.choices, + isOpenAiCompletionResponseChoiceOrError + )) + , explainProperty("usage", explainOpenAiCompletionResponseUsage(value?.usage)) + , explainProperty("warning", explainStringOrUndefined(value?.warning)) + ] + ); +} + +/** + * Convert the given `OpenAiCompletionResponseDTO` object to a string. + * + * @param {OpenAiCompletionResponseDTO} value - The value to convert. + * @returns {string} A string representation of the `OpenAiCompletionResponseDTO` object. + */ +export function stringifyOpenAiCompletionResponseDTO (value : OpenAiCompletionResponseDTO) : string { + return `OpenAiCompletionResponseDTO(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiCompletionResponseDTO` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiCompletionResponseDTO|undefined} The parsed `OpenAiCompletionResponseDTO` object, or `undefined` if the value is not a valid `OpenAiCompletionResponseDTO` object. + */ +export function parseOpenAiCompletionResponseDTO (value: unknown) : OpenAiCompletionResponseDTO | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiCompletionResponseDTO(")) { + value = value.substring("OpenAiCompletionResponseDTO(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiCompletionResponseDTO(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiCompletionResponseUsage.test.ts b/openai/dto/OpenAiCompletionResponseUsage.test.ts new file mode 100644 index 0000000..46b0715 --- /dev/null +++ b/openai/dto/OpenAiCompletionResponseUsage.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiCompletionResponseUsage, + explainOpenAiCompletionResponseUsage, + isOpenAiCompletionResponseUsage, + OpenAiCompletionResponseUsage, + parseOpenAiCompletionResponseUsage, + stringifyOpenAiCompletionResponseUsage +} from "./OpenAiCompletionResponseUsage"; + +describe("OpenAiCompletionResponseUsage", () => { + + describe("createOpenAiCompletionResponseUsage", () => { + it("creates a valid OpenAiCompletionResponseUsage object", () => { + const item1: OpenAiCompletionResponseUsage = createOpenAiCompletionResponseUsage( + 1, + 2, + 3 + ); + expect(item1).toEqual({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + }); + + const item2: OpenAiCompletionResponseUsage = createOpenAiCompletionResponseUsage( + 2, + 3, + 4 + ); + expect(item2).toEqual({ + prompt_tokens: 2, + completion_tokens: 3, + total_tokens: 4 + }); + }); + }); + + describe('isOpenAiCompletionResponseUsage', () => { + it('returns true for valid OpenAiCompletionResponseUsage objects', () => { + expect(isOpenAiCompletionResponseUsage({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + })).toBe(true); + }); + + it('returns false for invalid OpenAiCompletionResponseUsage objects', () => { + // not an object + expect(isOpenAiCompletionResponseUsage('invalid')).toBe(false); + // extra keys + expect(isOpenAiCompletionResponseUsage({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3, + extraKey: 'extra value' + })).toBe(false); + // non-number prompt_tokens + expect(isOpenAiCompletionResponseUsage({ + prompt_tokens: '1', + completion_tokens: 2, + total_tokens: 3 + })).toBe(false); + // non-number completion_tokens + expect(isOpenAiCompletionResponseUsage({ + prompt_tokens: 1, + completion_tokens: '2', + total_tokens: 3 + })).toBe(false); + // non-number total_tokens + expect(isOpenAiCompletionResponseUsage({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: '3' + })).toBe(false); + }); + }); + + describe("explainOpenAiCompletionResponseUsage", () => { + + it("returns a human-readable string explaining why the value has extra keys", () => { + const value = { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3, + extraKey: "This is an extra key" + }; + expect(explainOpenAiCompletionResponseUsage(value)).toEqual( + "Value had extra properties: extraKey" + ); + }); + + it("returns a human-readable string explaining why the value has a non-number text property", () => { + expect(explainOpenAiCompletionResponseUsage({ + prompt_tokens: "1", + completion_tokens: 2, + total_tokens: 3 + })).toEqual( + // "Object has a non-number property 'prompt_tokens' ("1")" + expect.stringContaining('property "prompt_tokens" not number') + ); + }); + + it("returns a human-readable string explaining why the value has a non-number completion_tokens property", () => { + expect(explainOpenAiCompletionResponseUsage({ + prompt_tokens: 1, + completion_tokens: "not a number", + total_tokens: 3 + })).toEqual( + // "Expected property 'completion_tokens' to be a number, but got string 'not a number' instead" + expect.stringContaining('property "completion_tokens" not number') + ); + }); + + it("returns a human-readable string explaining why the value is not a valid OpenAiCompletionResponseUsage", () => { + expect(explainOpenAiCompletionResponseUsage({ extra: "key" })).toMatch(/Value had extra properties: extra/); + expect(explainOpenAiCompletionResponseUsage("foo")).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(undefined)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(null)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(true)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(false)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(0)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(1)).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage("")).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage("test")).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage([])).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(["test"])).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(() => {})).toMatch(/not regular object/); + expect(explainOpenAiCompletionResponseUsage(Symbol())).toMatch(/not regular object/); + }); + + }); + + describe("stringifyOpenAiCompletionResponseUsage", () => { + it("should return the string 'OpenAiCompletionResponseUsage({...})'", () => { + const value: OpenAiCompletionResponseUsage = { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + }; + const expected = "OpenAiCompletionResponseUsage({\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3})"; + const result = stringifyOpenAiCompletionResponseUsage(value); + expect(result).toEqual(expected); + }); + }); + + describe("parseOpenAiCompletionResponseUsage", () => { + + it("parses a valid OpenAiCompletionResponseUsage string representation", () => { + const string = `OpenAiCompletionResponseUsage({"prompt_tokens":1,"completion_tokens":2,"total_tokens":3})`; + const value = parseOpenAiCompletionResponseUsage(string); + expect(value).toEqual({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + }); + }); + + it("parses a valid OpenAiCompletionResponseUsage object", () => { + const object = { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + }; + const value = parseOpenAiCompletionResponseUsage(object); + expect(value).toEqual(object); + }); + + it("returns undefined for an invalid OpenAiCompletionResponseUsage string representation", () => { + const string = `OpenAiCompletionResponseUsage({"invalid":true})`; + const value = parseOpenAiCompletionResponseUsage(string); + expect(value).toBeUndefined(); + }); + + it("returns undefined for a non-OpenAiCompletionResponseUsage object", () => { + const object = { + invalid: true + }; + const value = parseOpenAiCompletionResponseUsage(object); + expect(value).toBeUndefined(); + }); + + it("returns undefined for non-string, non-object values", () => { + expect(parseOpenAiCompletionResponseUsage(null)).toBeUndefined(); + expect(parseOpenAiCompletionResponseUsage(true)).toBeUndefined(); + expect(parseOpenAiCompletionResponseUsage(1)).toBeUndefined(); + expect(parseOpenAiCompletionResponseUsage([])).toBeUndefined(); + }); + + }); + +}); diff --git a/openai/dto/OpenAiCompletionResponseUsage.ts b/openai/dto/OpenAiCompletionResponseUsage.ts new file mode 100644 index 0000000..5f74a0b --- /dev/null +++ b/openai/dto/OpenAiCompletionResponseUsage.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainNumber, isNumber } from "../../types/Number"; +import { isString } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; + +/** + * @typedef {Object} OpenAiCompletionResponseUsage + * + * Property "usage" from the completion response item returned by the OpenAI API. + */ +export interface OpenAiCompletionResponseUsage { + + /** + */ + readonly prompt_tokens: number; + + /** + */ + readonly completion_tokens: number; + + /** + */ + readonly total_tokens: number; + +} + +/** + * Creates an `OpenAiCompletionResponseUsage` object. + * + * @param {number} prompt_tokens - + * @param {number} completion_tokens - + * @param {number} total_tokens - + * @returns {OpenAiCompletionResponseUsage} The created `OpenAiCompletionResponseUsage` object. + */ +export function createOpenAiCompletionResponseUsage ( + prompt_tokens: number, + completion_tokens: number, + total_tokens: number +) : OpenAiCompletionResponseUsage { + return { + prompt_tokens, + completion_tokens, + total_tokens + }; +} + +/** + * Check if the given value is a valid `OpenAiCompletionResponseUsage` object. + * + * @param {unknown} value - The value to check. + * @returns {value is OpenAiCompletionResponseUsage} `true` if the value is a valid `OpenAiCompletionResponseUsage` object, `false` otherwise. + */ +export function isOpenAiCompletionResponseUsage (value: unknown) : value is OpenAiCompletionResponseUsage { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'prompt_tokens', + 'completion_tokens', + 'total_tokens' + ]) + && isNumber(value?.prompt_tokens) + && isNumber(value?.completion_tokens) + && isNumber(value?.total_tokens) + ); +} + +/** + * Attempts to explain why the given value is not a valid OpenAiCompletionResponseUsage object. + * + * @param {unknown} value - The value to explain. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiCompletionResponseUsage object. + */ +export function explainOpenAiCompletionResponseUsage (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'prompt_tokens', + 'completion_tokens', + 'total_tokens' + ]) + , explainProperty("prompt_tokens", explainNumber(value?.prompt_tokens)) + , explainProperty("completion_tokens", explainNumber(value?.completion_tokens)) + , explainProperty("total_tokens", explainNumber(value?.total_tokens)) + ] + ); +} + +/** + * Convert the given `OpenAiCompletionResponseUsage` object to a string. + * + * @param {OpenAiCompletionResponseUsage} value - The value to convert. + * @returns {string} A string representation of the `OpenAiCompletionResponseUsage` object. + */ +export function stringifyOpenAiCompletionResponseUsage (value : OpenAiCompletionResponseUsage) : string { + return `OpenAiCompletionResponseUsage(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiCompletionResponseUsage` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiCompletionResponseUsage|undefined} The parsed `OpenAiCompletionResponseUsage` object, or `undefined` if the value is not a valid `OpenAiCompletionResponseUsage` object. + */ +export function parseOpenAiCompletionResponseUsage (value: unknown) : OpenAiCompletionResponseUsage | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiCompletionResponseUsage(")) { + value = value.substring("OpenAiCompletionResponseUsage(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiCompletionResponseUsage(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiEditRequestDTO.test.ts b/openai/dto/OpenAiEditRequestDTO.test.ts new file mode 100644 index 0000000..a07ded9 --- /dev/null +++ b/openai/dto/OpenAiEditRequestDTO.test.ts @@ -0,0 +1,423 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpenAiEditRequestDTO, createOpenAiEditRequestDTO, isOpenAiEditRequestDTO, explainOpenAiEditRequestDTO, stringifyOpenAiEditRequestDTO, parseOpenAiEditRequestDTO } from "./OpenAiEditRequestDTO"; +import { OpenAiModel } from "../types/OpenAiModel"; + +describe("OpenAiEditRequestDTO", () => { + + describe("createOpenAiEditRequestDTO", () => { + + it("creates valid OpenAiEditRequestDTO objects", () => { + const request1: OpenAiEditRequestDTO = createOpenAiEditRequestDTO( + "Fix the spelling mistakes", + "What day of the wek is it?", + OpenAiModel.DAVINCI_EDIT_TEXT, + 1, + 2, + 3 + ); + expect(request1).toEqual({ + instruction: "Fix the spelling mistakes", + input: "What day of the wek is it?", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 1, + temperature: 2, + top_p: 3 + }); + + const request2: OpenAiEditRequestDTO = createOpenAiEditRequestDTO( + "What is the capital city of France?", + "", + OpenAiModel.DAVINCI_EDIT_TEXT, + 1, + 0.5, + 0.7 + ); + expect(request2).toEqual({ + instruction: "What is the capital city of France?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 1, + temperature: 0.5, + top_p: 0.7 + }); + }); + + it("throws an error if instruction is not a string", () => { + expect(() => createOpenAiEditRequestDTO( + // @ts-ignore + undefined, // this should throw an error + '', + OpenAiModel.DAVINCI_EDIT_TEXT, + 10, + 0.5, + 0.9 + )).toThrowError(); + + expect(() => createOpenAiEditRequestDTO( + // @ts-ignore + 123, // this should throw an error + '', + OpenAiModel.DAVINCI_EDIT_TEXT, + 10, + 0.5, + 0.9 + )).toThrowError(); + }); + + it("can have models which are not a valid OpenAiApiModel", () => { + expect(() => createOpenAiEditRequestDTO( + "What is the weather like today?", + '', + "invalid-model" as OpenAiModel, // this should work + 10, + 0.5, + 0.9 + )).not.toThrowError(); + }); + + it("throws an error if n is not a number", () => { + expect(() => createOpenAiEditRequestDTO( + "What is the weather like today?", + "", + OpenAiModel.DAVINCI_EDIT_TEXT, + "10" as any, // this should throw an error + 0.5, + 0.9 + )).toThrowError(); + }); + + it("throws an error if temperature is not a number", () => { + expect(() => createOpenAiEditRequestDTO( + "What is the weather like today?", + "", + OpenAiModel.DAVINCI_EDIT_TEXT, + 10, + "0.5" as any, // this should throw an error + 0.9 + )).toThrowError(); + }); + + it("throws an error if top_p is not a number", () => { + expect(() => createOpenAiEditRequestDTO( + "What is the weather like today?", + "", + OpenAiModel.DAVINCI_EDIT_TEXT, + 10, + 0.5, + "0.9" as any // this should throw an error + )).toThrowError(); + }); + + }); + + describe("isOpenAiEditRequestDTO", () => { + + it("returns true for valid OpenAiEditRequestDTO objects", () => { + const request1: OpenAiEditRequestDTO = { + instruction: "What is the weather like today?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9 + }; + expect(isOpenAiEditRequestDTO(request1)).toBe(true); + + const request2: OpenAiEditRequestDTO = { + instruction: "What is the capital city of France?", + input: '', + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 15, + temperature: 0.7, + top_p: 0.8 + }; + expect(isOpenAiEditRequestDTO(request2)).toBe(true); + }); + + it("returns false for non-object values", () => { + expect(isOpenAiEditRequestDTO(null)).toBe(false); + expect(isOpenAiEditRequestDTO(undefined)).toBe(false); + expect(isOpenAiEditRequestDTO(true)).toBe(false); + expect(isOpenAiEditRequestDTO(false)).toBe(false); + expect(isOpenAiEditRequestDTO(0)).toBe(false); + expect(isOpenAiEditRequestDTO(1)).toBe(false); + expect(isOpenAiEditRequestDTO("")).toBe(false); + expect(isOpenAiEditRequestDTO("hello")).toBe(false); + }); + + it("returns false for invalid OpenAiEditRequestDTO objects", () => { + expect(isOpenAiEditRequestDTO(undefined)).toBe(false); + expect(isOpenAiEditRequestDTO(null)).toBe(false); + expect(isOpenAiEditRequestDTO(false)).toBe(false); + expect(isOpenAiEditRequestDTO(true)).toBe(false); + expect(isOpenAiEditRequestDTO("string")).toBe(false); + expect(isOpenAiEditRequestDTO(123)).toBe(false); + expect(isOpenAiEditRequestDTO( + {})).toBe(false); + expect(isOpenAiEditRequestDTO( + {instruction: 123})).toBe(false); + expect(isOpenAiEditRequestDTO( + {input: 123})).toBe(false); + expect(isOpenAiEditRequestDTO( + {model: 123})).toBe(false); + expect(isOpenAiEditRequestDTO( + {n: "string"})).toBe(false); + expect(isOpenAiEditRequestDTO( + {temperature: "string"})).toBe(false); + expect(isOpenAiEditRequestDTO( + {top_p: "string"})).toBe(false); + }); + + it("returns false for objects with extra keys", () => { + expect(isOpenAiEditRequestDTO( + { + instruction: "What is the weather like today?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9, + extraKey: "extra value" + } + )).toBe(false); + }); + + it("returns false for objects with non-string instruction value", () => { + expect(isOpenAiEditRequestDTO( + { + instruction: 123, + input: '', + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9 + } + )).toBe(false); + expect(isOpenAiEditRequestDTO( + { + instruction: null, + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9 + } + )).toBe(false); + expect(isOpenAiEditRequestDTO( + { + instruction: undefined, + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9 + } + )).toBe(false); + }); + + it("returns false for objects with non-OpenAiApiModel model value", () => { + const invalidModel: any = "invalid model"; + expect(isOpenAiEditRequestDTO( + { + instruction: "What is the weather like today?", + input: '', + model: invalidModel, + n: 10, + temperature: 0.5, + top_p: 0.9 + } + )).toBe(false); + }); + + it("returns false for objects with non-number n value", () => { + expect(isOpenAiEditRequestDTO( + { + instruction: "What is the weather like today?", + input: '', + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: "10", // invalid value + temperature: 0.5, + top_p: 0.9 + } + )).toBe(false); + }); + + it("returns false for objects with non-number temperature value", () => { + expect(isOpenAiEditRequestDTO( + { + instruction: "What is the weather like today?", + input: '', + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: "0.5", + top_p: 0.9 + })).toBe(false); + }); + + it("returns false for objects with non-number top_p value", () => { + expect(isOpenAiEditRequestDTO( + { + instruction: "What is the capital city of France?", + input: '', + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 15, + temperature: 0.7, + top_p: "0.8" + })).toBe(false); + }); + + }); + + describe("explainOpenAiEditRequestDTO", () => { + + it("returns a human-readable string explaining why the value is not a regular object", () => { + expect(explainOpenAiEditRequestDTO(undefined)).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO(null)).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO(false)).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO(true)).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO(0)).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO(1)).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO("")).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO("foo")).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO([])).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO([ 1, 2, 3 ])).toMatch(/not regular object/); + expect(explainOpenAiEditRequestDTO(() => { + })).toMatch(/not regular object/); + }); + + it("returns a human-readable string explaining why the value has extra keys", () => { + expect(explainOpenAiEditRequestDTO( + { + instruction: "What is the capital city of France?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 15, + temperature: 0.7, + top_p: 0.8, + extraKey: "this shouldn't be here" + })).toBe("Value had extra properties: extraKey"); + }); + + it("returns a human-readable string explaining why the value has a non-string instruction property", () => { + expect(explainOpenAiEditRequestDTO( + { + instruction: 12345, + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9 + })).toEqual( + // TODO: 'value has a property "instruction" with invalid value: expected string, got number' + expect.stringContaining('property "instruction" not string') + ); + }); + + it("returns a human-readable string explaining why the value has a non-OpenAiApiModel model property", () => { + expect(explainOpenAiEditRequestDTO( + {instruction: "What is the weather like today?", model: "not a model"})).toMatch( + // 'incorrect enum value "not a model" for model: Accepted values davinci, curie, babbage, ada, eliza, einstein' + /incorrect enum value/ + ); + }); + + it('returns a human-readable string explaining why the value has a non-number n property', () => { + const nonNumberMaxTokensValues = [ null, true, false, '5', [], {} ]; + for ( const nonNumberMaxTokensValue of nonNumberMaxTokensValues ) { + const value = { + instruction: 'What is the weather like today?', + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: nonNumberMaxTokensValue, + temperature: 0.5, + top_p: 0.9 + }; + expect(explainOpenAiEditRequestDTO(value)).toMatch(/property "n" not number/); + } + }); + + it("returns a human-readable string explaining why the value has a non-number temperature property", () => { + const value = { + instruction: "What is the capital city of France?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 15, + temperature: "0.7", // This value is a string, not a number + top_p: 0.8 + }; + + expect(explainOpenAiEditRequestDTO(value)).toEqual( + // TODO: 'incorrect property value for temperature: expected a number, but got a string' + expect.stringContaining('property "temperature" not number or undefined') + ); + }); + + it("returns a human-readable string explaining why the value has a non-number top_p property", () => { + const value = { + instruction: "What is the weather like today?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: "0.9" // top_p is a string, not a number + }; + const result = explainOpenAiEditRequestDTO(value); + // TODO: const expected = `invalid OpenAiEditRequestDTO: top_p: expected a number, got string`; + const expected = `property "top_p" not number or undefined`; + expect(result).toEqual(expect.stringContaining(expected)); + }); + + }); + + describe("stringifyOpenAiEditRequestDTO", () => { + + it("returns a string representation of the OpenAiEditRequestDTO object", () => { + const request: OpenAiEditRequestDTO = { + instruction: "What is the weather like today?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9 + }; + expect(stringifyOpenAiEditRequestDTO(request)).toEqual(`OpenAiEditRequestDTO(${JSON.stringify(request)})`); + }); + + }); + + describe("parseOpenAiEditRequestDTO", () => { + + it("parses a valid OpenAiEditRequestDTO string as an OpenAiEditRequestDTO object", () => { + const request: OpenAiEditRequestDTO = { + instruction: "What is the weather like today?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9 + }; + const requestString: string = `OpenAiEditRequestDTO(${JSON.stringify(request)})`; + expect(parseOpenAiEditRequestDTO(requestString)).toEqual(request); + }); + + it("parses a valid JSON string as an OpenAiEditRequestDTO object", () => { + const request: OpenAiEditRequestDTO = { + instruction: "What is the weather like today?", + input: "", + model: OpenAiModel.DAVINCI_EDIT_TEXT, + n: 10, + temperature: 0.5, + top_p: 0.9 + }; + const requestString: string = `${JSON.stringify(request)}`; + expect(parseOpenAiEditRequestDTO(requestString)).toEqual(request); + }); + + it("returns undefined for invalid OpenAiEditRequestDTO strings", () => { + expect(parseOpenAiEditRequestDTO("invalid string")).toBeUndefined(); + expect(parseOpenAiEditRequestDTO("OpenAiEditRequestDTO(invalid json)")).toBeUndefined(); + }); + }); + +}); diff --git a/openai/dto/OpenAiEditRequestDTO.ts b/openai/dto/OpenAiEditRequestDTO.ts new file mode 100644 index 0000000..02ed04f --- /dev/null +++ b/openai/dto/OpenAiEditRequestDTO.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpenAiModel, isOpenAiModel, OpenAiModel } from "../types/OpenAiModel"; +import { explain, explainProperty } from "../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explainNumberOrUndefined, isNumber, isNumberOrUndefined } from "../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../types/OtherKeys"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; + +/** + * Data Transfer Object for requesting an edit from the OpenAI API. + * + * @see https://beta.openai.com/docs/api-reference/edits + */ +export interface OpenAiEditRequestDTO { + + /** + * @see https://beta.openai.com/docs/api-reference/edits/create#edits/create-instruction + */ + readonly instruction : string; + + /** + * The model to use for edit. + * + * @see https://beta.openai.com/docs/api-reference/edits/create#edits/create-model + */ + readonly model : OpenAiModel | string; + + /** + * The input text to use as the starting point for the edit. + * + * Defaults to `""` + * + * @see https://beta.openai.com/docs/api-reference/edits/create#edits/create-input + */ + readonly input ?: string; + + /** + * How many edits to generate for each prompt. + * + * Defaults to 1 + * + * @see https://beta.openai.com/docs/api-reference/edits/create#edits/create-n + */ + readonly n ?: number; + + /** + * The temperature to use for sampling. + * + * Defaults to `1` + * + * @see https://beta.openai.com/docs/api-reference/edits/create#edits/create-temperature + */ + readonly temperature ?: number; + + /** + * The top probability to use for sampling. + * + * Defaults to `1` + * + * @see https://beta.openai.com/docs/api-reference/edits/create#edits/create-top_p + */ + readonly top_p ?: number; + +} + +/** + * Create an `OpenAiEditRequestDTO` object with the given properties. + * + * @param {string} instruction - How to edit the text + * @param {string} input - Input text as the starting point for edit + * @param {OpenAiModel} model - The model to use for edit. + * @param {number} n - How many edits + * @param {number} temperature - The temperature to use for sampling. + * @param {number} top_p - The top probability to use for sampling. + * @returns {OpenAiEditRequestDTO} An `OpenAiEditRequestDTO` object with the given properties. + */ +export function createOpenAiEditRequestDTO ( + instruction : string, + input ?: string, + model ?: OpenAiModel | string, + n ?: number, + temperature ?: number, + top_p ?: number +) : OpenAiEditRequestDTO { + if (!isString(instruction)) throw new TypeError(`Invalid OpenAiEditRequestDTO.instruction: ${instruction}`); + if (!isStringOrUndefined(input)) throw new TypeError(`Invalid OpenAiEditRequestDTO.input: ${input}`); + if (!isStringOrUndefined(model)) throw new TypeError(`Invalid OpenAiEditRequestDTO.model: ${model}`); + if (!isNumberOrUndefined(n)) throw new TypeError(`Invalid OpenAiEditRequestDTO.n: ${n}`); + if (!isNumberOrUndefined(temperature)) throw new TypeError(`Invalid OpenAiEditRequestDTO.temperature: ${temperature}`); + if (!isNumberOrUndefined(top_p)) throw new TypeError(`Invalid OpenAiEditRequestDTO.top_p: ${top_p}`); + return { + model : model ?? OpenAiModel.DAVINCI_EDIT_TEXT, + instruction, + ...(isString(input) ? {input} : {}), + ...(isNumber(n) ? {n} : {}), + ...(isNumber(temperature) ? {temperature} : {}), + ...(isNumber(top_p) ? {top_p} : {}) + }; +} + +/** + * Test whether the given value is an `OpenAiEditRequestDTO` object. + * + * @param {unknown} value - The value to test. + * @returns {value is OpenAiEditRequestDTO} `true` if the value is an `OpenAiEditRequestDTO` object, `false` otherwise. + */ +export function isOpenAiEditRequestDTO (value: any) : value is OpenAiEditRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'instruction', + 'input', + 'model', + 'n', + 'temperature', + 'top_p' + ]) + && isOpenAiModel(value?.model) + && isString(value?.instruction) + && isStringOrUndefined(value?.input) + && isNumberOrUndefined(value?.n) + && isNumberOrUndefined(value?.temperature) + && isNumberOrUndefined(value?.top_p) + ); +} + +/** + * Explain why the given value is not an `OpenAiEditRequestDTO` object. + * + * @param {unknown} value - The value to test. + * @returns {string} A human-readable message explaining why the value is not an `OpenAiEditRequestDTO` object, or `'ok'` if it is. + */ +export function explainOpenAiEditRequestDTO (value: unknown) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'instruction', + 'input', + 'model', + 'n', + 'temperature', + 'top_p' + ]) + , explainProperty("instruction", explainString((value as any)?.instruction)) + , explainProperty("input", explainStringOrUndefined((value as any)?.input)) + , explainProperty("model", explainOpenAiModel((value as any)?.model)) + , explainProperty("n", explainNumberOrUndefined((value as any)?.n)) + , explainProperty("temperature", explainNumberOrUndefined((value as any)?.temperature)) + , explainProperty("top_p", explainNumberOrUndefined((value as any)?.top_p)) + ] + ); +} + +/** + * Convert the given `OpenAiEditRequestDTO` object to a string. + * + * @param {OpenAiEditRequestDTO} value - The value to convert. + * @returns {string} A string representation of the `OpenAiEditRequestDTO` object. + */ +export function stringifyOpenAiEditRequestDTO (value : OpenAiEditRequestDTO) : string { + return `OpenAiEditRequestDTO(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiEditRequestDTO` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiEditRequestDTO|undefined} The parsed `OpenAiEditRequestDTO` object, or `undefined` if the value is not a valid `OpenAiEditRequestDTO` object. + */ +export function parseOpenAiEditRequestDTO (value: unknown) : OpenAiEditRequestDTO | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiEditRequestDTO(")) { + value = value.substring("OpenAiEditRequestDTO(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiEditRequestDTO(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiEditResponseChoice.test.ts b/openai/dto/OpenAiEditResponseChoice.test.ts new file mode 100644 index 0000000..6eaf6bf --- /dev/null +++ b/openai/dto/OpenAiEditResponseChoice.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiEditResponseChoice, + explainOpenAiEditResponseChoice, + isOpenAiEditResponseChoice, + OpenAiEditResponseChoice, + parseOpenAiEditResponseChoice, + stringifyOpenAiEditResponseChoice +} from "./OpenAiEditResponseChoice"; + +describe("OpenAiEditResponseChoice", () => { + + describe("createOpenAiEditResponseChoice", () => { + it("creates a valid OpenAiEditResponseChoice object", () => { + const item1: OpenAiEditResponseChoice = createOpenAiEditResponseChoice( + "completed text", + 0, + null, + 'length' + ); + expect(item1).toEqual({ + text: "completed text", + index: 0, + logprobs: null, + finish_reason: 'length' + }); + + const item2: OpenAiEditResponseChoice = createOpenAiEditResponseChoice( + "more completed text", + 1, + 3, + 'length' + ); + expect(item2).toEqual({ + text: "more completed text", + index: 1, + logprobs: 3, + finish_reason: 'length' + }); + }); + }); + + describe('isOpenAiEditResponseChoice', () => { + it('returns true for valid OpenAiEditResponseChoice objects', () => { + expect(isOpenAiEditResponseChoice({ + text: 'This is a test', + index: 0, + logprobs: null, + finish_reason: 'length' + })).toBe(true); + }); + + it('returns false for invalid OpenAiEditResponseChoice objects', () => { + // not an object + expect(isOpenAiEditResponseChoice('invalid')).toBe(false); + // extra keys + expect(isOpenAiEditResponseChoice({ + text: 'This is a test', + index: 0, + logprobs: null, + finish_reason: 'length', + extraKey: 'extra value' + })).toBe(false); + // non-string text + expect(isOpenAiEditResponseChoice({ + text: ['This is a test'], + index: 0, + logprobs: null, + finish_reason: 'length' + })).toBe(false); + // non-number index + expect(isOpenAiEditResponseChoice({ + text: 'This is a test', + index: '0', + logprobs: null, + finish_reason: 'length' + })).toBe(false); + }); + }); + + describe("explainOpenAiEditResponseChoice", () => { + + it("returns a human-readable string explaining why the value has extra keys", () => { + const value = { + text: "This is a text", + index: 0, + logprobs: null, + finish_reason: 'length', + extraKey: "This is an extra key" + }; + expect(explainOpenAiEditResponseChoice(value)).toEqual( + "Value had extra properties: extraKey" + ); + }); + + it("returns a human-readable string explaining why the value has a non-string text property", () => { + expect(explainOpenAiEditResponseChoice({ + text: 5, + index: 0, + logprobs: null, + finish_reason: 'length' + })).toEqual( + // "Object has a non-string property 'text' (5)" + expect.stringContaining('property "text" not string') + ); + }); + + it("returns a human-readable string explaining why the value has a non-number index property", () => { + expect(explainOpenAiEditResponseChoice({ + text: "Some text", + index: "not a number", + logprobs: null, + finish_reason: 'length' + })).toEqual( + // "Expected property 'index' to be a number, but got string 'not a number' instead" + expect.stringContaining('property "index" not number') + ); + }); + + it("returns a human-readable string explaining why the value is not a valid OpenAiEditResponseChoice", () => { + expect(explainOpenAiEditResponseChoice({ extra: "key" })).toMatch(/Value had extra properties: extra/); + expect(explainOpenAiEditResponseChoice("foo")).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(undefined)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(null)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(true)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(false)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(0)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(1)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice("")).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice("test")).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice([])).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(["test"])).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(() => {})).toMatch(/not regular object/); + expect(explainOpenAiEditResponseChoice(Symbol())).toMatch(/not regular object/); + }); + + }); + + describe("stringifyOpenAiEditResponseChoice", () => { + it("should return the string 'OpenAiEditResponseChoice({...})'", () => { + const value: OpenAiEditResponseChoice = { + text: "This is a test", + index: 2, + logprobs: null, + finish_reason: 'length' + }; + const expected = "OpenAiEditResponseChoice({\"text\":\"This is a test\",\"index\":2,\"logprobs\":null,\"finish_reason\":\"length\"})"; + const result = stringifyOpenAiEditResponseChoice(value); + expect(result).toEqual(expected); + }); + }); + + describe("parseOpenAiEditResponseChoice", () => { + + it("parses a valid OpenAiEditResponseChoice string representation", () => { + const string = `OpenAiEditResponseChoice({"text":"text","index":1,"logprobs":null,"finish_reason":"length"})`; + const value = parseOpenAiEditResponseChoice(string); + expect(value).toEqual({ + text: "text", + index: 1, + logprobs: null, + finish_reason: 'length' + }); + }); + + it("parses a valid OpenAiEditResponseChoice object", () => { + const object = { + text: "text", + index: 1, + logprobs: null, + finish_reason: 'length' + }; + const value = parseOpenAiEditResponseChoice(object); + expect(value).toEqual(object); + }); + + it("returns undefined for an invalid OpenAiEditResponseChoice string representation", () => { + const string = `OpenAiEditResponseChoice({"invalid":true})`; + const value = parseOpenAiEditResponseChoice(string); + expect(value).toBeUndefined(); + }); + + it("returns undefined for a non-OpenAiEditResponseChoice object", () => { + const object = { + invalid: true + }; + const value = parseOpenAiEditResponseChoice(object); + expect(value).toBeUndefined(); + }); + + it("returns undefined for non-string, non-object values", () => { + expect(parseOpenAiEditResponseChoice(null)).toBeUndefined(); + expect(parseOpenAiEditResponseChoice(true)).toBeUndefined(); + expect(parseOpenAiEditResponseChoice(1)).toBeUndefined(); + expect(parseOpenAiEditResponseChoice([])).toBeUndefined(); + }); + + }); + +}); diff --git a/openai/dto/OpenAiEditResponseChoice.ts b/openai/dto/OpenAiEditResponseChoice.ts new file mode 100644 index 0000000..2a59c79 --- /dev/null +++ b/openai/dto/OpenAiEditResponseChoice.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { + explainNumber, + explainNumberOrNullOrUndefined, + isNumber, + isNumberOrNullOrUndefined +} from "../../types/Number"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; + +/** + * @typedef {Object} OpenAiEditResponseChoice + * + * A completion response item returned by the OpenAI API. + */ +export interface OpenAiEditResponseChoice { + + /** + * The completed text. + */ + readonly text: string; + readonly index : number; + readonly logprobs ?: number | null; + readonly finish_reason ?: string; + +} + +/** + * Creates an `OpenAiEditResponseChoice` object. + * + * @param {string} text - The completed text. + * @param {number} index - + * @param {number|null} logprobs - + * @param {string} finish_reason - + * @returns {OpenAiEditResponseChoice} The created `OpenAiEditResponseChoice` object. + */ +export function createOpenAiEditResponseChoice ( + text: string, + index: number, + logprobs ?: number|null, + finish_reason ?: string +) : OpenAiEditResponseChoice { + return { + text, + index, + logprobs, + finish_reason + }; +} + +/** + * Check if the given value is a valid `OpenAiEditResponseChoice` object. + * + * @param {unknown} value - The value to check. + * @returns {value is OpenAiEditResponseChoice} `true` if the value is a valid `OpenAiEditResponseChoice` object, `false` otherwise. + */ +export function isOpenAiEditResponseChoice (value: unknown) : value is OpenAiEditResponseChoice { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'text', + 'index', + 'logprobs', + 'finish_reason' + ]) + && isString(value?.text) + && isNumber(value?.index) + && isNumberOrNullOrUndefined(value?.logprobs) + && isStringOrUndefined(value?.finish_reason) + ); +} + +/** + * Attempts to explain why the given value is not a valid OpenAiEditResponseChoice object. + * + * @param {unknown} value - The value to explain. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiEditResponseChoice object. + */ +export function explainOpenAiEditResponseChoice (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'text', + 'index', + 'logprobs', + 'finish_reason' + ]) + , explainProperty("text", explainString(value?.text)) + , explainProperty("index", explainNumber(value?.index)) + , explainProperty("logprobs", explainNumberOrNullOrUndefined(value?.logprobs)) + , explainProperty("finish_reason", explainStringOrUndefined(value?.finish_reason)) + ] + ); +} + +/** + * Convert the given `OpenAiEditResponseChoice` object to a string. + * + * @param {OpenAiEditResponseChoice} value - The value to convert. + * @returns {string} A string representation of the `OpenAiEditResponseChoice` object. + */ +export function stringifyOpenAiEditResponseChoice (value : OpenAiEditResponseChoice) : string { + return `OpenAiEditResponseChoice(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiEditResponseChoice` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiEditResponseChoice|undefined} The parsed `OpenAiEditResponseChoice` object, or `undefined` if the value is not a valid `OpenAiEditResponseChoice` object. + */ +export function parseOpenAiEditResponseChoice (value: unknown) : OpenAiEditResponseChoice | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiEditResponseChoice(")) { + value = value.substring("OpenAiEditResponseChoice(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiEditResponseChoice(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiEditResponseDTO.test.ts b/openai/dto/OpenAiEditResponseDTO.test.ts new file mode 100644 index 0000000..f62fd4e --- /dev/null +++ b/openai/dto/OpenAiEditResponseDTO.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createOpenAiEditResponseChoice } from "./OpenAiEditResponseChoice"; +import { createOpenAiEditResponseDTO, isOpenAiEditResponseDTO } from "./OpenAiEditResponseDTO"; +import { createOpenAiEditResponseUsage } from "./OpenAiEditResponseUsage"; + +describe("OpenAiEditResponseDTO", () => { + + describe('createOpenAiEditResponseDTO', () => { + it('creates a valid OpenAiEditResponseDTO object', () => { + const object = 'text_completion'; + const created = 1589478378; + const choices = [ + createOpenAiEditResponseChoice('It', 0, null, 'length'), + createOpenAiEditResponseChoice('is', 1, null, 'length'), + createOpenAiEditResponseChoice('raining', 2, null, 'length'), + createOpenAiEditResponseChoice('.', 3, null, 'length') + ]; + const usage = createOpenAiEditResponseUsage( + 1, + 2, + 3 + ); + + const result = createOpenAiEditResponseDTO( + object, + created, + choices, + usage + ); + + expect(result).toStrictEqual( + { + object, + created, + choices, + usage + } + ); + }); + }); + + describe("isOpenAiEditResponseDTO", () => { + + it("returns true for valid OpenAiEditResponseDTO objects", () => { + const validOpenAiEditResponseDTO = createOpenAiEditResponseDTO( + "text_completion", + 1589478378, + [ + createOpenAiEditResponseChoice("It's raining today", 0, null, 'length') + ], + createOpenAiEditResponseUsage( + 1, + 2, + 3 + ), + ); + expect(isOpenAiEditResponseDTO(validOpenAiEditResponseDTO)).toBe(true); + }); + + it("returns false for objects with missing properties", () => { + const incompleteOpenAiEditResponseDTO = { + n: 1 + }; + expect(isOpenAiEditResponseDTO(incompleteOpenAiEditResponseDTO)).toBe(false); + }); + + it("returns false for a value with an extra key", () => { + expect(isOpenAiEditResponseDTO( + { + object: "some-id", + created: 1234, + choices: [ { + text: "The meaning of life is 42", + index: 0, + logprobs: null, + finish_reason: 'length' + } ], + usage: createOpenAiEditResponseUsage( + 1, + 2, + 3 + ), + extraKey: "this should not be here" + } + )).toBe(false); + }); + + it("returns false for a value with a non-OpenAiEditResponseChoice array choices property", () => { + expect(isOpenAiEditResponseDTO({ + object: "some-id", + created: 1234, + choices: [ + { text: 'text1', score: 0.5, choices: ['choice1', 'choice2'] }, + { text: 'text2' } + ], + usage: createOpenAiEditResponseUsage( + 1, + 2, + 3 + ) + })).toBe(false); + }); + + it("returns false for a non-object value", () => { + expect(isOpenAiEditResponseDTO(null)).toBe(false); + expect(isOpenAiEditResponseDTO(undefined)).toBe(false); + expect(isOpenAiEditResponseDTO(true)).toBe(false); + expect(isOpenAiEditResponseDTO(false)).toBe(false); + expect(isOpenAiEditResponseDTO(123)).toBe(false); + expect(isOpenAiEditResponseDTO("abc")).toBe(false); + }); + + }); + +}); diff --git a/openai/dto/OpenAiEditResponseDTO.ts b/openai/dto/OpenAiEditResponseDTO.ts new file mode 100644 index 0000000..961bfb2 --- /dev/null +++ b/openai/dto/OpenAiEditResponseDTO.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + explainOpenAiEditResponseChoice, + isOpenAiEditResponseChoice, + OpenAiEditResponseChoice +} from "./OpenAiEditResponseChoice"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, isString } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; +import { explainOpenAiEditResponseUsage, isOpenAiEditResponseUsage, OpenAiEditResponseUsage } from "./OpenAiEditResponseUsage"; +import { explainNumber, isNumber } from "../../types/Number"; +import { OpenAiError } from "./OpenAiError"; + +/** + * @typedef {Object} OpenAiEditResponseDTO + * + * The response to an OpenAI completion request. + */ +export interface OpenAiEditResponseDTO { + + /** + * + */ + readonly object: string; + + /** + * + */ + readonly created: number; + + /** + */ + readonly choices: readonly (OpenAiEditResponseChoice | OpenAiError)[]; + + /** + * + */ + readonly usage : OpenAiEditResponseUsage; + +} + +/** + * Create a new `OpenAiEditResponseDTO` object. + * + * @param {string} id - The ID of the response. + * @param {string} object - + * @param {number} created - + * @param {OpenAiModel} model - The name of the model used to generate the response. + * @param {readonly OpenAiEditResponseChoice[]} choices - + * @param {OpenAiEditResponseUsage} usage - + * @returns {OpenAiEditResponseDTO} The new `OpenAiEditResponseDTO` object. + */ +export function createOpenAiEditResponseDTO ( + object: string, + created: number, + choices: readonly OpenAiEditResponseChoice[], + usage: OpenAiEditResponseUsage +) : OpenAiEditResponseDTO { + return { + object, + created, + choices, + usage + }; +} + +/** + * Check if the given value is an `OpenAiEditResponseDTO` object. + * + * @param value - The value to check. + * @returns `true` if the value is a valid `OpenAiEditResponseDTO` object, `false` otherwise. + */ +export function isOpenAiEditResponseDTO (value: unknown) : value is OpenAiEditResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'object', + 'created', + 'choices', + 'usage' + ]) + && isString(value?.object) + && isNumber(value?.created) + && isArrayOf(value?.choices, isOpenAiEditResponseChoice) + && isOpenAiEditResponseUsage(value?.usage) + ); +} + +/** + * Explain why a value is not a valid OpenAiEditResponseDTO object. + * + * @param {any} value - The value to check. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiEditResponseDTO object. + */ +export function explainOpenAiEditResponseDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'object', + 'created', + 'choices', + 'usage' + ]) + , explainProperty("object", explainString(value?.object)) + , explainProperty("created", explainNumber(value?.created)) + , explainProperty("choices", explainArrayOf( + "OpenAiEditResponseChoice", + explainOpenAiEditResponseChoice, + value?.choices, + isOpenAiEditResponseChoice + )) + , explainProperty("usage", explainOpenAiEditResponseUsage(value?.usage)) + ] + ); +} + +/** + * Convert the given `OpenAiEditResponseDTO` object to a string. + * + * @param {OpenAiEditResponseDTO} value - The value to convert. + * @returns {string} A string representation of the `OpenAiEditResponseDTO` object. + */ +export function stringifyOpenAiEditResponseDTO (value : OpenAiEditResponseDTO) : string { + return `OpenAiEditResponseDTO(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiEditResponseDTO` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiEditResponseDTO|undefined} The parsed `OpenAiEditResponseDTO` object, or `undefined` if the value is not a valid `OpenAiEditResponseDTO` object. + */ +export function parseOpenAiEditResponseDTO (value: unknown) : OpenAiEditResponseDTO | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiEditResponseDTO(")) { + value = value.substring("OpenAiEditResponseDTO(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiEditResponseDTO(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiEditResponseUsage.test.ts b/openai/dto/OpenAiEditResponseUsage.test.ts new file mode 100644 index 0000000..0b15431 --- /dev/null +++ b/openai/dto/OpenAiEditResponseUsage.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiEditResponseUsage, + explainOpenAiEditResponseUsage, + isOpenAiEditResponseUsage, + OpenAiEditResponseUsage, + parseOpenAiEditResponseUsage, + stringifyOpenAiEditResponseUsage +} from "./OpenAiEditResponseUsage"; + +describe("OpenAiEditResponseUsage", () => { + + describe("createOpenAiEditResponseUsage", () => { + it("creates a valid OpenAiEditResponseUsage object", () => { + const item1: OpenAiEditResponseUsage = createOpenAiEditResponseUsage( + 1, + 2, + 3 + ); + expect(item1).toEqual({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + }); + + const item2: OpenAiEditResponseUsage = createOpenAiEditResponseUsage( + 2, + 3, + 4 + ); + expect(item2).toEqual({ + prompt_tokens: 2, + completion_tokens: 3, + total_tokens: 4 + }); + }); + }); + + describe('isOpenAiEditResponseUsage', () => { + it('returns true for valid OpenAiEditResponseUsage objects', () => { + expect(isOpenAiEditResponseUsage({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + })).toBe(true); + }); + + it('returns false for invalid OpenAiEditResponseUsage objects', () => { + // not an object + expect(isOpenAiEditResponseUsage('invalid')).toBe(false); + // extra keys + expect(isOpenAiEditResponseUsage({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3, + extraKey: 'extra value' + })).toBe(false); + // non-number prompt_tokens + expect(isOpenAiEditResponseUsage({ + prompt_tokens: '1', + completion_tokens: 2, + total_tokens: 3 + })).toBe(false); + // non-number completion_tokens + expect(isOpenAiEditResponseUsage({ + prompt_tokens: 1, + completion_tokens: '2', + total_tokens: 3 + })).toBe(false); + // non-number total_tokens + expect(isOpenAiEditResponseUsage({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: '3' + })).toBe(false); + }); + }); + + describe("explainOpenAiEditResponseUsage", () => { + + it("returns a human-readable string explaining why the value has extra keys", () => { + const value = { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3, + extraKey: "This is an extra key" + }; + expect(explainOpenAiEditResponseUsage(value)).toEqual( + "Value had extra properties: extraKey" + ); + }); + + it("returns a human-readable string explaining why the value has a non-number text property", () => { + expect(explainOpenAiEditResponseUsage({ + prompt_tokens: "1", + completion_tokens: 2, + total_tokens: 3 + })).toEqual( + // "Object has a non-number property 'prompt_tokens' ("1")" + expect.stringContaining('property "prompt_tokens" not number') + ); + }); + + it("returns a human-readable string explaining why the value has a non-number completion_tokens property", () => { + expect(explainOpenAiEditResponseUsage({ + prompt_tokens: 1, + completion_tokens: "not a number", + total_tokens: 3 + })).toEqual( + // "Expected property 'completion_tokens' to be a number, but got string 'not a number' instead" + expect.stringContaining('property "completion_tokens" not number') + ); + }); + + it("returns a human-readable string explaining why the value is not a valid OpenAiEditResponseUsage", () => { + expect(explainOpenAiEditResponseUsage({ extra: "key" })).toMatch(/Value had extra properties: extra/); + expect(explainOpenAiEditResponseUsage("foo")).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(undefined)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(null)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(true)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(false)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(0)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(1)).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage("")).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage("test")).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage([])).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(["test"])).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(() => {})).toMatch(/not regular object/); + expect(explainOpenAiEditResponseUsage(Symbol())).toMatch(/not regular object/); + }); + + }); + + describe("stringifyOpenAiEditResponseUsage", () => { + it("should return the string 'OpenAiEditResponseUsage({...})'", () => { + const value: OpenAiEditResponseUsage = { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + }; + const expected = "OpenAiEditResponseUsage({\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3})"; + const result = stringifyOpenAiEditResponseUsage(value); + expect(result).toEqual(expected); + }); + }); + + describe("parseOpenAiEditResponseUsage", () => { + + it("parses a valid OpenAiEditResponseUsage string representation", () => { + const string = `OpenAiEditResponseUsage({"prompt_tokens":1,"completion_tokens":2,"total_tokens":3})`; + const value = parseOpenAiEditResponseUsage(string); + expect(value).toEqual({ + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + }); + }); + + it("parses a valid OpenAiEditResponseUsage object", () => { + const object = { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3 + }; + const value = parseOpenAiEditResponseUsage(object); + expect(value).toEqual(object); + }); + + it("returns undefined for an invalid OpenAiEditResponseUsage string representation", () => { + const string = `OpenAiEditResponseUsage({"invalid":true})`; + const value = parseOpenAiEditResponseUsage(string); + expect(value).toBeUndefined(); + }); + + it("returns undefined for a non-OpenAiEditResponseUsage object", () => { + const object = { + invalid: true + }; + const value = parseOpenAiEditResponseUsage(object); + expect(value).toBeUndefined(); + }); + + it("returns undefined for non-string, non-object values", () => { + expect(parseOpenAiEditResponseUsage(null)).toBeUndefined(); + expect(parseOpenAiEditResponseUsage(true)).toBeUndefined(); + expect(parseOpenAiEditResponseUsage(1)).toBeUndefined(); + expect(parseOpenAiEditResponseUsage([])).toBeUndefined(); + }); + + }); + +}); diff --git a/openai/dto/OpenAiEditResponseUsage.ts b/openai/dto/OpenAiEditResponseUsage.ts new file mode 100644 index 0000000..5054690 --- /dev/null +++ b/openai/dto/OpenAiEditResponseUsage.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainNumber, isNumber } from "../../types/Number"; +import { isString } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; + +/** + * @typedef {Object} OpenAiEditResponseUsage + * + * Property "usage" from the completion response item returned by the OpenAI API. + */ +export interface OpenAiEditResponseUsage { + + /** + */ + readonly prompt_tokens: number; + + /** + */ + readonly completion_tokens: number; + + /** + */ + readonly total_tokens: number; + +} + +/** + * Creates an `OpenAiEditResponseUsage` object. + * + * @param {number} prompt_tokens - + * @param {number} completion_tokens - + * @param {number} total_tokens - + * @returns {OpenAiEditResponseUsage} The created `OpenAiEditResponseUsage` object. + */ +export function createOpenAiEditResponseUsage ( + prompt_tokens: number, + completion_tokens: number, + total_tokens: number +) : OpenAiEditResponseUsage { + return { + prompt_tokens, + completion_tokens, + total_tokens + }; +} + +/** + * Check if the given value is a valid `OpenAiEditResponseUsage` object. + * + * @param {unknown} value - The value to check. + * @returns {value is OpenAiEditResponseUsage} `true` if the value is a valid `OpenAiEditResponseUsage` object, `false` otherwise. + */ +export function isOpenAiEditResponseUsage (value: unknown) : value is OpenAiEditResponseUsage { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'prompt_tokens', + 'completion_tokens', + 'total_tokens' + ]) + && isNumber(value?.prompt_tokens) + && isNumber(value?.completion_tokens) + && isNumber(value?.total_tokens) + ); +} + +/** + * Attempts to explain why the given value is not a valid OpenAiEditResponseUsage object. + * + * @param {unknown} value - The value to explain. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiEditResponseUsage object. + */ +export function explainOpenAiEditResponseUsage (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'prompt_tokens', + 'completion_tokens', + 'total_tokens' + ]) + , explainProperty("prompt_tokens", explainNumber(value?.prompt_tokens)) + , explainProperty("completion_tokens", explainNumber(value?.completion_tokens)) + , explainProperty("total_tokens", explainNumber(value?.total_tokens)) + ] + ); +} + +/** + * Convert the given `OpenAiEditResponseUsage` object to a string. + * + * @param {OpenAiEditResponseUsage} value - The value to convert. + * @returns {string} A string representation of the `OpenAiEditResponseUsage` object. + */ +export function stringifyOpenAiEditResponseUsage (value : OpenAiEditResponseUsage) : string { + return `OpenAiEditResponseUsage(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiEditResponseUsage` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiEditResponseUsage|undefined} The parsed `OpenAiEditResponseUsage` object, or `undefined` if the value is not a valid `OpenAiEditResponseUsage` object. + */ +export function parseOpenAiEditResponseUsage (value: unknown) : OpenAiEditResponseUsage | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiEditResponseUsage(")) { + value = value.substring("OpenAiEditResponseUsage(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiEditResponseUsage(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiError.ts b/openai/dto/OpenAiError.ts new file mode 100644 index 0000000..a0a4430 --- /dev/null +++ b/openai/dto/OpenAiError.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explain, explainOk, explainProperty } from "../../types/explain"; +import { explainString, isString } from "../../types/String"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; +import { isUndefined } from "../../types/undefined"; + +export interface OpenAiError { + readonly message : string; + readonly type : string; +} + +export function createOpenAiError ( + message : string, + type : string +) : OpenAiError { + return { + message, + type + }; +} + +export function isOpenAiError (value: unknown) : value is OpenAiError { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'message', + 'type' + ]) + && isString(value?.message) + && isString(value?.type) + ); +} + +export function explainOpenAiError (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'message', + 'type' + ]) + , explainProperty("message", explainString(value?.message)) + , explainProperty("type", explainString(value?.type)) + ] + ); +} + +export function isOpenAiErrorOrUndefined (value : unknown) : value is undefined | OpenAiError { + return isUndefined(value) || isOpenAiError(value); +} + +export function explainOpenAiErrorOrUndefined (value: unknown): string { + return isOpenAiErrorOrUndefined(value) ? explainOk() : `not OpenAiError or undefined`; +} + +export function stringifyOpenAiError (value : OpenAiError) : string { + return `OpenAiError(${JSON.stringify(value)})`; +} + +export function parseOpenAiError (value: unknown) : OpenAiError | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiError(")) { + value = value.substring("OpenAiError(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiError(value)) return value; + return undefined; +} diff --git a/openai/dto/OpenAiErrorDTO.ts b/openai/dto/OpenAiErrorDTO.ts new file mode 100644 index 0000000..8000d66 --- /dev/null +++ b/openai/dto/OpenAiErrorDTO.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explain, explainProperty } from "../../types/explain"; +import { isString } from "../../types/String"; +import { startsWith } from "../../functions/startsWith"; +import { parseJson } from "../../Json"; +import { explainOpenAiError, isOpenAiError, OpenAiError } from "./OpenAiError"; + +export interface OpenAiErrorDTO { + readonly error : OpenAiError; +} + +export function createOpenAiErrorDTO ( + error : OpenAiError +) : OpenAiErrorDTO { + return { + error + }; +} + +export function isOpenAiErrorDTO (value: unknown) : value is OpenAiErrorDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'error' + ]) + && isOpenAiError(value?.error) + ); +} + +export function explainOpenAiErrorDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'error' + ]) + , explainProperty("error", explainOpenAiError(value?.error)) + ] + ); +} + +export function stringifyOpenAiErrorDTO (value : OpenAiErrorDTO) : string { + return `OpenAiErrorDTO(${JSON.stringify(value)})`; +} + +export function parseOpenAiErrorDTO (value: unknown) : OpenAiErrorDTO | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiErrorDTO(")) { + value = value.substring("OpenAiErrorDTO(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiErrorDTO(value)) return value; + return undefined; +} diff --git a/openai/dto/chatDTO/OpenAiChatCompletionFunctionCall.test.ts b/openai/dto/chatDTO/OpenAiChatCompletionFunctionCall.test.ts new file mode 100644 index 0000000..7a41d02 --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionFunctionCall.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiChatCompletionFunctionCall, + explainOpenAiChatCompletionFunctionCall, + isOpenAiChatCompletionFunctionCall +} from "./OpenAiChatCompletionFunctionCall"; + +describe("OpenAiChatCompletionFunctionCall", () => { + const validItem = createOpenAiChatCompletionFunctionCall( + "testFunc", + "0,7 true" + ); + + const inValidItem = {"name": "testFunc", "role": "tester"}; + + describe("createOpenAiChatCompletionFunctionCall", () => { + + it("creates valid OpenAiChatCompletionFunctionCall objects", () => { + + expect(validItem).toBeTruthy(); + expect(validItem).toStrictEqual( + {"args": "0,7 true", "name": "testFunc"} + ); + }); + + }); + + describe("isOpenAiChatCompletionFunctionCall", () => { + + it("returns true for valid OpenAiChatCompletionFunctionCall objects", () => { + expect(isOpenAiChatCompletionFunctionCall(validItem)).toBeTruthy(); + }); + + }); + + describe("explainOpenAiChatCompletionFunctionCall", () => { + + it("returns true if value is OpenAiChatCompletionFunctionCall", () => { + expect(explainOpenAiChatCompletionFunctionCall(validItem)).toBeTruthy(); + }); + + it("returns a human-readable string explaining why the value is not a OpenAiChatCompletionFunctionCall", () => { + const result = explainOpenAiChatCompletionFunctionCall(inValidItem); + expect(result).toEqual( expect.stringContaining("Value had extra properties: role") ); + expect(result).toEqual( expect.stringContaining("property \"args\" not string") ); + }); + + }); + +}); diff --git a/openai/dto/chatDTO/OpenAiChatCompletionFunctionCall.ts b/openai/dto/chatDTO/OpenAiChatCompletionFunctionCall.ts new file mode 100644 index 0000000..c5c6787 --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionFunctionCall.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2022. Heusala Group . All rights reserved. + +import {explainRegularObject, isRegularObject} from "../../../types/RegularObject"; +import {explainNoOtherKeys, hasNoOtherKeysInDevelopment} from "../../../types/OtherKeys"; +import {explainString, isString} from "../../../types/String"; +import {explain, explainNot, explainOk, explainProperty} from "../../../types/explain"; +import {isUndefined} from "../../../types/undefined"; +import {OpenAiChatCompletionFunctions} from "./OpenAiChatCompletionFunctions"; + +export interface OpenAiChatCompletionFunctionCall { + /** + * The name of the function to call. + */ + name : string; + + /** + * The arguments to call the function with, as generated by the model in JSON format. + * Note that the model does not always generate valid JSON, + * and may hallucinate parameters not defined by your function schema. + * Validate the arguments in your code before calling your function. + */ + args : string; +} + +export function createOpenAiChatCompletionFunctionCall ( + name :string, + args :string +) : OpenAiChatCompletionFunctionCall { + return { + name, + args, + } +} + +export function isOpenAiChatCompletionFunctionCall (value: unknown): value is OpenAiChatCompletionFunctionCall { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + "name", + "args", + ]) + && isString(value?.name) + && isString(value?.args) + ) +}; + +/** + * Explain why the given value is not an `OpenAiCompletionRequestDTO` object. + * + * @param {unknown} value - The value to test. + * @returns {string} A human-readable message explaining why the value is not an `OpenAiCompletionRequestDTO` object, or `'ok'` if it is. + */ +export function explainOpenAiChatCompletionFunctionCall (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + "name", + "args", + ]) + , explainProperty("name", explainString((value as any)?.name)) + , explainProperty("args", explainString((value as any)?.args)) + ] + ); +} + +export function isOpenAiChatCompletionFunctionCallOrUndefined (value: unknown): value is OpenAiChatCompletionFunctions | undefined { + return isOpenAiChatCompletionFunctionCall(value) || isUndefined(value); +} + +export function explainOpenAiChatCompletionFunctionCallOrUndefined (value: any): string { + return isOpenAiChatCompletionFunctionCallOrUndefined(value) ? explainOk() : explainNot('OpenAiChatCompletionFunctionCall or undefined'); +} \ No newline at end of file diff --git a/openai/dto/chatDTO/OpenAiChatCompletionFunctions.test.ts b/openai/dto/chatDTO/OpenAiChatCompletionFunctions.test.ts new file mode 100644 index 0000000..b3fe1fc --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionFunctions.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + + +import { + createOpenAiChatCompletionFunctions, + explainOpenAiChatCompletionFunctions, + isOpenAiChatCompletionFunctions +} from "./OpenAiChatCompletionFunctions"; + +describe("OpenAiChatCompletionFunctions", () => { + const validItem = createOpenAiChatCompletionFunctions( + "function_1", + {startParam : "param1", nextParam : "param2"}, + "For testing purposes" + ); + + const inValidItem = { + "description": ["For testing purposes"], + "name": "function_1", + "params": { + "nextParam": "param2", + "startParam": 2 + } + } + describe("createOpenAiChatCompletionFunctions", () => { + + it("creates valid OpenAiChatCompletionFunctions objects", () => { + + expect(validItem).toBeTruthy(); + expect(validItem).toStrictEqual( + { + "description": "For testing purposes", + "name": "function_1", + "parameters": { + "nextParam": "param2", + "startParam": "param1" + }} + ); + }); + + }); + + describe("isOpenAiChatCompletionFunctions", () => { + + it("returns true for valid OpenAiChatCompletionFunctions objects", () => { + expect(isOpenAiChatCompletionFunctions(validItem)).toBeTruthy(); + expect(isOpenAiChatCompletionFunctions(inValidItem)).toBeFalsy(); + }); + + }); + + describe("explainOpenAiChatCompletionFunctions", () => { + + it("returns true if value is OpenAiChatCompletionFunctions", () => { + expect(explainOpenAiChatCompletionFunctions(validItem)).toBeTruthy(); + }); + + it("returns a human-readable string explaining why the value is not a OpenAiChatCompletionFunctions", () => { + const result = explainOpenAiChatCompletionFunctions(inValidItem); + expect(result).toEqual( expect.stringContaining( "Value had extra properties: params" ) ); + expect(result).toEqual( expect.stringContaining( "property \"parameters\" not object" ) ); + expect(result).toEqual( expect.stringContaining( "property \"description\" not string or undefined" ) ); + }); + + }); + +}); diff --git a/openai/dto/chatDTO/OpenAiChatCompletionFunctions.ts b/openai/dto/chatDTO/OpenAiChatCompletionFunctions.ts new file mode 100644 index 0000000..8435f4f --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionFunctions.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2022. Heusala Group . All rights reserved. + +import {explainRegularObject, isRegularObject} from "../../../types/RegularObject"; +import {explainNoOtherKeys, hasNoOtherKeysInDevelopment} from "../../../types/OtherKeys"; +import {explainString, explainStringOrUndefined, isString, isStringOrUndefined} from "../../../types/String"; +import {explain, explainNot, explainOk, explainProperty} from "../../../types/explain"; +import {explainObject, isObject} from "../../../types/Object"; +import {isUndefined} from "../../../types/undefined"; + +export interface OpenAiChatCompletionFunctions { + /** + * The name of the function to be called. + * Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. + */ + name : string; + + /** + * A description of what the function does, used by the model to choose when and how to call the function. + */ + parameters : object; + + /** + * The parameters the functions accepts, described as a JSON Schema object. + * To describe a function that accepts no parameters, + * provide the value {"type": "object", "properties": {}}. + */ + description ?: string; +} + +export function createOpenAiChatCompletionFunctions ( + name :string, + parameters :object, + description ?:string +) : OpenAiChatCompletionFunctions { + return { + name, + parameters, + description + } +} + +export function isOpenAiChatCompletionFunctions (value: unknown): value is OpenAiChatCompletionFunctions { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + "name", + "parameters", + "description", + ]) + && isString(value?.name) + && isObject(value?.parameters) + && isStringOrUndefined(value?.description) + ) +}; + +/** + * Explain why the given value is not an `OpenAiCompletionRequestDTO` object. + * + * @param {unknown} value - The value to test. + * @returns {string} A human-readable message explaining why the value is not an `OpenAiCompletionRequestDTO` object, or `'ok'` if it is. + */ +export function explainOpenAiChatCompletionFunctions (value: unknown) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + "name", + "parameters", + "description", + ]) + , explainProperty("name", explainString((value as any)?.name)) + , explainProperty("parameters", explainObject((value as any)?.parameters)) + , explainProperty("description", explainStringOrUndefined((value as any)?.description)) + ] + ); +} + +export function isOpenAiChatCompletionFunctionsOrUndefined (value: unknown): value is OpenAiChatCompletionFunctions | undefined { + return isOpenAiChatCompletionFunctions(value) || isUndefined(value); +} + +export function explainOpenAiChatCompletionFunctionsOrUndefined (value: any): string { + return isOpenAiChatCompletionFunctionsOrUndefined(value) ? explainOk() : explainNot('OpenAiChatCompletionFunctions or undefined'); +} \ No newline at end of file diff --git a/openai/dto/chatDTO/OpenAiChatCompletionMessage.test.ts b/openai/dto/chatDTO/OpenAiChatCompletionMessage.test.ts new file mode 100644 index 0000000..579322c --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionMessage.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiChatCompletionMessage, + explainOpenAiChatCompletionMessage, + isOpenAiChatCompletionMessage, +} from "./OpenAiChatCompletionMessage"; +import {OpenAiUserType} from "../../types/OpenAiUserType"; + +describe("OpenAiChatCompletionMessage", () => { + + describe("createOpenAiChatCompletionMessage", () => { + + it("creates valid OpenAiChatCompletionMessage objects", () => { + const message = createOpenAiChatCompletionMessage( + OpenAiUserType.USER, + "what is a banana?" + ); + + expect(message).toStrictEqual({"content": "what is a banana?", "role": "user"}) + }); + + }); + + describe("isOpenAiChatCompletionMessage", () => { + + it("returns true for valid OpenAiChatCompletionMessage objects", () => { + const message = {"content": "what is a banana?", "role": "user"}; + + expect(isOpenAiChatCompletionMessage(message)).toBeTruthy(); + }); + + }); + + describe("explainOpenAiChatCompletionMessage", () => { + + it("returns true if value is OpenAiChatCompletionMessage", () => { + const validMessage = {"role": "user", "content": "what is a banana?"}; + + expect(explainOpenAiChatCompletionMessage(validMessage)).toBeTruthy(); + + }); + + it("returns a human-readable string explaining why the value is not a OpenAiChatCompletionMessage", () => { + const inValidMessage = {"content": "what is a banana?", "role": "tester"}; + + expect(explainOpenAiChatCompletionMessage(inValidMessage)).toContain( + "property \"role\" incorrect enum value \"undefined\" for OpenAiUserType: Accepted values " + ); + }); + + }); + +}); diff --git a/openai/dto/chatDTO/OpenAiChatCompletionMessage.ts b/openai/dto/chatDTO/OpenAiChatCompletionMessage.ts new file mode 100644 index 0000000..66b0c4f --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionMessage.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2022. Heusala Group . All rights reserved. + + + +import {explainOpenAiUserType, isOpenAiUserType, OpenAiUserType, parseOpenAiUserType} from "../../types/OpenAiUserType"; +import {explainRegularObject, isRegularObject} from "../../../types/RegularObject"; +import {explainNoOtherKeys, hasNoOtherKeysInDevelopment} from "../../../types/OtherKeys"; +import {explainString, explainStringOrUndefined, isString, isStringOrUndefined} from "../../../types/String"; +import {explain, explainProperty} from "../../../types/explain"; +import { + explainOpenAiChatCompletionFunctionCallOrUndefined, + isOpenAiChatCompletionFunctionCall, isOpenAiChatCompletionFunctionCallOrUndefined, + OpenAiChatCompletionFunctionCall +} from "./OpenAiChatCompletionFunctionCall"; + +export interface OpenAiChatCompletionMessage { + + /** + * The role of the messages author. One of system, user, assistant, or function. + */ + role : OpenAiUserType; + + /** + * The contents of the message. + * content is required for all messages, and may be null for assistant messages with function calls. + */ + content : string; + + /** + * The name of the author of this message. + * name is required if role is function, and it should be the name of the function + * whose response is in the content + */ + name ?: string; + + /** + * The name and arguments of a function that should be called, as generated by the model. + */ + function_call ?: OpenAiChatCompletionFunctionCall +} + +export function createOpenAiChatCompletionMessage ( + role :OpenAiUserType, + content :string, + name ?:string, + function_call ?:OpenAiChatCompletionFunctionCall, +) : OpenAiChatCompletionMessage { + return { + role, + content, + ...(isString(name) ? {name} : {}), + ...(isOpenAiChatCompletionFunctionCall(function_call) ? {function_call} : {}) + } +} + +export function isOpenAiChatCompletionMessage (value: unknown): value is OpenAiChatCompletionMessage { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + "role", + "content", + "name", + "function_call" + ]) + && isOpenAiUserType(parseOpenAiUserType(value?.role)) + && isString(value?.content) + && isStringOrUndefined(value?.name) + && isOpenAiChatCompletionFunctionCallOrUndefined(value?.function_call) + ) +}; + +/** + * Explain why the given value is not an `OpenAiCompletionRequest` object. + * + * @param {unknown} value - The value to test. + * @returns {string} A human-readable message explaining why the value is not an `OpenAiCompletionRequest` object, or `'ok'` if it is. + */ +export function explainOpenAiChatCompletionMessage (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + "role", + "content", + "name", + "function_call" + ]) + , explainProperty("role", explainOpenAiUserType(parseOpenAiUserType(value.role))) + , explainProperty("content", explainString(value.content)) + , explainProperty("name", explainStringOrUndefined(value?.name)) + , explainProperty("function_call", explainOpenAiChatCompletionFunctionCallOrUndefined(value?.function_call)) + ] + ); +} \ No newline at end of file diff --git a/openai/dto/chatDTO/OpenAiChatCompletionRequestDTO.test.ts b/openai/dto/chatDTO/OpenAiChatCompletionRequestDTO.test.ts new file mode 100644 index 0000000..c4bef86 --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionRequestDTO.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiChatCompletionRequestDTO, + explainOpenAiChatCompletionRequestDTO, + isOpenAiChatCompletionRequestDTO, +} from "./OpenAiChatCompletionRequestDTO"; +import {createOpenAiChatCompletionMessage} from "./OpenAiChatCompletionMessage"; +import {OpenAiUserType} from "../../types/OpenAiUserType"; +import {OpenAiModel} from "../../types/OpenAiModel"; +import {createOpenAiChatCompletionFunctions} from "./OpenAiChatCompletionFunctions"; + +describe("OpenAiChatCompletionRequestDTO", () => { + + const validItem = { + "frequency_penalty": 1, + "max_tokens": 300, + "messages": [ + { + "content": "Who manufactures volvo?", + "role": "user" + } + ], + "model": "gpt-3.5-turbo-16k", + "presence_penalty": 2, + "temperature": 1, + "top_p": 1 + }; + + const inValidItem = { + "frequency_penalty": "1", + "max_tokens": 300, + "messages": [ + { + "content": ["Who manufactures volvo arr?"], + "role": "user" + } + ], + "model": "gpt-3.5-turbo-16k", + "presence_penalty": 2, + "temperature": 1, + "top_p": 1 + }; + + + describe("createOpenAiChatCompletionRequestDTO", () => { + + it("creates valid OpenAiChatCompletionRequestDTO objects", () => { + const item = createOpenAiChatCompletionRequestDTO( + [createOpenAiChatCompletionMessage( + OpenAiUserType.USER, + "Who manufactures volvo?", + )], + OpenAiModel.GPT_3_5_TURBO_16K, + [createOpenAiChatCompletionFunctions( + "testFunc", + {"type": "json", "props": "true"}, + "Function for testing" + )], + 0.5, + 1, + 0.5, + 2, + 2, + "none", + 10, + ); + + expect(item).toBeTruthy(); + expect(item).toHaveProperty("frequency_penalty"); + expect(item).toHaveProperty("max_tokens"); + expect(item).toHaveProperty("messages"); + expect(item).toHaveProperty("model"); + expect(item).toHaveProperty("presence_penalty"); + expect(item).toHaveProperty("temperature"); + expect(item).toHaveProperty("top_p"); + + expect(item).toStrictEqual( + { + "frequency_penalty": 2, + "function_call": "none", + "functions": [ + { + "description": "Function for testing", + "name": "testFunc", + "parameters": { + "props": "true", + "type": "json" + } + } + ], + "logit_bias": undefined, + "max_tokens": 0.5, + "messages": [ + { + "content": "Who manufactures volvo?", + "role": "user" + } + ], + "model": "gpt-3.5-turbo-16k", + "n": 10, + "presence_penalty": 2, + "stop": undefined, + "stream": undefined, + "temperature": 1, + "top_p": 0.5, + "user": undefined + } + ); + + }); + + }); + + describe("isOpenAiChatCompletionRequestDTO", () => { + + it("returns true for valid OpenAiChatCompletionRequestDTO objects", () => { + + expect(isOpenAiChatCompletionRequestDTO(validItem)).toBeTruthy(); + + }); + + it("returns false for inValid OpenAiChatCompletionRequestDTO objects", () => { + + expect(isOpenAiChatCompletionRequestDTO(inValidItem)).toBeFalsy(); + + }); + + }); + + describe("explainOpenAiChatCompletionRequestDTO", () => { + + it("returns true if value is OpenAiChatCompletionRequestDTO", () => { + expect(explainOpenAiChatCompletionRequestDTO(validItem)).toBeTruthy(); + expect(explainOpenAiChatCompletionRequestDTO(validItem)).toBe('OK'); + }); + + it("returns a human-readable string explaining why the value is not a regular object", () => { + const result = explainOpenAiChatCompletionRequestDTO(inValidItem); + expect(result).toEqual( expect.stringContaining("property \"messages\" not OpenAiChatCompletionMessageDTO:") ); + expect(result).toEqual( expect.stringContaining("property \"content\" not string") ); + expect(result).toEqual( expect.stringContaining("property \"frequency_penalty\" not number or undefined") ); + }); + + }); + +}); diff --git a/openai/dto/chatDTO/OpenAiChatCompletionRequestDTO.ts b/openai/dto/chatDTO/OpenAiChatCompletionRequestDTO.ts new file mode 100644 index 0000000..71a80e3 --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionRequestDTO.ts @@ -0,0 +1,286 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import {explainOpenAiModel, isOpenAiModel, OpenAiModel } from "../../types/OpenAiModel"; +import {explain, explainNot, explainOk, explainProperty} from "../../../types/explain"; +import { explainStringOrUndefined, isStringOrUndefined } from "../../../types/String"; +import { explainNumberOrUndefined, isNumberOrUndefined } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; +import { isReadonlyJsonObject, ReadonlyJsonObject } from "../../../Json"; +import {explainArrayOf, explainArrayOfOrUndefined, isArrayOf, isArrayOfOrUndefined} from "../../../types/Array"; +import { + explainOpenAiChatCompletionMessage, + isOpenAiChatCompletionMessage, + OpenAiChatCompletionMessage +} from "./OpenAiChatCompletionMessage"; +import { + explainOpenAiChatCompletionFunctionsOrUndefined, + isOpenAiChatCompletionFunctionsOrUndefined, + OpenAiChatCompletionFunctions +} from "./OpenAiChatCompletionFunctions"; +import { isStringArrayOrUndefined} from "../../../types/StringArray"; +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import {isUndefined} from "../../../types/undefined"; +import {isOpenAiChatCompletionFunctionCall} from "./OpenAiChatCompletionFunctionCall"; + +/** + * Data Transfer Object for requesting a completion from the OpenAI API. + * + * @see https://beta.openai.com/docs/api-reference/completions/create + */ +export interface OpenAiChatCompletionRequestDTO { + + /** + * The model to use for completion. + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-model + */ + readonly model: OpenAiModel | string; + + /** + * A list of messages comprising the conversation so far. + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-messages + */ + readonly messages ?: OpenAiChatCompletionMessage[]; + + /** + * A list of functions the model may generate JSON inputs for. + */ + readonly functions ?: OpenAiChatCompletionFunctions[]; + + /** + * Controls how the model responds to function calls. + * "none" means the model does not call a function, and responds to the end-user. + * "auto" means the model can pick between an end-user or calling a function. + * Specifying a particular function via {"name":\ "my_function"} + * forces the model to call that function. + * "none" is the default when no functions are present. + * "auto" is the default if functions are present. + */ + readonly function_call ?: string | object; + + /** + * The maximum number of tokens to generate in the completion. + * + * Defaults to 16 + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-max_tokens + */ + readonly max_tokens ?: number; + + /** + * The temperature to use for sampling. + * + * Defaults to 1 + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-temperature + */ + readonly temperature ?: number; + + /** + * The top probability to use for sampling. + * + * Defaults to 1 + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-top_p + */ + readonly top_p ?: number; + + /** + * The presence penalty to use for sampling. + * + * Defaults to `0` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-presence_penalty + */ + readonly presence_penalty ?: number; + + /** + * The frequency penalty to use for sampling. + * + * Defaults to `0` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-frequency_penalty + */ + readonly frequency_penalty ?: number; + + /** + * How many chat completion choices to generate for each input message. + */ + readonly n ?: number; + + /** + * If set, partial message deltas will be sent, like in ChatGPT. + * Tokens will be sent as data-only server-sent events as they become available, + * with the stream terminated by a data: [DONE] + */ + readonly stream ?: boolean; + + /** + * Up to 4 sequences where the API will stop generating further tokens. + */ + readonly stop ?: string | string[]; + + /** + * Detaults to `null` + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-logit_bias + * Modify the likelihood of specified tokens appearing in the completion. + */ + readonly logit_bias ?: ReadonlyJsonObject; + + /** + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-user + * A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. + */ + readonly user ?: string; + +} + +/** + * Create an `OpenAiChatCompletionRequestDTO` object with the given properties. + * + * @param {OpenAiChatCompletionMessageDTO} messages - The messages to complete. + * @param {OpenAiModel} model - The model to use for completion. + * @param {OpenAiChatCompletionFunctions} functions + * @param {number} max_tokens - The maximum number of tokens to generate in the completion. + * @param {number} temperature - The temperature to use for sampling. + * @param {number} top_p - The top probability to use for sampling. + * @param {number} frequency_penalty - The frequency penalty to use for sampling. + * @param {number} presence_penalty - The presence penalty to use for sampling. + * @param {number} function_call + * @param {number} n + * @param {number} stream + * @param {number} stop + * @param {number} logit_bias + * @param {number} user + * @returns {OpenAiChatCompletionRequestDTO} An `OpenAiChatCompletionRequestDTO` object with the given properties. + */ +export function createOpenAiChatCompletionRequestDTO ( + messages : OpenAiChatCompletionMessage[], + model : OpenAiModel | string, + functions ?: OpenAiChatCompletionFunctions[], + max_tokens ?: number, + temperature ?: number, + top_p ?: number, + frequency_penalty ?: number, + presence_penalty ?: number, + function_call ?: string | object, + n ?: number, + stream ?: boolean, + stop ?: string | string[], + logit_bias ?: ReadonlyJsonObject, + user ?: string, +) : OpenAiChatCompletionRequestDTO { + return { + messages, + model: model ?? OpenAiModel.GPT_4, + functions, + max_tokens, + temperature, + top_p, + frequency_penalty, + presence_penalty, + function_call, + n, + stream, + stop, + logit_bias, + user + }; +} + +/** + * Test whether the given value is an `OpenAiChatCompletionRequestDTO` object. + * + * @param {unknown} value - The value to test. + * @returns {value is OpenAiChatCompletionRequestDTO} `true` if the value is an `OpenAiChatCompletionRequestDTO` object, `false` otherwise. + */ +export function isOpenAiChatCompletionRequestDTO (value: any) : value is OpenAiChatCompletionRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'messages', + 'model', + 'functions', + 'max_tokens', + 'temperature', + 'top_p', + 'frequency_penalty', + 'presence_penalty', + 'function_call', + 'n', + 'stream', + 'stop', + 'logit_bias', + 'user', + ]) + && isArrayOf(value.messages, isOpenAiChatCompletionMessage) + && isOpenAiModel(value?.model) + && isArrayOfOrUndefined(value?.functions, isOpenAiChatCompletionFunctionsOrUndefined) + && isNumberOrUndefined(value?.max_tokens) + && isNumberOrUndefined(value?.temperature) + && isNumberOrUndefined(value?.top_p) + && isNumberOrUndefined(value?.frequency_penalty) + && isNumberOrUndefined(value?.presence_penalty) + && isStringOrUndefined(value?.function_call) + && isNumberOrUndefined(value?.n) + && isBooleanOrUndefined(value?.stream) + && isStringArrayOrUndefined(value?.stop) || isStringOrUndefined(value?.stop) + && isReadonlyJsonObject(value?.logit_bias) + && isStringOrUndefined(value?.user) + ); +} + +/** + * Explain why the given value is not an `OpenAiChatCompletionRequestDTO` object. + * + * @param {unknown} value - The value to test. + * @returns {string} A human-readable message explaining why the value is not an `OpenAiChatCompletionRequestDTO` object, or `'ok'` if it is. + */ +export function explainOpenAiChatCompletionRequestDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'messages', + 'model', + 'functions', + 'max_tokens', + 'temperature', + 'top_p', + 'frequency_penalty', + 'presence_penalty', + 'function_call', + 'n', + 'stream', + 'stop', + 'logit_bias', + 'user', + ]) + , explainProperty("messages", explainArrayOf("OpenAiChatCompletionMessageDTO", explainOpenAiChatCompletionMessage, value?.messages, isOpenAiChatCompletionMessage)) + , explainProperty("model", explainOpenAiModel(value.model)) + , explainProperty("functions", explainArrayOfOrUndefined("OpenAiChatCompletionFunctions", explainOpenAiChatCompletionFunctionsOrUndefined, value?.functions, isOpenAiChatCompletionFunctionsOrUndefined)) + , explainProperty("max_tokens", explainNumberOrUndefined(value?.max_tokens)) + , explainProperty("temperature", explainNumberOrUndefined(value?.temperature)) + , explainProperty("top_p", explainNumberOrUndefined(value?.top_p)) + , explainProperty("frequency_penalty", explainNumberOrUndefined(value?.frequency_penalty)) + , explainProperty("presence_penalty", explainNumberOrUndefined(value?.presence_penalty)) + , explainProperty("function_call", explainStringOrUndefined(value?.function_call)) + , explainProperty("n", explainStringOrUndefined(value?.n)) + , explainProperty("stream", explainStringOrUndefined(value?.stream)) + , explainProperty("stop", explainStringOrUndefined(value?.stop)) + , explainProperty("logit_bias", explainStringOrUndefined(value?.logit_bias)) + , explainProperty("user", explainStringOrUndefined(value?.user)) + ] + ); +} + +export function isOpenAiChatCompletionFunctionCallOrUndefined (value: unknown): value is OpenAiChatCompletionFunctions | undefined { + return isOpenAiChatCompletionFunctionCall(value) || isUndefined(value); +} + +export function explainOpenAiChatCompletionFunctionCallOrUndefined (value: any): string { + return isOpenAiChatCompletionFunctionCallOrUndefined(value) ? explainOk() : explainNot('OpenAiChatCompletionFunctionCall or undefined'); +} \ No newline at end of file diff --git a/openai/dto/chatDTO/OpenAiChatCompletionResponseChoice.test.ts b/openai/dto/chatDTO/OpenAiChatCompletionResponseChoice.test.ts new file mode 100644 index 0000000..7e87275 --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionResponseChoice.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiChatCompletionResponseChoice, + explainOpenAiChatCompletionResponseChoice, + isOpenAiChatCompletionResponseChoice, +} from "./OpenAiChatCompletionResponseChoice"; +import {createOpenAiChatCompletionMessage} from "./OpenAiChatCompletionMessage"; +import {OpenAiUserType} from "../../types/OpenAiUserType"; + +describe("OpenAiChatCompletionResponseChoice", () => { + let validItem = { + "finish_reason": "stop", + "index": 1, + "message": { + "content": "tell me a lie", + "name": "tester", + "role": "user" + }}; + + let inValidItem = { + "finish_reason": 100, + "index": 1, + "message": { + "content": ["Can I add array here ?"], + "name": "tester", + "role": "owner" + }}; + + describe("createOpenAiChatCompletionResponseChoice", () => { + + it("creates valid OpenAiChatCompletionResponseChoice objects", () => { + const item = createOpenAiChatCompletionResponseChoice( + 1, + createOpenAiChatCompletionMessage( + OpenAiUserType.USER, + "tell me a lie", + "tester", + undefined + ), + "stop" + ); + + expect(item).toBeTruthy(); + expect(item).toStrictEqual( + { + "finish_reason": "stop", + "index": 1, + "message": { + "content": "tell me a lie", + "name": "tester", + "role": "user" + } + } + ); + }); + + }); + + describe("isOpenAiChatCompletionResponseChoice", () => { + + it("returns true for valid OpenAiChatCompletionResponseChoice objects", () => { + expect(isOpenAiChatCompletionResponseChoice(validItem)).toBeTruthy(); + }); + + it("returns false for inValid OpenAiChatCompletionResponseChoice objects", () => { + expect(isOpenAiChatCompletionResponseChoice(inValidItem)).toBeFalsy(); + }); + + }); + + describe("explainOpenAiChatCompletionResponseChoice", () => { + + it("returns true if value is OpenAiChatCompletionResponseChoice", () => { + + expect(explainOpenAiChatCompletionResponseChoice(validItem)).toBeTruthy(); + expect(explainOpenAiChatCompletionResponseChoice(validItem)).toStrictEqual( + "OK" + ); + + }); + + it("returns a human-readable string explaining why the value is not a regular object", () => { + const result = explainOpenAiChatCompletionResponseChoice(inValidItem); + expect(result).toEqual( expect.stringContaining( "property \"message\"" ) ); + expect(result).toEqual( expect.stringContaining( "property \"role\" incorrect enum value \"undefined\" for OpenAiUserType: Accepted values " ) ); + }); + + }); + +}); diff --git a/openai/dto/chatDTO/OpenAiChatCompletionResponseChoice.ts b/openai/dto/chatDTO/OpenAiChatCompletionResponseChoice.ts new file mode 100644 index 0000000..1569936 --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionResponseChoice.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { + explainNumber, + isNumber, +} from "../../../types/Number"; +import { explainString, isString } from "../../../types/String"; +import { explain, explainOk, explainProperty } from "../../../types/explain"; +import { startsWith } from "../../../functions/startsWith"; +import { parseJson } from "../../../Json"; +import { isOpenAiError, OpenAiError } from "../OpenAiError"; +import { + explainOpenAiChatCompletionMessage, + isOpenAiChatCompletionMessage, + OpenAiChatCompletionMessage +} from "./OpenAiChatCompletionMessage"; + +/** + * @typedef {Object} OpenAiChatCompletionResponseChoice + * + * A completion response item returned by the OpenAI API. + */ +export interface OpenAiChatCompletionResponseChoice { + + /** + * The completed text. + */ + readonly index : number; + readonly message: OpenAiChatCompletionMessage; + readonly finish_reason: string; + +} + +/** + * Creates an `OpenAiChatCompletionResponseChoice` object. + * + * @param {number} index - + * @param {OpenAiChatCompletionMessageDTO} message - + * @param {string} finish_reason - + * @returns {OpenAiChatCompletionResponseChoice} The created `OpenAiChatCompletionResponseChoice` object. + */ +export function createOpenAiChatCompletionResponseChoice ( + index: number, + message: OpenAiChatCompletionMessage, + finish_reason: string +) : OpenAiChatCompletionResponseChoice { + return { + index, + message: message, + finish_reason + }; +} + +/** + * Check if the given value is a valid `OpenAiChatCompletionResponseChoice` object. + * + * @param {unknown} value - The value to check. + * @returns {value is OpenAiChatCompletionResponseChoice} `true` if the value is a valid `OpenAiChatCompletionResponseChoice` object, `false` otherwise. + */ +export function isOpenAiChatCompletionResponseChoice (value: unknown) : value is OpenAiChatCompletionResponseChoice { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'index', + 'message', + 'finish_reason' + ]) + && isNumber(value?.index) + && isOpenAiChatCompletionMessage(value?.message) + && isString(value?.finish_reason) + ); +} + +/** + * Attempts to explain why the given value is not a valid OpenAiChatCompletionResponseChoice object. + * + * @param {unknown} value - The value to explain. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiChatCompletionResponseChoice object. + */ +export function explainOpenAiChatCompletionResponseChoice (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'index', + 'message', + 'finish_reason' + ]) + , explainProperty("index", explainNumber(value?.index)) + , explainProperty("message", explainOpenAiChatCompletionMessage(value?.message)) + , explainProperty("finish_reason", explainString(value?.finish_reason)) + ] + ); +} + +/** + * Check if the given value is a valid `OpenAiChatCompletionResponseChoice` object or an OpenAiError. + * + * @param {unknown} value - The value to check. + * @returns {value is OpenAiChatCompletionResponseChoice| OpenAiError} `true` if the value is a valid `OpenAiChatCompletionResponseChoice` object, `false` otherwise. + */ +export function isOpenAiChatCompletionResponseChoiceOrError (value: unknown) : value is (OpenAiChatCompletionResponseChoice | OpenAiError) { + return isOpenAiChatCompletionResponseChoice(value) || isOpenAiError(value); +} + +/** + * Attempts to explain why the given value is not a valid OpenAiChatCompletionResponseChoice or OpenAiError object. + * + * @param {unknown} value - The value to explain. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiChatCompletionResponseChoice or an OpenAiError object. + */ +export function explainOpenAiChatCompletionResponseChoiceOrError (value: any) : string { + return isOpenAiChatCompletionResponseChoiceOrError(value) ? explainOk() : 'Not OpenAiError or OpenAiChatCompletionResponseChoice'; +} + +/** + * Convert the given `OpenAiChatCompletionResponseChoice` object to a string. + * + * @param {OpenAiChatCompletionResponseChoice} value - The value to convert. + * @returns {string} A string representation of the `OpenAiChatCompletionResponseChoice` object. + */ +export function stringifyOpenAiChatCompletionResponseChoice (value : OpenAiChatCompletionResponseChoice) : string { + return `OpenAiChatCompletionResponseChoice(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiChatCompletionResponseChoice` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiChatCompletionResponseChoice|undefined} The parsed `OpenAiChatCompletionResponseChoice` object, or `undefined` if the value is not a valid `OpenAiChatCompletionResponseChoice` object. + */ +export function parseOpenAiChatCompletionResponseChoice (value: unknown) : OpenAiChatCompletionResponseChoice | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiChatCompletionResponseChoice(")) { + value = value.substring("OpenAiChatCompletionResponseChoice(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiChatCompletionResponseChoice(value)) return value; + return undefined; +} diff --git a/openai/dto/chatDTO/OpenAiChatCompletionResponseDTO.test.ts b/openai/dto/chatDTO/OpenAiChatCompletionResponseDTO.test.ts new file mode 100644 index 0000000..ebc0631 --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionResponseDTO.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createOpenAiChatCompletionResponseDTO, + explainOpenAiChatCompletionResponseDTO, + isOpenAiChatCompletionResponseDTO, +} from "./OpenAiChatCompletionResponseDTO"; +import {OpenAiModel} from "../../types/OpenAiModel"; +import {createOpenAiChatCompletionResponseChoice} from "./OpenAiChatCompletionResponseChoice"; +import {createOpenAiChatCompletionMessage} from "./OpenAiChatCompletionMessage"; +import {OpenAiUserType} from "../../types/OpenAiUserType"; +import {createOpenAiCompletionResponseUsage} from "../OpenAiCompletionResponseUsage"; + +describe("OpenAiChatCompletionResponseDTO", () => { + + describe("createOpenAiChatCompletionResponseDTO", () => { + + it("creates valid OpenAiChatCompletionResponseDTO objects", () => { + const result = createOpenAiChatCompletionResponseDTO( + "1", + "chat.completion", + 1677858242, + OpenAiModel.GPT_3_5_TURBO_16K, + [createOpenAiChatCompletionResponseChoice( + 1, + createOpenAiChatCompletionMessage( + OpenAiUserType.USER, + "Hey, could you describe apple to me?" + ), + "stop", + ),], + createOpenAiCompletionResponseUsage( + 200, + 200, + 500 + ) + ); + + expect(result).toBeTruthy(); + expect(result).toStrictEqual( + { + "choices": [ + { + "finish_reason": "stop", + "index": 1, + "message": { + "content": "Hey, could you describe apple to me?", + "role": "user" + } + } + ], + "created": 1677858242, + "id": "1", + "model": "gpt-3.5-turbo-16k", + "object": "chat.completion", + "usage": { + "completion_tokens": 200, + "prompt_tokens": 200, + "total_tokens": 500 + } + } + ) + }); + + }); + + describe("isOpenAiChatCompletionResponseDTO", () => { + + it("returns true for valid OpenAiChatCompletionResponseDTO objects", () => { + const item = { + "choices": [ + { + "finish_reason": "stop", + "index": 1, + "message": { + "content": "Hey, could you describe apple to me?", + "role": "user" + } + } + ], + "created": 1677858242, + "id": "1", + "model": "gpt-3.5-turbo-16k", + "object": "chat.completion", + "usage": { + "completion_tokens": 200, + "prompt_tokens": 200, + "total_tokens": 500 + } + }; + + expect(isOpenAiChatCompletionResponseDTO(item)).toBeTruthy(); + + }); + + it("returns true for valid OpenAiChatCompletionResponseDTO objects", () => { + const item = { + "choices": [ + { + "finish_reason": "stop", + "index": "1", + "message": { + "content": "Hey, could you describe apple to me?", + "role": "user" + } + } + ], + "created": 1677858242, + "id": "1", + "model": "gpt-3.5-turbo-16k", + "object": "chat.completion", + "usage": { + "completion_tokens": 200, + "prompt_tokens": 200, + "total_tokens": 500 + } + }; + + expect(isOpenAiChatCompletionResponseDTO(item)).toBe(false); + }); + + + + }); + + describe("explainOpenAiChatCompletionResponseDTO", () => { + + it("returns a string explaining why the value is not a OpenAiChatCompletionResponseDTO", () => { + + const item = { + "choices": [ + { + "finish_reason": "stop", + "index": "1", + "message": { + "content": "Hey, could you describe apple to me?", + "role": "user" + } + } + ], + "created": [1677858242], + "id": 1, + "model": "gpt-3.5-turbo-16k", + "object": "chat.completion", + "usage": { + "completion_tokens": 200, + "prompt_tokens": 200, + "total_tokens": 500 + } + }; + + const result = explainOpenAiChatCompletionResponseDTO(item); + expect(result).toEqual( expect.stringContaining( "property \"id\" not string" ) ); + expect(result).toEqual( expect.stringContaining( "property \"created\" not number" ) ); + expect(result).toEqual( expect.stringContaining( "property \"choices\" not OpenAiChatCompletionResponseChoice|OpenAiError" ) ); + expect(result).toEqual( expect.stringContaining( "property \"index\" not number" ) ); + + }); + + + it("returns a string explaining why the OpenAiChatCompletionResponseDTO has extra keys", () => { + const item = { + "choices": [ + { + "finish_reason": "stop", + "index": "1", + "message": { + "content": "Hey, could you describe apple to me?", + "role": "user", + "extra": "just saying", + } + } + ], + "created": [1677858242], + "id": 1, + "model": "gpt-3.5-turbo-16k", + "params" : ["some extra data"], + "object": "chat.completion", + "usage": { + "completion_tokens": 200, + "prompt_tokens": 200, + "total_tokens": 500 + } + }; + + const result = explainOpenAiChatCompletionResponseDTO(item); + + expect(result).toEqual( expect.stringContaining("Value had extra properties: params") ); + expect(result).toEqual( expect.stringContaining("property \"id\" not string") ); + expect(result).toEqual( expect.stringContaining("property \"created\" not number") ); + expect(result).toEqual( expect.stringContaining("property \"choices\" not OpenAiChatCompletionResponseChoice|OpenAiError") ); + expect(result).toEqual( expect.stringContaining("property \"index\" not number") ); + expect(result).toEqual( expect.stringContaining("property \"message\" Value had extra properties: extra") ); + }); + + + }); + +}); diff --git a/openai/dto/chatDTO/OpenAiChatCompletionResponseDTO.ts b/openai/dto/chatDTO/OpenAiChatCompletionResponseDTO.ts new file mode 100644 index 0000000..e8d7711 --- /dev/null +++ b/openai/dto/chatDTO/OpenAiChatCompletionResponseDTO.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { OpenAiModel } from "../../types/OpenAiModel"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../types/String"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainArrayOf, isArrayOf } from "../../../types/Array"; +import { startsWith } from "../../../functions/startsWith"; +import { parseJson } from "../../../Json"; +import { explainOpenAiCompletionResponseUsage, isOpenAiCompletionResponseUsage, OpenAiCompletionResponseUsage } from "../OpenAiCompletionResponseUsage"; +import { explainNumber, isNumber } from "../../../types/Number"; +import { OpenAiError } from "../OpenAiError"; +import { + explainOpenAiChatCompletionResponseChoice, + isOpenAiChatCompletionResponseChoice, + OpenAiChatCompletionResponseChoice +} from "./OpenAiChatCompletionResponseChoice"; + +/** + * @typedef {Object} OpenAiChatCompletionResponseDTO + * + * The response to an OpenAI completion request. + */ +export interface OpenAiChatCompletionResponseDTO { + + /** + * The ID of the response. + */ + readonly id: string; + + /** + * + */ + readonly object: string; + + /** + * + */ + readonly created: number; + + /** + * The name of the model used to generate the response. + * + * @see https://beta.openai.com/docs/api-reference/completions/create#completions/create-model + */ + readonly model: OpenAiModel | string; + + /** + */ + readonly choices: readonly (OpenAiChatCompletionResponseChoice| OpenAiError)[]; + + /** + * + */ + readonly usage : OpenAiCompletionResponseUsage; + + readonly warning ?: string; + +} + +/** + * Create a new `OpenAiChatCompletionResponseDTO` object. + * + * @param {string} id - The ID of the response. + * @param {string} object - + * @param {number} created - + * @param {OpenAiModel} model - The name of the model used to generate the response. + * @param {readonly OpenAiCompletionResponseChoice[]} choices - + * @param {OpenAiCompletionResponseUsage} usage - + * @returns {OpenAiChatCompletionResponseDTO} The new `OpenAiChatCompletionResponseDTO` object. + */ +export function createOpenAiChatCompletionResponseDTO ( + id: string, + object: string, + created: number, + model: OpenAiModel | string, + choices: readonly (OpenAiChatCompletionResponseChoice | OpenAiError)[], + usage: OpenAiCompletionResponseUsage +) : OpenAiChatCompletionResponseDTO { + return { + id, + object, + created, + model, + choices, + usage + }; +} + +/** + * Check if the given value is an `OpenAiChatCompletionResponseDTO` object. + * + * @param value - The value to check. + * @returns `true` if the value is a valid `OpenAiChatCompletionResponseDTO` object, `false` otherwise. + */ +export function isOpenAiChatCompletionResponseDTO (value: unknown) : value is OpenAiChatCompletionResponseDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'object', + 'created', + 'model', + 'choices', + 'usage', + 'warning' + ]) + && isString(value?.id) + && isString(value?.object) + && isNumber(value?.created) + && isString(value?.model) + && isArrayOf(value?.choices, isOpenAiChatCompletionResponseChoice) + && isOpenAiCompletionResponseUsage(value?.usage) + && isStringOrUndefined(value?.warning) + ); +} + +/** + * Explain why a value is not a valid OpenAiChatCompletionResponseDTO object. + * + * @param {any} value - The value to check. + * @returns {string} A human-readable string explaining why the value is not a valid OpenAiChatCompletionResponseDTO object. + */ +export function explainOpenAiChatCompletionResponseDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'id', + 'object', + 'created', + 'model', + 'choices', + 'usage', + 'warning' + ]) + , explainProperty("id", explainString(value?.id)) + , explainProperty("object", explainString(value?.object)) + , explainProperty("created", explainNumber(value?.created)) + , explainProperty("model", explainString(value?.model)) + , explainProperty("choices", explainArrayOf( + "OpenAiChatCompletionResponseChoice|OpenAiError", + explainOpenAiChatCompletionResponseChoice, + value?.choices, + isOpenAiChatCompletionResponseChoice + )) + , explainProperty("usage", explainOpenAiCompletionResponseUsage(value?.usage)) + , explainProperty("warning", explainStringOrUndefined(value?.warning)) + ] + ); +} + +/** + * Convert the given `OpenAiChatCompletionResponseDTO` object to a string. + * + * @param {OpenAiChatCompletionResponseDTO} value - The value to convert. + * @returns {string} A string representation of the `OpenAiChatCompletionResponseDTO` object. + */ +export function stringifyOpenAiChatCompletionResponseDTO (value : OpenAiChatCompletionResponseDTO) : string { + return `OpenAiChatCompletionResponseDTO(${JSON.stringify(value)})`; +} + +/** + * Attempt to parse the given value as an `OpenAiChatCompletionResponseDTO` object. + * + * @param {unknown} value - The value to parse. + * @returns {OpenAiChatCompletionResponseDTO|undefined} The parsed `OpenAiChatCompletionResponseDTO` object, or `undefined` if the value is not a valid `OpenAiChatCompletionResponseDTO` object. + */ +export function parseOpenAiChatCompletionResponseDTO (value: unknown) : OpenAiChatCompletionResponseDTO | undefined { + if (isString(value)) { + if (startsWith(value, "OpenAiChatCompletionResponseDTO(")) { + value = value.substring("OpenAiChatCompletionResponseDTO(".length, value.length -1 ); + } + value = parseJson(value); + } + if (isOpenAiChatCompletionResponseDTO(value)) return value; + return undefined; +} diff --git a/openai/instructions/aiDocumentCodeInstruction.test.ts b/openai/instructions/aiDocumentCodeInstruction.test.ts new file mode 100644 index 0000000..17b54c3 --- /dev/null +++ b/openai/instructions/aiDocumentCodeInstruction.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { aiDocumentCodeInstruction } from "./aiDocumentCodeInstruction"; + +describe("aiDocumentCodeInstruction", () => { + + it("generates instructions to document code without detail", () => { + const language = "JavaScript"; + const framework = "JSDoc"; + const inDetail = false; + const examples = "..."; + const expectedInstruction = `Let's go step by step. +Document the public interface from the following ${language} code using ${framework}. +Include the source code unmodified. + +${examples} +`; + + expect(aiDocumentCodeInstruction(language, framework, inDetail, examples)).toBe(expectedInstruction); + }); + + it("generates instructions to document code in detail", () => { + const language = "JavaScript"; + const framework = "JSDoc"; + const examples = "..."; + const inDetail = true; + const expectedInstruction = `Let's go step by step. +Document the public interface from the following ${language} code in detail using ${framework}. +Include the source code unmodified. + +${examples} +`; + expect(aiDocumentCodeInstruction(language, framework, inDetail, examples)).toBe(expectedInstruction); + }); + +}); diff --git a/openai/instructions/aiDocumentCodeInstruction.ts b/openai/instructions/aiDocumentCodeInstruction.ts new file mode 100644 index 0000000..b0f9eb4 --- /dev/null +++ b/openai/instructions/aiDocumentCodeInstruction.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { replaceTemplate } from "../../functions/replaceTemplate"; + +/** + * A template for instruction to describe the provided source code. + * + * This template requires the following placeholder parameters to be replaced: + * * `{{LANG}}` - The programming language, e.g. `TypeScript`. + * * `{{FRAMEWORK}}` - The testing framework, e.g. `JSDoc`. + * @type {string} + */ +export const AI_DOCUMENT_IN_DETAIL_CODE_INSTRUCTION = `Let's go step by step. +Document the public interface from the following {{LANGUAGE}} code in detail using {{FRAMEWORK}}. +Include the source code unmodified. + +{{EXAMPLES}} +`; + +/** + * A template for instruction to describe the provided source code. + * + * This template requires the following placeholder parameters to be replaced: + * * `{{LANGUAGE}}` - The programming language, e.g. `TypeScript`. + * * `{{FRAMEWORK}}` - The testing framework, e.g. `JSDoc`. + * @type {string} + */ +export const AI_DOCUMENT_CODE_INSTRUCTION = `Let's go step by step. +Document the public interface from the following {{LANGUAGE}} code using {{FRAMEWORK}}. +Include the source code unmodified. + +{{EXAMPLES}} +`; + +/** + * Generates instruction for AI to document in JSDoc the provided source code. + * + * @param {string} [language] - The programming language, e.g. `TypeScript`. If + * not provided, defaults to `TypeScript`. + * @param {string} [framework] - The testing framework, e.g. `JSDoc`. If + * not provided, defaults to `JSDoc`. + * @param {boolean} [inDetail] - Detailed or short mode. If + * not provided, defaults to `false`. + * @param {string} [examples] - The examples. If + * not provided, defaults to `""`. + * @returns {string} Instruction to describe code for the provided source + * code. + */ +export function aiDocumentCodeInstruction ( + language ?: string, + framework ?: string, + inDetail ?: boolean, + examples ?: string, +) : string { + return replaceTemplate( + inDetail ? AI_DOCUMENT_IN_DETAIL_CODE_INSTRUCTION : AI_DOCUMENT_CODE_INSTRUCTION, + { + '{{LANGUAGE}}' : language ?? 'TypeScript', + '{{FRAMEWORK}}' : framework ?? 'JSDoc', + '{{EXAMPLES}}' : examples ?? '' + } + ); +} diff --git a/openai/instructions/changelogInstruction.test.ts b/openai/instructions/changelogInstruction.test.ts new file mode 100644 index 0000000..cf80279 --- /dev/null +++ b/openai/instructions/changelogInstruction.test.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { changelogInstruction } from "./changelogInstruction"; + +describe("changelogInstruction", () => { + it("generates instructions on how to write changelog for the provided source code diff", () => { + const expectedInstruction = `Write a change log for the following commit diff:`; + expect(changelogInstruction()).toBe(expectedInstruction); + }); +}); diff --git a/openai/instructions/changelogInstruction.ts b/openai/instructions/changelogInstruction.ts new file mode 100644 index 0000000..746be94 --- /dev/null +++ b/openai/instructions/changelogInstruction.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * A template for instruction to document the provided source code. + * + * This template requires the following placeholder parameters to be replaced: + * * `{{LANG}}` - The programming language, e.g. `TypeScript`. + * * `{{FRAMEWORK}}` - The testing framework, e.g. `JSDoc`. + * @type {string} + */ +export const CHANGELOG_INSTRUCTION = `Write a change log for the following commit diff:`; + +/** + * Generates instruction on to write change log code for the provided diff string. + * + * @returns {string} Instruction to write changelog for the provided diff of + * source code. + */ +export function changelogInstruction () : string { + return CHANGELOG_INSTRUCTION; +} diff --git a/openai/instructions/describeCodeInstruction.test.ts b/openai/instructions/describeCodeInstruction.test.ts new file mode 100644 index 0000000..d81c35b --- /dev/null +++ b/openai/instructions/describeCodeInstruction.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { describeCodeInstruction } from "./describeCodeInstruction"; + +describe("describeCodeInstruction", () => { + + it("generates instructions to describe code without detail", () => { + const language = "JavaScript"; + const inDetail = false; + const expectedInstruction = `Let's go step by step. +Describe what this ${language} does?`; + expect(describeCodeInstruction(language, inDetail)).toBe(expectedInstruction); + }); + + it("generates instructions to describe code in detail", () => { + const language = "JavaScript"; + const inDetail = true; + const expectedInstruction = `Let's go step by step. +Describe in detail what this ${language} does?`; + expect(describeCodeInstruction(language, inDetail)).toBe(expectedInstruction); + }); + +}); diff --git a/openai/instructions/describeCodeInstruction.ts b/openai/instructions/describeCodeInstruction.ts new file mode 100644 index 0000000..161bc48 --- /dev/null +++ b/openai/instructions/describeCodeInstruction.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { replaceTemplate } from "../../functions/replaceTemplate"; + +/** + * A template for instruction to describe the provided source code. + * + * This template requires the following placeholder parameters to be replaced: + * * `{{LANG}}` - The programming language, e.g. `TypeScript`. + * @type {string} + */ +export const DESCRIBE_IN_DETAIL_CODE_INSTRUCTION = `Let's go step by step. +Describe in detail what this {{LANG}} does?`; + +/** + * A template for instruction to describe the provided source code. + * + * This template requires the following placeholder parameters to be replaced: + * * `{{LANG}}` - The programming language, e.g. `TypeScript`. + * @type {string} + */ +export const DESCRIBE_CODE_INSTRUCTION = `Let's go step by step. +Describe what this {{LANG}} does?`; + +/** + * Generates instruction on to describe code for the provided source code. + * + * @param {string} [language] - The programming language, e.g. `TypeScript`. If + * not provided, defaults to `TypeScript`. + * @param {boolean} [inDetail] - Detailed or short mode. If + * not provided, defaults to `false`. + * @returns {string} Instruction to describe code for the provided source + * code. + */ +export function describeCodeInstruction ( + language ?: string, + inDetail ?: boolean +) : string { + return replaceTemplate( + inDetail ? DESCRIBE_IN_DETAIL_CODE_INSTRUCTION : DESCRIBE_CODE_INSTRUCTION, + { + '{{LANG}}' : language ?? 'TypeScript' + } + ); +} diff --git a/openai/instructions/documentCodeInstruction.test.ts b/openai/instructions/documentCodeInstruction.test.ts new file mode 100644 index 0000000..78f97b4 --- /dev/null +++ b/openai/instructions/documentCodeInstruction.test.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { documentCodeInstruction } from "./documentCodeInstruction"; + +describe("documentCodeInstruction", () => { + it("generates instructions on how to write tests for the provided source code", () => { + const language = "JavaScript"; + const framework = "JSDoc"; + const expectedInstruction = `Let's go step by step. +Improve the ${framework} documentations from the following ${language} code:`; + + expect(documentCodeInstruction(language, framework)).toBe(expectedInstruction); + }); +}); diff --git a/openai/instructions/documentCodeInstruction.ts b/openai/instructions/documentCodeInstruction.ts new file mode 100644 index 0000000..03593e2 --- /dev/null +++ b/openai/instructions/documentCodeInstruction.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { replaceTemplate } from "../../functions/replaceTemplate"; + +/** + * A template for instruction to document the provided source code. + * + * This template requires the following placeholder parameters to be replaced: + * * `{{LANG}}` - The programming language, e.g. `TypeScript`. + * * `{{FRAMEWORK}}` - The testing framework, e.g. `JSDoc`. + * @type {string} + */ +export const DOCUMENT_CODE_INSTRUCTION = `Let's go step by step. +Improve the {{FRAMEWORK}} documentations from the following {{LANG}} code:`; + +/** + * Generates instruction on to document code for the provided source code. + * + * @param {string} [language] - The programming language, e.g. `TypeScript`. If + * not provided, defaults to `TypeScript`. + * @param {string} [framework] - The testing framework, e.g. `JSDoc`. If not + * provided, defaults to `JSDoc`. + * @returns {string} Instruction to document code for the provided source + * code. + */ +export function documentCodeInstruction ( + language ?: string, + framework ?: string +) : string { + return replaceTemplate( + DOCUMENT_CODE_INSTRUCTION, + { + '{{LANG}}' : language ?? 'TypeScript', + '{{FRAMEWORK}}' : framework ?? 'JSDoc' + } + ); +} diff --git a/openai/instructions/exampleTypeScriptTest.test.ts b/openai/instructions/exampleTypeScriptTest.test.ts new file mode 100644 index 0000000..340adff --- /dev/null +++ b/openai/instructions/exampleTypeScriptTest.test.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { exampleTypeScriptTest } from "./exampleTypeScriptTest"; + +describe("exampleTypeScriptTest", () => { + it("generates an example of a TypeScript test with the provided class, method, and test names", () => { + const className = "MyClass"; + const methodName = "myMethod"; + const testName = "can do something"; + const expectedTest = `describe("MyClass", () => { + describe("myMethod", () => { + it("can do something", () => { + }); + }); +}); +`; + expect(exampleTypeScriptTest(className, methodName, testName)).toBe(expectedTest); + }); +}); diff --git a/openai/instructions/exampleTypeScriptTest.ts b/openai/instructions/exampleTypeScriptTest.ts new file mode 100644 index 0000000..127ea48 --- /dev/null +++ b/openai/instructions/exampleTypeScriptTest.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { replaceTemplate } from "../../functions/replaceTemplate"; + +/** + * A template for examples of how to write tests for a given programming + * language and testing framework. + * + * This template requires the following placeholder parameters to be replaced: + * * `{{CLASS_NAME}}` - The class name, e.g. `ExampleClass`. + * * `{{METHOD_NAME}}` - The method name, e.g. `someMethod`. + * * `{{TEST_NAME}}` - The test name, e.g. `can ...`. + * @type {string} + */ +export const EXAMPLE_TESTS_FOR_TYPESCRIPT_JEST = `describe("{{CLASS_NAME}}", () => { + describe("{{METHOD_NAME}}", () => { + it("{{TEST_NAME}}", () => { + }); + }); +}); +`; + +/** + * Generates an example of a TypeScript test for a given class, method, and test name. + * + * @param {string} [className] - The class name, e.g. `ExampleClass`. If not provided, defaults to `ExampleClass`. + * @param {string} [methodName] - The method name, e.g. `exampleMethod`. If not provided, defaults to `exampleMethod`. + * @param {string} [testName] - The test name, e.g. `should ...`. If not provided, defaults to `should ...`. + * @returns {string} An example of a TypeScript test for the given class, method, and test name. + */ +export function exampleTypeScriptTest ( + className ?: string, + methodName ?: string, + testName ?: string +) : string { + return replaceTemplate( + EXAMPLE_TESTS_FOR_TYPESCRIPT_JEST, + { + '{{CLASS_NAME}}' : className ?? 'ExampleClass', + '{{METHOD_NAME}}' : methodName ?? 'exampleMethod', + '{{TEST_NAME}}' : testName ?? 'should ...' + } + ); +} diff --git a/openai/instructions/writeTestsInstruction.test.ts b/openai/instructions/writeTestsInstruction.test.ts new file mode 100644 index 0000000..d1423b4 --- /dev/null +++ b/openai/instructions/writeTestsInstruction.test.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { writeTestsInstruction } from "./writeTestsInstruction"; + +describe("writeTestsInstruction", () => { + it("generates instructions on how to write tests for the provided source code", () => { + const language = "JavaScript"; + const framework = "Mocha"; + const examples = "Some example tests\n"; + const expectedInstruction = `// JavaScript +// Write test cases. +// Framework: Mocha + +Some example tests +`; + + expect(writeTestsInstruction(language, framework, examples)).toBe(expectedInstruction); + }); +}); diff --git a/openai/instructions/writeTestsInstruction.ts b/openai/instructions/writeTestsInstruction.ts new file mode 100644 index 0000000..3ec8c30 --- /dev/null +++ b/openai/instructions/writeTestsInstruction.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { replaceTemplate } from "../../functions/replaceTemplate"; +import { exampleTypeScriptTest } from "./exampleTypeScriptTest"; + +/** + * A template for instructions on how to write automated tests for the provided + * source code. + * + * This template requires the following placeholder parameters to be replaced: + * * `{{LANG}}` - The programming language, e.g. `TypeScript`. + * * `{{FRAMEWORK}}` - The testing framework, e.g. `Jest`. + * * `{{EXAMPLES}}` - Examples of how the tests should look. + * See `EXAMPLE_TESTS_FOR_TYPESCRIPT_JEST` below. + * @type {string} + */ +export const WRITE_TESTS_INSTRUCTION = `// {{LANG}} +// Write test cases. +// Framework: {{FRAMEWORK}} + +{{EXAMPLES}}`; + +/** + * Generates instructions on how to write tests for the provided source code. + * + * @param {string} [language] - The programming language, e.g. `TypeScript`. If + * not provided, defaults to `TypeScript`. + * @param {string} [framework] - The testing framework, e.g. `Jest`. If not + * provided, defaults to `Jest`. + * @param {string} [examples] - Examples of how the tests should look. If not + * provided, defaults to an example of a TypeScript + * test. + * @returns {string} Instructions on how to write tests for the provided source + * code. + */ +export function writeTestsInstruction ( + language ?: string, + framework ?: string, + examples ?: string +) : string { + return replaceTemplate( + WRITE_TESTS_INSTRUCTION, + { + '{{LANG}}' : language ?? 'TypeScript', + '{{FRAMEWORK}}' : framework ?? 'Jest', + '{{EXAMPLES}}' : examples ?? exampleTypeScriptTest() + } + ); +} diff --git a/openai/types/OpenAiApiModel.test.ts b/openai/types/OpenAiApiModel.test.ts new file mode 100644 index 0000000..8b0b494 --- /dev/null +++ b/openai/types/OpenAiApiModel.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainOpenAiModel, isOpenAiModel, OpenAiModel, parseOpenAiModel, stringifyOpenAiModel } from "./OpenAiModel"; + +describe("OpenAiApiModel", () => { + + describe("isOpenApiModel", () => { + + it("should return true for valid models", () => { + expect(isOpenAiModel(OpenAiModel.DAVINCI)).toBe(true); + expect(isOpenAiModel(OpenAiModel.CURIE)).toBe(true); + expect(isOpenAiModel(OpenAiModel.BABBAGE)).toBe(true); + expect(isOpenAiModel(OpenAiModel.ADA)).toBe(true); + // expect(isOpenApiModel(OpenAiApiModel.NEWTON)).toBe(true); + // expect(isOpenApiModel(OpenAiApiModel.LOVE)).toBe(true); + expect(isOpenAiModel(OpenAiModel.CODEX)).toBe(true); + // expect(isOpenApiModel(OpenAiApiModel.POPPY)).toBe(true); + expect(isOpenAiModel(OpenAiModel.CONTENT_FILTER)).toBe(true); + }); + + it("should return false for invalid models", () => { + expect(isOpenAiModel(null)).toBe(false); + expect(isOpenAiModel(undefined)).toBe(false); + expect(isOpenAiModel("")).toBe(false); + expect(isOpenAiModel("invalid")).toBe(false); + expect(isOpenAiModel(123)).toBe(false); + expect(isOpenAiModel({})).toBe(false); + expect(isOpenAiModel([])).toBe(false); + }); + + }); + + describe("explainOpenApiModel", () => { + it("should return 'incorrect enum value' message for invalid values", () => { + expect(explainOpenAiModel("invalid")).toMatch(/incorrect enum value "invalid" for OpenApiModel/); + expect(explainOpenAiModel(123)).toMatch(/incorrect enum value "123" for OpenApiModel/); + expect(explainOpenAiModel(null)).toMatch(/incorrect enum value "null" for OpenApiModel/); + expect(explainOpenAiModel(undefined)).toMatch(/incorrect enum value "undefined" for OpenApiModel/); + }); + + it("should return OK message for valid values", () => { + expect(explainOpenAiModel(OpenAiModel.DAVINCI)).toBe("OK"); + expect(explainOpenAiModel(OpenAiModel.CURIE)).toBe("OK"); + expect(explainOpenAiModel(OpenAiModel.BABBAGE)).toBe("OK"); + expect(explainOpenAiModel(OpenAiModel.ADA)).toBe("OK"); + // expect(explainOpenApiModel(OpenAiApiModel.LOVELACE)).toBe("OK"); + }); + }); + + describe('stringifyOpenApiModel', () => { + it('should return the string representation of the OpenAiApiModel', () => { + expect(stringifyOpenAiModel(OpenAiModel.DAVINCI)).toEqual('text-davinci-003'); + expect(stringifyOpenAiModel(OpenAiModel.CURIE)).toEqual('text-curie-001'); + expect(stringifyOpenAiModel(OpenAiModel.BABBAGE)).toEqual('text-babbage-001'); + expect(stringifyOpenAiModel(OpenAiModel.ADA)).toEqual('text-ada-001'); + }); + }); + + describe("parseOpenApiModel", () => { + + it("parses a string representation of a model into the corresponding enum value", () => { + expect(parseOpenAiModel("text-davinci-003")).toEqual(OpenAiModel.DAVINCI); + expect(parseOpenAiModel("text-curie-001")).toEqual(OpenAiModel.CURIE); + expect(parseOpenAiModel("text-babbage-001")).toEqual(OpenAiModel.BABBAGE); + expect(parseOpenAiModel("text-ada-001")).toEqual(OpenAiModel.ADA); + // expect(parseOpenApiModel("text-lovelace-002")).toEqual(OpenAiApiModel.LOVELACE); + }); + + it("returns undefined if the given string is not a valid model", () => { + expect(parseOpenAiModel("")).toBeUndefined(); + expect(parseOpenAiModel("invalid-model")).toBeUndefined(); + expect(parseOpenAiModel("text-davinci-004")).toBeUndefined(); + }); + + }); + +}); diff --git a/openai/types/OpenAiModel.ts b/openai/types/OpenAiModel.ts new file mode 100644 index 0000000..a430d31 --- /dev/null +++ b/openai/types/OpenAiModel.ts @@ -0,0 +1,308 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../types/Enum"; + +/** + * Enum for the available models in the OpenAI API. + * + * It is possible that there are other models available in the OpenAI API that + * are not listed in the `OpenAiModel` enum. To get an up-to-date list of + * available models, you can consult the OpenAI API documentation or make a + * request to the API's /models endpoint. + */ +export enum OpenAiModel { + + /** + * The most capable GPT-3 model. Can do any task the other models can do, + * often with higher quality, longer output and better instruction-following. + * Also supports [inserting completions](https://beta.openai.com/docs/guides/completion/inserting-text) within text. + * + * String presentation recognized by the OpenAI REST API: `text-davinci-003` + * + * Recommended default values: + * - max_tokens: 4,000 + * - temperature: 0.5 + * - top_p: 1 + * - frequency_penalty: 0 + * - presence_penalty: 0 + * + * Link for extra information: https://beta.openai.com/docs/models/gpt-3 + * + * Remarks: + * - This model is generally the most capable in the GPT-3 series. + * - The max_tokens parameter specifies the maximum number of tokens that + * the model is allowed to generate in its response. + * + * Alias names: `davinci` (older version) + */ + DAVINCI = 'text-davinci-003', + + + DAVINCI_EDIT_TEXT = 'text-davinci-edit-001', + DAVINCI_EDIT_CODE = 'code-davinci-edit-001', + + /** + * Very capable GPT-3 model, but faster and lower cost than Davinci. + * + * String presentation recognized by the OpenAI REST API: `text-curie-001` + * + * Recommended default values: + * - max_tokens: 2,048 + * - temperature: 0.5 + * - top_p: 1 + * - frequency_penalty: 0 + * - presence_penalty: 0 + * + * Link for extra information: https://beta.openai.com/docs/models/gpt-3 + * + * Remarks: + * - This model can perform many of the same tasks as Davinci, but faster + * and for 1/10th the cost. + * - The max_tokens parameter specifies the maximum number of tokens that + * the model is allowed to generate in its response. + * + * Alias names: `curie` (older version) + */ + CURIE = 'text-curie-001', + + /** + * Capable of straightforward tasks, very fast, and lower cost. + * + * String presentation recognized by the OpenAI REST API: `text-babbage-001` + * + * Recommended default values: + * - max_tokens: 2,048 + * - temperature: 0.5 + * - top_p: 1 + * - frequency_penalty: 0 + * - presence_penalty: 0 + * + * Link for extra information: https://beta.openai.com/docs/models/gpt-3 + * + * Remarks: + * - The max_tokens parameter specifies the maximum number of tokens that + * the model is allowed to generate in its response. + * + * Alias names: `babbage` (older version) + */ + BABBAGE = 'text-babbage-001', + + /** + * Capable of very simple tasks, usually the fastest model in the GPT-3 + * series, and lowest cost. + * + * String presentation recognized by the OpenAI REST API: `text-ada-001` + * + * Recommended default values: + * - max_tokens: 2,048 + * - temperature: 0.5 + * - top_p: 1 + * - frequency_penalty: 0 + * - presence_penalty: 0 + * + * Link for extra information: https://beta.openai.com/docs/models/gpt-3 + * + * Remarks: + * - The max_tokens parameter specifies the maximum number of tokens that + * the model is allowed to generate in its response. + * + * Alias names: `ada` (older version) + */ + ADA = 'text-ada-001', + + /** + * A fine-tuned model that can detect whether text may be sensitive or unsafe. + * + * String presentation recognized by the OpenAI REST API: `content-filter` + * + * Recommended default values: + * - max_tokens: 1 + * - temperature: 0.0 + * - top_p: 0 + * - frequency_penalty: 0 + * - presence_penalty: 0 + * - logprobs: 0 + * + * Wrap your prompt in the following way: + * ``` + * "<|endoftext|>[prompt]\n--\nLabel:" + * ``` + * + * Link for extra information: https://beta.openai.com/docs/models/content-filter + */ + CONTENT_FILTER = 'content-filter-alpha', + + /** + * A set of models that can understand and generate code, including + * translating natural language to code (Limited Beta). + * + * String presentation recognized by the OpenAI REST API: `codex` + * + * Link for extra information: https://beta.openai.com/docs/models/codex + * + * Recommended default values: + * - max_tokens: 8000 + * - temperature: 0.5 + * - top_p: 1 + * - frequency_penalty: 0 + * - presence_penalty: 0 + * + * Remarks: + * - This model is currently in limited beta. + */ + CODEX = 'code-davinci-002', + + /** + * More capable than any GPT-3.5 model, able to do more complex tasks, and optimized for chat. + * Will be updated with our latest model iteration 2 weeks after it is released. + */ + GPT_4 = "gpt-4", + + /** + * Same capabilities as the base gpt-4 mode but with 4x the context length. + * Will be updated with our latest model iteration. + */ + GPT_4_32K = "gpt-4-32k", + + /** + * Most capable GPT-3.5 model and optimized for chat at 1/10th the cost of text-davinci-003. + * Will be updated with our latest model iteration 2 weeks after it is released. + */ + GPT_3_5_TURBO = "gpt-3.5-turbo", + + /** + * Same capabilities as the standard gpt-3.5-turbo model but with 4 times the context. + */ + GPT_3_5_TURBO_16K = "gpt-3.5-turbo-16k", + +} + +/** + * Check if the given value is a valid `OpenAiModel`. + * + * @param {unknown} value - The value to check. + * @returns {value is OpenAiModel} `true` if the value is a valid `OpenAiModel`, `false` otherwise. + */ +export function isOpenAiModel (value: unknown) : value is OpenAiModel { + switch (value) { + case OpenAiModel.DAVINCI: + case OpenAiModel.DAVINCI_EDIT_TEXT: + case OpenAiModel.DAVINCI_EDIT_CODE: + case OpenAiModel.CURIE: + case OpenAiModel.BABBAGE: + case OpenAiModel.ADA: + case OpenAiModel.CONTENT_FILTER: + case OpenAiModel.CODEX: + case OpenAiModel.GPT_4: + case OpenAiModel.GPT_4_32K: + case OpenAiModel.GPT_3_5_TURBO: + case OpenAiModel.GPT_3_5_TURBO_16K: + return true; + default: + return false; + } +} + +/** + * Explain the given value with respect to the `OpenAiModel` enum. + * + * @param {unknown} value - The value to explain. + * @returns {string} A string explaining the value with respect to the `OpenAiModel` enum. + */ +export function explainOpenAiModel (value : unknown) : string { + return explainEnum("OpenApiModel", OpenAiModel, isOpenAiModel, value); +} + +/** + * Convert the given `OpenAiModel` value to a string. + * + * @param {OpenAiModel} value - The value to convert. + * @returns {string} The string representation of the `OpenAiModel` value. + * @throws {TypeError} If the value is not a valid `OpenAiModel`. + */ +export function stringifyOpenAiModel (value : OpenAiModel) : string { + switch (value) { + case OpenAiModel.DAVINCI : return 'text-davinci-003'; + case OpenAiModel.DAVINCI_EDIT_TEXT : return 'text-davinci-edit-001'; + case OpenAiModel.DAVINCI_EDIT_CODE : return 'code-davinci-edit-001'; + case OpenAiModel.CURIE : return 'text-curie-001'; + case OpenAiModel.BABBAGE : return 'text-babbage-001'; + case OpenAiModel.ADA : return 'text-ada-001'; + case OpenAiModel.CONTENT_FILTER : return 'content-filter-alpha'; + case OpenAiModel.CODEX : return 'code-davinci-002'; + case OpenAiModel.GPT_4 : return 'gpt-4'; + case OpenAiModel.GPT_4_32K : return 'gpt-4-32k'; + case OpenAiModel.GPT_3_5_TURBO : return 'gpt-3.5-turbo'; + case OpenAiModel.GPT_3_5_TURBO_16K : return 'gpt-3.5-turbo-16k'; + } + throw new TypeError(`Unsupported OpenApiModel value: ${value}`) +} + +/** + * Convert the given value to an `OpenAiModel` value, if possible. + * + * @param {unknown} value - The value to convert. + * @returns {(OpenAiModel | undefined)} The `OpenAiModel` representation of the value, or `undefined` if the value cannot be converted. + */ +export function parseOpenAiModel (value: any) : OpenAiModel | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toLowerCase()) { + + case 'edit' : + case 'text-edit' : + case 'text_edit' : + case 'text_davinci_edit' : + case 'text-davinci-edit-001' : return OpenAiModel.DAVINCI_EDIT_TEXT; + + case 'code_edit' : + case 'code-edit' : + case 'code_davinci_edit_001' : + case 'code-davinci-edit-001' : return OpenAiModel.DAVINCI_EDIT_CODE; + + case 'text' : + case 'text_davinci' : + case 'text-davinci' : + case 'text_davinci_003' : + case 'text-davinci-003' : + case 'davinci' : return OpenAiModel.DAVINCI; + + case 'text_curie_001' : + case 'text-curie-001' : + case 'curie' : return OpenAiModel.CURIE; + + case 'text_babbage_001' : + case 'text-babbage-001' : + case 'babbage' : return OpenAiModel.BABBAGE; + + case 'text_ada_001' : + case 'text-ada-001' : + case 'ada' : return OpenAiModel.ADA; + + case 'content-filter' : + case 'content_filter' : + case 'content_filter_alpha' : + case 'content-filter-alpha' : return OpenAiModel.CONTENT_FILTER; + + case 'codex' : + case 'code_davinci_002' : + case 'code-davinci-002' : return OpenAiModel.CODEX; + + case 'gpt4' : + case 'gpt-4' : + case 'gpt_4' : return OpenAiModel.GPT_4; + + case 'gtp4-32k' : + case 'gpt-4-32k' : + case 'gpt_4_32k' : return OpenAiModel.GPT_4_32K; + + case 'gpt3.5' : + case 'gpt3.5-turbo' : + case 'gpt-3.5-turbo' : return OpenAiModel.GPT_3_5_TURBO; + + case 'gpt3.5-16k' : + case 'gpt3.5-turbo-16k' : + case 'gpt-3.5-turbo-16k' : return OpenAiModel.GPT_3_5_TURBO_16K; + + default : return undefined; + } +} \ No newline at end of file diff --git a/openai/types/OpenAiUserType.ts b/openai/types/OpenAiUserType.ts new file mode 100644 index 0000000..922c3cc --- /dev/null +++ b/openai/types/OpenAiUserType.ts @@ -0,0 +1,51 @@ +import {explainEnum} from "../../types/Enum"; + +export enum OpenAiUserType { + USER = "user", + SYSTEM = "system", + ASSISTANT = "assistant", + FUNCTION = "function" +} + +export function isOpenAiUserType (value : any) : value is OpenAiUserType { + switch(value) { + case OpenAiUserType.USER: + case OpenAiUserType.SYSTEM: + case OpenAiUserType.ASSISTANT: + case OpenAiUserType.FUNCTION: + return true; + + default: + return false; + } +} + +export function stringifyOpenAiUserType (value: OpenAiUserType): string { + switch (value) { + case OpenAiUserType.USER : return 'user'; + case OpenAiUserType.SYSTEM : return 'system'; + case OpenAiUserType.ASSISTANT : return 'assistant'; + case OpenAiUserType.FUNCTION : return 'function'; + default : return `OpenAiUserType#${value}` + } +} + +/** + * Explain the given value with respect to the `OpenAiUserType` enum. + * + * @param {unknown} value - The value to explain. + * @returns {string} A string explaining the value with respect to the `OpenAiModel` enum. + */ +export function explainOpenAiUserType (value : unknown) : string { + return explainEnum("OpenAiUserType", OpenAiUserType, isOpenAiUserType, value); +} + +export function parseOpenAiUserType (value: any): OpenAiUserType | undefined { + switch (`${value}`.toLowerCase()) { + case 'user' : return OpenAiUserType.USER; + case 'system' : return OpenAiUserType.SYSTEM; + case 'assistant' : return OpenAiUserType.ASSISTANT; + case 'function' : return OpenAiUserType.FUNCTION; + default : return undefined; + } +} \ No newline at end of file diff --git a/paytrail/HttpPaytrailClient.system.test.ts b/paytrail/HttpPaytrailClient.system.test.ts new file mode 100644 index 0000000..625d6ff --- /dev/null +++ b/paytrail/HttpPaytrailClient.system.test.ts @@ -0,0 +1,618 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import "../../testing/jest/matchers/index"; +import { HttpPaytrailClient } from "./HttpPaytrailClient"; +import { PaytrailClient } from "./PaytrailClient"; +import { createPaytrailCustomer } from "./types/PaytrailCustomer"; +import { createPaytrailItem } from "./types/PaytrailItem"; +import { createPaytrailCallbackUrl } from "./types/PaytrailCallbackUrl"; +import { PaytrailCurrency } from "./types/PaytrailCurrency"; +import { PaytrailLanguage } from "./types/PaytrailLanguage"; +import { createPaytrailAddress } from "./types/PaytrailAddress"; +import { LogLevel } from "../types/LogLevel"; + +// Note: This is a system test intended to run on NodeJS but these imports will +// warn about error on frontend projects which do not have the node module +// @ts-ignore +import { HgNode } from "../../node/HgNode"; +// @ts-ignore +import { NodeRequestClient } from "../../node/requestClient/node/NodeRequestClient"; + +import { RequestClientImpl } from "../RequestClientImpl"; +import { keys } from "../functions/keys"; +import { isNonEmptyString } from "../types/String"; +import { every } from "../functions/every"; + +const TEST_API_URL = 'https://services.paytrail.com'; +const TEST_MERCHANT_ID = '375917'; +const TEST_MERCHANT_SECRET_KEY = 'SAIPPUAKAUPPIAS'; + +// Test constants +const TEST_STAMP = '29858472953'; +const TEST_REFERENCE = '9187445'; +const TEST_AMOUNT = 1590; +const TEST_CURRENCY = PaytrailCurrency.EUR; +const TEST_LANGUAGE = PaytrailLanguage.FI; +const TEST_UNIT_PRICE = 1590; +const TEST_UNITS = 1; +const TEST_VAT_PERCENTAGE = 24; +const TEST_PRODUCT_CODE = "#927502759"; +const TEST_DELIVERY_DATE = "2018-03-07"; +const TEST_DESCRIPTION = "Cat ladder"; +const TEST_CATEGORY = "Pet supplies"; + +const TEST_CUSTOMER_EMAIL = "erja.esimerkki@example.org"; +const TEST_CUSTOMER_FIRST_NAME = "Erja"; +const TEST_CUSTOMER_LAST_NAME = "Esimerkki"; +const TEST_CUSTOMER_PHONE = "+358501234567"; +const TEST_CUSTOMER_VAT_ID = "FI12345671"; + +const TEST_DELIVERY_ADDRESS_STREET = "Hämeenkatu 6 B"; +const TEST_DELIVERY_ADDRESS_POSTAL_CODE = "33100"; +const TEST_DELIVERY_ADDRESS_CITY = "Tampere"; +const TEST_DELIVERY_ADDRESS_COUNTY = "Pirkanmaa"; +const TEST_DELIVERY_ADDRESS_COUNTRY = "FI"; +const TEST_INVOICING_ADDRESS_STREET = "Testikatu 1"; +const TEST_INVOICING_ADDRESS_POSTAL_CODE = "00510"; +const TEST_INVOICING_ADDRESS_CITY = "Helsinki"; +const TEST_INVOICING_ADDRESS_COUNTY = "Uusimaa"; +const TEST_INVOICING_ADDRESS_COUNTRY = "FI"; +const TEST_CALLBACK_URL_SUCCESS = "https://ecom.example.org/success"; +const TEST_CALLBACK_URL_CANCEL = "https://ecom.example.org/cancel"; + +describe('system', () => { + + describe('HttpPaytrailClient', () => { + + let client : PaytrailClient; + + beforeAll( () => { + HgNode.initialize(); + HttpPaytrailClient.setLogLevel(LogLevel.NONE); + RequestClientImpl.setLogLevel(LogLevel.NONE); + NodeRequestClient.setLogLevel(LogLevel.NONE); + }); + + beforeEach(() => { + client = HttpPaytrailClient.create(TEST_MERCHANT_ID, TEST_MERCHANT_SECRET_KEY, TEST_API_URL); + }); + + describe('#createPayment', () => { + + it('should return a successful response with valid input', async () => { + + const UNIQUE_TEST_STAMP : string = `${TEST_STAMP}-${Date.now()}`; + const UNIQUE_TEST_REFERENCE : string = `${TEST_REFERENCE}-${Date.now()}`; + + const payment = await client.createPayment( + UNIQUE_TEST_STAMP, + UNIQUE_TEST_REFERENCE, + TEST_AMOUNT, + createPaytrailCustomer( + TEST_CUSTOMER_EMAIL, + TEST_CUSTOMER_FIRST_NAME, + TEST_CUSTOMER_LAST_NAME, + TEST_CUSTOMER_PHONE, + TEST_CUSTOMER_VAT_ID, + ), + [ + createPaytrailItem( + TEST_UNIT_PRICE, + TEST_UNITS, + TEST_VAT_PERCENTAGE, + TEST_PRODUCT_CODE, + TEST_DESCRIPTION, + TEST_CATEGORY, + undefined, + undefined, + undefined, + undefined, + undefined, + TEST_DELIVERY_DATE, + ) + ], + createPaytrailCallbackUrl( + TEST_CALLBACK_URL_SUCCESS, + TEST_CALLBACK_URL_CANCEL + ), + createPaytrailCallbackUrl( + TEST_CALLBACK_URL_SUCCESS, + TEST_CALLBACK_URL_CANCEL + ), + TEST_CURRENCY, + TEST_LANGUAGE, + createPaytrailAddress( + TEST_DELIVERY_ADDRESS_STREET, + TEST_DELIVERY_ADDRESS_POSTAL_CODE, + TEST_DELIVERY_ADDRESS_CITY, + TEST_DELIVERY_ADDRESS_COUNTRY, + TEST_DELIVERY_ADDRESS_COUNTY, + ), + createPaytrailAddress( + TEST_INVOICING_ADDRESS_STREET, + TEST_INVOICING_ADDRESS_POSTAL_CODE, + TEST_INVOICING_ADDRESS_CITY, + TEST_INVOICING_ADDRESS_COUNTRY, + TEST_INVOICING_ADDRESS_COUNTY, + ), + ); + + expect(payment).toBeDefined(); + expect(payment?.transactionId).toBeDefined(); // like `3426108e-1caa-11ee-ad14-871a14c22a32` + expect(payment?.href).toBe(`https://pay.paytrail.com/pay/${payment?.transactionId}`); + expect(payment?.terms).toBe('Valitsemalla maksutavan hyväksyt maksupalveluehdot'); + expect(payment?.reference).toBeDefined(); // Like `6248669867` + + expect(payment?.groups).toBeArray(); + expect(payment?.groups?.length).toBe(4); + + expect(payment?.groups).toEqual( + expect.arrayContaining([ + { + "icon": "https://resources.paytrail.com/images/payment-group-icons/bank.png", + "id": "bank", + "name": "Pankkimaksutavat", + "svg": "https://resources.paytrail.com/images/payment-group-icons/bank.svg", + }, + { + "icon": "https://resources.paytrail.com/images/payment-group-icons/mobile.png", + "id": "mobile", + "name": "Mobiilimaksutavat", + "svg": "https://resources.paytrail.com/images/payment-group-icons/mobile.svg", + }, + { + "icon": "https://resources.paytrail.com/images/payment-group-icons/creditcard.png", + "id": "creditcard", + "name": "Korttimaksutavat", + "svg": "https://resources.paytrail.com/images/payment-group-icons/creditcard.svg", + }, + { + "icon": "https://resources.paytrail.com/images/payment-group-icons/credit.png", + "id": "credit", + "name": "Lasku- ja osamaksutavat", + "svg": "https://resources.paytrail.com/images/payment-group-icons/credit.svg", + } + ]) + ); + + // payment.customProviders + + expect(payment?.customProviders).toBeRegularObject(); + expect(keys(payment?.customProviders)).toEqual(expect.arrayContaining(['applepay'])); + + expect((payment?.customProviders?.applepay as any)?.parameters).toBeArray(); + + // payment.customProviders.applepay.parameters + expect( + every( + (payment?.customProviders?.applepay as any)?.parameters, + (item: any) : boolean => isNonEmptyString(item?.name) && isNonEmptyString(item?.value) + ) + ).toBe(true); + + // payment.customProviders.applepay.parameters looks like: + // ``` + // [ + // { + // "name": "checkout-transaction-id", + // "value": "88f33fd2-1cab-11ee-bc95-ff97cca17a7f" + // }, + // { + // "name": "checkout-account", + // "value": "375917" + // }, + // { + // "name": "checkout-method", + // "value": "POST" + // }, + // { + // "name": "checkout-algorithm", + // "value": "sha256" + // }, + // { + // "name": "checkout-timestamp", + // "value": "2023-07-07T09:49:15.777Z" + // }, + // { + // "name": "checkout-nonce", + // "value": "115509cb-ae9f-4dc1-9fa8-18335eb52740" + // }, + // { + // "name": "signature", + // "value": "29e69941dddfe83c7072ffbe0dca4a7de144bd0a6381fdf9787c12715582489a" + // }, + // { + // "name": "amount", + // "value": "15.90" + // }, + // { + // "name": "label", + // "value": "Paytrail Oyj" + // }, + // { + // "name": "currency", + // "value": "EUR" + // } + // ] + // ``` + + // payment.providers + + // Looks like: + // ``` + // { + // "group": "credit", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/walley.png", + // "id": "collectorb2c", + // "name": "Collector", + // "parameters": Array [ + // { + // "name": "checkout-transaction-id", + // "value": "c0004672-1cac-11ee-b6f6-772ce8d3e82a", + // }, + // { + // "name": "checkout-account", + // "value": "375917", + // }, + // { + // "name": "checkout-method", + // "value": "POST", + // }, + // { + // "name": "checkout-algorithm", + // "value": "sha256", + // }, + // { + // "name": "checkout-timestamp", + // "value": "2023-07-07T09:57:57.631Z", + // }, + // { + // "name": "checkout-nonce", + // "value": "95a11fd3-501f-4fd2-bcf6-b754a4e420a0", + // }, + // { + // "name": "signature", + // "value": "727185586dbfe4496d1b1ddc89f4d36e733bcae8a84bc8aa07eaf72772a352b6", + // }, + // ], + // "svg": "https://resources.paytrail.com/images/payment-method-logos/walley.svg", + // "url": "https://pay.paytrail.com/payments/c0004672-1cac-11ee-b6f6-772ce8d3e82a/collectorb2c/", + // } + // ``` + + expect(payment?.providers).toBeArray(); + expect(payment?.providers?.length).toBe(17); + + expect(payment?.providers).toEqual( + expect.arrayContaining( [ + expect.objectContaining({ + group: 'bank', + icon: 'https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png', + id: 'osuuspankki', + name: 'OP', + svg: 'https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg', + url: `https://services.paytrail.com/payments/${payment?.transactionId}/osuuspankki/loading-and-redirect`, + }) + ] ) + ); + + expect(payment?.providers[0]?.parameters).toBeArray(); + expect( + every( + payment?.providers[0]?.parameters, + (item: any) : boolean => isNonEmptyString(item?.name) && isNonEmptyString(item?.value) + ) + ).toBe(true); + + }); + + }); + + describe('#getPayment', () => { + + it('should return a successful response with valid input', async () => { + + const UNIQUE_TEST_STAMP : string = `${TEST_STAMP}-${Date.now()}`; + const UNIQUE_TEST_REFERENCE : string = `${TEST_REFERENCE}-${Date.now()}`; + + const payment = await client.createPayment( + UNIQUE_TEST_STAMP, + UNIQUE_TEST_REFERENCE, + TEST_AMOUNT, + createPaytrailCustomer( + TEST_CUSTOMER_EMAIL, + TEST_CUSTOMER_FIRST_NAME, + TEST_CUSTOMER_LAST_NAME, + TEST_CUSTOMER_PHONE, + TEST_CUSTOMER_VAT_ID, + ), + [ + createPaytrailItem( + TEST_UNIT_PRICE, + TEST_UNITS, + TEST_VAT_PERCENTAGE, + TEST_PRODUCT_CODE, + TEST_DESCRIPTION, + TEST_CATEGORY, + undefined, + undefined, + undefined, + undefined, + undefined, + TEST_DELIVERY_DATE, + ) + ], + createPaytrailCallbackUrl( + TEST_CALLBACK_URL_SUCCESS, + TEST_CALLBACK_URL_CANCEL + ), + createPaytrailCallbackUrl( + TEST_CALLBACK_URL_SUCCESS, + TEST_CALLBACK_URL_CANCEL + ), + TEST_CURRENCY, + TEST_LANGUAGE, + createPaytrailAddress( + TEST_DELIVERY_ADDRESS_STREET, + TEST_DELIVERY_ADDRESS_POSTAL_CODE, + TEST_DELIVERY_ADDRESS_CITY, + TEST_DELIVERY_ADDRESS_COUNTRY, + TEST_DELIVERY_ADDRESS_COUNTY, + ), + createPaytrailAddress( + TEST_INVOICING_ADDRESS_STREET, + TEST_INVOICING_ADDRESS_POSTAL_CODE, + TEST_INVOICING_ADDRESS_CITY, + TEST_INVOICING_ADDRESS_COUNTRY, + TEST_INVOICING_ADDRESS_COUNTY, + ), + ); + + expect(payment).toBeDefined(); + expect(payment?.transactionId).toBeDefined(); // like `3426108e-1caa-11ee-ad14-871a14c22a32` + + const fetchedPayment = await client.getPayment(payment?.transactionId); + + // Like: + // ``` + // { + // "amount": 1590, + // "createdAt": "2023-07-07T10:09:23.578Z", + // "currency": "EUR", + // "href": "https://pay.checkout.fi/pay/58e17342-1cae-11ee-bbb8-5b1aeddddd28", + // "reference": "9187445-1688724563163", + // "stamp": "29858472953-1688724563163", + // "status": "new", + // "transactionId": "58e17342-1cae-11ee-bbb8-5b1aeddddd28" + // } + // ``` + + expect(fetchedPayment).toBeRegularObject(); + expect(fetchedPayment?.amount).toBe(1590); + expect(fetchedPayment?.createdAt).toBeIsoDateStringWithMilliseconds(); + expect(fetchedPayment?.currency).toBe('EUR'); + expect(fetchedPayment?.href).toBe(`https://pay.checkout.fi/pay/${payment?.transactionId}`); + expect(fetchedPayment?.reference).toBe(UNIQUE_TEST_REFERENCE); + expect(fetchedPayment?.stamp).toBe(UNIQUE_TEST_STAMP); + expect(fetchedPayment?.status).toBe('new'); + expect(fetchedPayment?.transactionId).toBe(payment?.transactionId); + + }); + + }); + + describe('#getMerchantsPaymentProviders', () => { + + it('should return a successful response with valid input', async () => { + + const dto = await client.getMerchantsPaymentProviders(); + + expect(dto).toBeArray(); + expect(dto?.length).toBe(17); + + expect(dto).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + "group": "bank", + "icon": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png", + "id": "osuuspankki", + "name": "OP", + "svg": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg", + }) + ] ) + ); + + // Like: + // ``` + // [ + // { + // "id": "pivo", + // "name": "Pivo", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/pivo.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/pivo.svg", + // "group": "mobile" + // }, + // { + // "id": "osuuspankki", + // "name": "OP", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg", + // "group": "bank" + // }, + // { + // "id": "nordea", + // "name": "Nordea", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/nordea.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/nordea.svg", + // "group": "bank" + // }, + // { + // "id": "handelsbanken", + // "name": "Handelsbanken", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.svg", + // "group": "bank" + // }, + // { + // "id": "pop", + // "name": "POP Pankki", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/pop.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/pop.svg", + // "group": "bank" + // }, + // { + // "id": "aktia", + // "name": "Aktia", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/aktia.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/aktia.svg", + // "group": "bank" + // }, + // { + // "id": "saastopankki", + // "name": "Säästöpankki", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.svg", + // "group": "bank" + // }, + // { + // "id": "omasp", + // "name": "Oma Säästöpankki", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/omasp.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/omasp.svg", + // "group": "bank" + // }, + // { + // "id": "spankki", + // "name": "S-pankki", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/spankki.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/spankki.svg", + // "group": "bank" + // }, + // { + // "id": "alandsbanken", + // "name": "Ålandsbanken", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.svg", + // "group": "bank" + // }, + // { + // "id": "danske", + // "name": "Danske Bank", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/danske.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/danske.svg", + // "group": "bank" + // }, + // { + // "id": "creditcard", + // "name": "Visa", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/visa.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/visa.svg", + // "group": "creditcard" + // }, + // { + // "id": "creditcard", + // "name": "Visa Electron", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.svg", + // "group": "creditcard" + // }, + // { + // "id": "creditcard", + // "name": "Mastercard", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/mastercard.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/mastercard.svg", + // "group": "creditcard" + // }, + // { + // "id": "amex", + // "name": "American Express", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/amex.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/amex.svg", + // "group": "creditcard" + // }, + // { + // "id": "collectorb2c", + // "name": "Collector", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/walley.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/walley.svg", + // "group": "credit" + // }, + // { + // "id": "collectorb2b", + // "name": "Collector B2B", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.png", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.svg", + // "group": "credit" + // } + // ] + // ``` + + // expect(dto).toBeRegularObject(); + + }); + + }); + + describe('#getMerchantsGroupedPaymentProviders', () => { + + it('should return a successful response with valid input', async () => { + + const dto = await client.getMerchantsGroupedPaymentProviders(); + + expect(dto).toBeRegularObject(); + expect(dto?.terms).toBe('Valitsemalla maksutavan hyväksyt maksupalveluehdot'); + + expect(dto?.providers).toEqual( + expect.arrayContaining([ + expect.objectContaining( + { + "group": "bank", + "icon": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png", + "id": "osuuspankki", + "name": "OP", + "svg": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg", + } + ) + ]) + ); + + expect( dto?.groups ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + "icon": "https://resources.paytrail.com/images/payment-group-icons/bank.png", + "id": "bank", + "name": "Pankkimaksutavat", + "svg": "https://resources.paytrail.com/images/payment-group-icons/bank.svg" + }) + ]) + ); + + expect( dto?.groups[0]?.providers ).toEqual( + expect.arrayContaining([{ + "group": "bank", + "icon": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png", + "id": "osuuspankki", + "name": "OP", + "svg": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg" + }]) + ); + + // expect(dto?.length).toBe(17); + // expect(dto[0]).toStrictEqual({ + // "group": "mobile", + // "icon": "https://resources.paytrail.com/images/payment-method-logos/pivo.png", + // "id": "pivo", + // "name": "Pivo", + // "svg": "https://resources.paytrail.com/images/payment-method-logos/pivo.svg", + // }); + + // Like: + // ``` + // ``` + + }); + + }); + + }); +}); diff --git a/paytrail/HttpPaytrailClient.test.ts b/paytrail/HttpPaytrailClient.test.ts new file mode 100644 index 0000000..889fb3e --- /dev/null +++ b/paytrail/HttpPaytrailClient.test.ts @@ -0,0 +1,302 @@ + +import { jest } from "@jest/globals"; +import { HttpPaytrailClient } from "./HttpPaytrailClient"; +import { PaytrailClient } from "./PaytrailClient"; +import { createPaytrailCustomer } from "./types/PaytrailCustomer"; +import { createPaytrailItem } from "./types/PaytrailItem"; +import { createPaytrailCallbackUrl } from "./types/PaytrailCallbackUrl"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { PaytrailCurrency } from "./types/PaytrailCurrency"; +import { PaytrailLanguage } from "./types/PaytrailLanguage"; +import { HgTest } from "../HgTest"; +import { PaytrailCreatePaymentDTO } from "./dtos/PaytrailCreatePaymentDTO"; +import { createPaytrailPaymentMethodGroupData } from "./types/PaytrailPaymentMethodGroupData"; +import { PaytrailPaymentMethodGroup } from "./types/PaytrailPaymentMethodGroup"; +import { createPaytrailProvider } from "./types/PaytrailProvider"; +import { createPaytrailFormField } from "./types/PaytrailFormField"; +import { parseJson } from "../Json"; +import { createPaytrailAddress } from "./types/PaytrailAddress"; +import { LogLevel } from "../types/LogLevel"; + +// Test constants +const TEST_STAMP = '29858472953'; +const TEST_REFERENCE = '9187445'; +const TEST_AMOUNT = 1590; +const TEST_CURRENCY = PaytrailCurrency.EUR; +const TEST_LANGUAGE = PaytrailLanguage.FI; +const TEST_UNIT_PRICE = 1590; +const TEST_UNITS = 1; +const TEST_VAT_PERCENTAGE = 24; +const TEST_PRODUCT_CODE = "#927502759"; +const TEST_DELIVERY_DATE = "2018-03-07"; +const TEST_DESCRIPTION = "Cat ladder"; +const TEST_CATEGORY = "Pet supplies"; + +const TEST_CUSTOMER_EMAIL = "erja.esimerkki@example.org"; +const TEST_CUSTOMER_FIRST_NAME = "Erja"; +const TEST_CUSTOMER_LAST_NAME = "Esimerkki"; +const TEST_CUSTOMER_PHONE = "+358501234567"; +const TEST_CUSTOMER_VAT_ID = "FI12345671"; + +const TEST_DELIVERY_ADDRESS_STREET = "Hämeenkatu 6 B"; +const TEST_DELIVERY_ADDRESS_POSTAL_CODE = "33100"; +const TEST_DELIVERY_ADDRESS_CITY = "Tampere"; +const TEST_DELIVERY_ADDRESS_COUNTY = "Pirkanmaa"; +const TEST_DELIVERY_ADDRESS_COUNTRY = "FI"; +const TEST_INVOICING_ADDRESS_STREET = "Testikatu 1"; +const TEST_INVOICING_ADDRESS_POSTAL_CODE = "00510"; +const TEST_INVOICING_ADDRESS_CITY = "Helsinki"; +const TEST_INVOICING_ADDRESS_COUNTY = "Uusimaa"; +const TEST_INVOICING_ADDRESS_COUNTRY = "FI"; +const TEST_REDIRECT_URL_SUCCESS = "https://ecom.example.org/success"; +const TEST_REDIRECT_URL_CANCEL = "https://ecom.example.org/cancel"; +const TEST_CALLBACK_URL_SUCCESS = "https://ecom.example.org/success"; +const TEST_CALLBACK_URL_CANCEL = "https://ecom.example.org/cancel"; + +const TEST_TIME = '2023-07-07T09:09:47.213Z'; + +// Request for the createPayment function +const TEST_CREATE_REQUEST = { + "stamp": TEST_STAMP, + "reference": TEST_REFERENCE, + "amount": TEST_AMOUNT, + "currency": TEST_CURRENCY, + "language": TEST_LANGUAGE, + "items": [ + { + "unitPrice": TEST_UNIT_PRICE, + "units": TEST_UNITS, + "vatPercentage": TEST_VAT_PERCENTAGE, + "productCode": TEST_PRODUCT_CODE, + "deliveryDate": TEST_DELIVERY_DATE, + "description": TEST_DESCRIPTION, + "category": TEST_CATEGORY + } + ], + "customer": { + "email": TEST_CUSTOMER_EMAIL, + "firstName": TEST_CUSTOMER_FIRST_NAME, + "lastName": TEST_CUSTOMER_LAST_NAME, + "phone": TEST_CUSTOMER_PHONE, + "vatId": TEST_CUSTOMER_VAT_ID + }, + "deliveryAddress": { + "streetAddress": TEST_DELIVERY_ADDRESS_STREET, + "postalCode": TEST_DELIVERY_ADDRESS_POSTAL_CODE, + "city": TEST_DELIVERY_ADDRESS_CITY, + "county": TEST_DELIVERY_ADDRESS_COUNTY, + "country": TEST_DELIVERY_ADDRESS_COUNTRY + }, + "invoicingAddress": { + "streetAddress": TEST_INVOICING_ADDRESS_STREET, + "postalCode": TEST_INVOICING_ADDRESS_POSTAL_CODE, + "city": TEST_INVOICING_ADDRESS_CITY, + "county": TEST_INVOICING_ADDRESS_COUNTY, + "country": TEST_INVOICING_ADDRESS_COUNTRY + }, + "redirectUrls": { + "success": TEST_REDIRECT_URL_SUCCESS, + "cancel": TEST_REDIRECT_URL_CANCEL + }, + "callbackUrls": { + "success": TEST_CALLBACK_URL_SUCCESS, + "cancel": TEST_CALLBACK_URL_CANCEL + } +}; + +const TEST_CREATE_PARAMS = { + "checkout-account": "mockAccount", + "checkout-algorithm": "sha256", + "checkout-method": "POST", + "checkout-nonce": "b800e31d15e9262ce69c23a21478ad38494775cf36a895e4fad8075c2e535995", + "checkout-timestamp": "2023-07-07T09:09:47.213Z", + "content-type": "application/json; charset=utf-8", + "signature": "c0403add6d437861923a68ab06d400dc72a03e5ec77aa1936054b13af3d0b2a8", +}; + +// Response from the createPayment function +const TEST_CREATE_RESPONSE : PaytrailCreatePaymentDTO = { + "transactionId": "5770642a-9a02-4ca2-8eaa-cc6260a78eb6", + "href": "https://services.paytrail.com/pay/5770642a-9a02-4ca2-8eaa-cc6260a78eb6", + "reference": TEST_REFERENCE, + "terms": "By continuing with your payment, you agree to our payment terms & conditions", + groups: [ + createPaytrailPaymentMethodGroupData( + PaytrailPaymentMethodGroup.MOBILE, + "Mobile payment methods", + "https://static.paytrail.com/static/img/payment-groups/mobile.png", + "https://static.paytrail.com/static/img/payment-groups/mobile.svg" + ) + ], + "providers": [ + createPaytrailProvider( + "https://static.paytrail.com/static/img/pivo_140x75.png", + "https://static.paytrail.com/static/img/payment-methods/pivo-siirto.svg", + PaytrailPaymentMethodGroup.MOBILE, + "Pivo", + "pivo", + "https://maksu.pivo.fi/api/payments", + [ + createPaytrailFormField("amount", "base64 MTUyNQ==") + ] + ) + ], + "customProviders": { + "applepay": { + "parameters": [ + { + "name": "amount", + "value": "15.25" + } + ] + } + } +}; + +const TEST_FETCH_RESPONSE = { + "transactionId": "681538c4-fc84-11e9-83bc-2ffcef4c3453", + "status": "new", + "amount": 1689, + "currency": "EUR", + "reference": "4940046476", + "stamp": "15725981193483", + "createdAt": "2019-11-01T10:48:39.979Z", + "href": "https://pay.paytrail.com/pay/681538c4-fc84-11e9-83bc-2ffcef4c3453" +}; + +describe('HttpPaytrailClient', () => { + + let client : PaytrailClient; + let postTextSpy : jest.SpiedFunction<(...args: any) => any>; + let getTextSpy : jest.SpiedFunction<(...args: any) => any>; + + beforeAll( () => { + HgTest.initialize(); + HttpPaytrailClient.setLogLevel(LogLevel.NONE); + + jest + .useFakeTimers() + .setSystemTime(new Date(TEST_TIME)); + + postTextSpy = jest.spyOn(RequestClientImpl, 'postText').mockResolvedValue(JSON.stringify(TEST_CREATE_RESPONSE)); + getTextSpy = jest.spyOn(RequestClientImpl, 'getText').mockResolvedValue(JSON.stringify(TEST_FETCH_RESPONSE)); + + }); + + beforeEach(() => { + client = HttpPaytrailClient.create('mockAccount', 'mockSecret', 'http://mockUrl'); + postTextSpy.mockClear(); + getTextSpy.mockClear(); + }); + + describe('createPayment', () => { + + it('should return a successful response with valid input', async () => { + + const payment : any = await client.createPayment( + TEST_STAMP, + TEST_REFERENCE, + TEST_AMOUNT, + createPaytrailCustomer( + TEST_CUSTOMER_EMAIL, + TEST_CUSTOMER_FIRST_NAME, + TEST_CUSTOMER_LAST_NAME, + TEST_CUSTOMER_PHONE, + TEST_CUSTOMER_VAT_ID, + ), + [ + createPaytrailItem( + TEST_UNIT_PRICE, + TEST_UNITS, + TEST_VAT_PERCENTAGE, + TEST_PRODUCT_CODE, + TEST_DESCRIPTION, + TEST_CATEGORY, + undefined, + undefined, + undefined, + undefined, + undefined, + TEST_DELIVERY_DATE, + ) + ], + createPaytrailCallbackUrl( + TEST_CALLBACK_URL_SUCCESS, + TEST_CALLBACK_URL_CANCEL + ), + createPaytrailCallbackUrl( + TEST_CALLBACK_URL_SUCCESS, + TEST_CALLBACK_URL_CANCEL + ), + TEST_CURRENCY, + TEST_LANGUAGE, + createPaytrailAddress( + TEST_DELIVERY_ADDRESS_STREET, + TEST_DELIVERY_ADDRESS_POSTAL_CODE, + TEST_DELIVERY_ADDRESS_CITY, + TEST_DELIVERY_ADDRESS_COUNTRY, + TEST_DELIVERY_ADDRESS_COUNTY, + ), + createPaytrailAddress( + TEST_INVOICING_ADDRESS_STREET, + TEST_INVOICING_ADDRESS_POSTAL_CODE, + TEST_INVOICING_ADDRESS_CITY, + TEST_INVOICING_ADDRESS_COUNTRY, + TEST_INVOICING_ADDRESS_COUNTY, + ), + ); + + expect(payment).toMatchObject(TEST_CREATE_RESPONSE as any); + expect(RequestClientImpl.postText).toHaveBeenCalledTimes(1); + expect((RequestClientImpl.postText as any).mock.calls[0][0]).toBe('http://mockUrl/payments'); + expect( parseJson( (RequestClientImpl.postText as any).mock.calls[0][1] )).toStrictEqual(TEST_CREATE_REQUEST); + expect((RequestClientImpl.postText as any).mock.calls[0][2]).toStrictEqual(TEST_CREATE_PARAMS); + + }); + + it('should throw an error with invalid input', async () => { + await expect(client.createPayment( + TEST_STAMP, + TEST_REFERENCE, + // @ts-ignore + "invalid", + createPaytrailCustomer( + TEST_CUSTOMER_EMAIL, + TEST_CUSTOMER_FIRST_NAME, + TEST_CUSTOMER_LAST_NAME, + TEST_CUSTOMER_PHONE, + TEST_CUSTOMER_VAT_ID, + ), + [ + createPaytrailItem( + TEST_UNIT_PRICE, + TEST_UNITS, + TEST_VAT_PERCENTAGE, + TEST_PRODUCT_CODE, + TEST_DESCRIPTION, + TEST_CATEGORY, + undefined, + TEST_STAMP, + TEST_REFERENCE, + undefined, + undefined, + TEST_DELIVERY_DATE, + ) + ], + createPaytrailCallbackUrl( + TEST_CALLBACK_URL_SUCCESS, + TEST_CALLBACK_URL_CANCEL + ), + createPaytrailCallbackUrl( + TEST_CALLBACK_URL_SUCCESS, + TEST_CALLBACK_URL_CANCEL + ), + TEST_CURRENCY, + TEST_LANGUAGE + )).rejects.toThrowError('property "amount" not number'); + expect(RequestClientImpl.postText).not.toHaveBeenCalled(); + }); + + }); + +}); diff --git a/paytrail/HttpPaytrailClient.ts b/paytrail/HttpPaytrailClient.ts new file mode 100644 index 0000000..f76790a --- /dev/null +++ b/paytrail/HttpPaytrailClient.ts @@ -0,0 +1,293 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { PaytrailClient } from "./PaytrailClient"; +import { createHash, createHmac } from "crypto"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { startsWith } from "../functions/startsWith"; +import { map } from "../functions/map"; +import { PaytrailItem } from "./types/PaytrailItem"; +import { CreatePaymentRequestDTO, explainCreatePaymentRequestDTO, isCreatePaymentRequestDTO } from "./dtos/CreatePaymentRequestDTO"; +import { PaytrailCurrency } from "./types/PaytrailCurrency"; +import { PaytrailLanguage } from "./types/PaytrailLanguage"; +import { PaytrailCustomer } from "./types/PaytrailCustomer"; +import { PaytrailCallbackUrl } from "./types/PaytrailCallbackUrl"; +import { parseJson } from "../Json"; +import { PaytrailCreatePaymentDTO, explainPaytrailCreatePaymentDTO, isPaytrailCreatePaymentDTO } from "./dtos/PaytrailCreatePaymentDTO"; +import { LogService } from "../LogService"; +import { explainPaytrailPaymentDTO, isPaytrailPaymentDTO, PaytrailPaymentDTO } from "./dtos/PaytrailPaymentDTO"; +import { DEFAULT_PAYTRAIL_API_URL } from "./paytrail-constants"; +import { PaytrailAddress } from "./types/PaytrailAddress"; +import { LogLevel } from "../types/LogLevel"; +import { PaytrailPaymentMethodGroup } from "./types/PaytrailPaymentMethodGroup"; +import { explainPaytrailPaymentProviderListDTO, isPaytrailPaymentProviderListDTO, PaytrailPaymentProviderListDTO } from "./dtos/PaytrailPaymentProviderListDTO"; +import { explainPaytrailProvider, isPaytrailProvider } from "./types/PaytrailProvider"; +import { explainArrayOf, isArrayOf } from "../types/Array"; +import { isPaytrailLimitedProvider, PaytrailLimitedProvider } from "./types/PaytrailLimitedProvider"; + +const LOG = LogService.createLogger( 'HttpPaytrailClient' ); + +export class HttpPaytrailClient implements PaytrailClient { + + public static setLogLevel(level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _account: string; + private readonly _secret : string; + private readonly _url : string; + + private constructor ( + account : string, + secret : string, + url : string + ) { + this._account = account; + this._secret = secret; + this._url = url; + } + + public static create ( + account : string, + secret : string, + url : string = DEFAULT_PAYTRAIL_API_URL + ) { + return new HttpPaytrailClient(account, secret, url); + } + + /** + * + * @inheritDoc + */ + public async createPayment ( + stamp : string, + reference : string, + amount : number, + customer : PaytrailCustomer, + items : readonly PaytrailItem[] | undefined, + redirectUrls : PaytrailCallbackUrl, + callbackUrls ?: PaytrailCallbackUrl, + currency : PaytrailCurrency = PaytrailCurrency.EUR, + language : PaytrailLanguage = PaytrailLanguage.FI, + deliveryAddress ?: PaytrailAddress, + invoicingAddress ?: PaytrailAddress, + groups ?: readonly PaytrailPaymentMethodGroup[], + ): Promise { + const body : CreatePaymentRequestDTO = this._getCreatePaymentRequestBody( + stamp, + reference, + amount, + customer, + items, + redirectUrls, + callbackUrls, + currency, + language, + deliveryAddress, + invoicingAddress, + groups + ); + return this._createPayment( body ); + } + + /** + * @inheritDoc + */ + public isPaid (params: { [key: string]: any }): boolean { + return params['checkout-status'] === 'ok'; + } + + /** + * @inheritDoc + */ + public async getPayment (transactionId: string): Promise { + if (!transactionId) throw new Error('No transaction ID defined!'); + const headers = this._createHeaders('GET', transactionId); + headers['signature'] = this._calculateHmac(this._secret, headers); + const resultString = await RequestClientImpl.getText( + `${this._url}/payments/${transactionId}`, + headers + ); + const result = parseJson(resultString); + if (!isPaytrailPaymentDTO(result)) { + throw new Error(`Response was not PaytrailPaymentDTO: ${explainPaytrailPaymentDTO(result)}`); + } + LOG.debug(`getTransaction: ${transactionId}: result = `, result); + return result; + } + + /** + * @inheritDoc + */ + public validateRequestParams ( + params : {[key: string]: string}, + body ?: string + ): boolean { + const signature = params?.signature; + return signature === this._calculateHmac(this._secret, params, body); + } + + /** + * @inheritDoc + */ + public async getMerchantsPaymentProviders ( + amount ?: number, + groups ?: readonly PaytrailPaymentMethodGroup[] + ) : Promise { + const queryParams = [ + ...(amount !== undefined? [`amount=${amount}`] : []), + ...(groups !== undefined? [`groups=${groups.join(',')}`] : []) + ]; + const headers = this._createHeaders('GET'); + headers['signature'] = this._calculateHmac(this._secret, headers); + const resultString = await RequestClientImpl.getText( + `${this._url}/merchants/payment-providers${queryParams?.length ? `?${queryParams.join('&')}`: ''}`, + headers + ); + const result = parseJson(resultString); + if (!isArrayOf(result, isPaytrailLimitedProvider)) { + throw new Error(`Response was not array of PaytrailLimitedProvider: ${explainArrayOf("PaytrailProvider", explainPaytrailProvider, result, isPaytrailProvider)}: ${JSON.stringify(result, null , 2)}`); + } + LOG.debug(`getMerchantsPaymentProviders: result = `, result); + return result; + } + + /** + * @inheritDoc + */ + public async getMerchantsGroupedPaymentProviders ( + language ?: PaytrailLanguage, + amount ?: number, + groups ?: readonly PaytrailPaymentMethodGroup[] + ) : Promise { + const queryParams = [ + ...(amount !== undefined? [`amount=${amount}`] : []), + ...(groups !== undefined? [`groups=${groups.join(',')}`] : []), + ...(language !== undefined? [`language=${language}`] : []), + ]; + const headers = this._createHeaders('GET'); + headers['signature'] = this._calculateHmac(this._secret, headers); + const resultString = await RequestClientImpl.getText( + `${this._url}/merchants/grouped-payment-providers${queryParams?.length ? `?${queryParams.join('&')}`: ''}`, + headers + ); + const result = parseJson(resultString); + if (!isPaytrailPaymentProviderListDTO(result)) { + throw new Error(`Response was not PaytrailPaymentProviderListDTO: ${explainPaytrailPaymentProviderListDTO(result)}: ${JSON.stringify(result, null , 2)}`); + } + LOG.debug(`getMerchantsGroupedPaymentProviders: result = `, result); + return result; + } + + //////////////////////////// PRIVATE METHODS /////////////////////////////// + + private _createHeaders ( + method : string, + transactionId ?: string + ) : {[key: string]: string} { + return { + ...(transactionId ? { 'checkout-transaction-id': transactionId } : {}), + 'checkout-account': this._account, + 'checkout-algorithm': 'sha256', + 'checkout-method': method, + 'checkout-nonce': HttpPaytrailClient._hash('sha256', Date.now().toString()), + 'checkout-timestamp': new Date().toISOString(), + 'content-type': 'application/json; charset=utf-8' + }; + } + + /** + * HTTP POST /payments creates a new open payment and returns a JSON object + * that includes the available payment methods. The merchant web shop + * renders HTML forms from the response objects (see example). The client + * browser will submit the form to the payment method provider. + * + * Once the payment has been completed the client browser will return to the + * merchant provided redirect URL. + * + * List providers endpoint can be used to receive available payment methods + * without opening a new payment. + * + * @param body + * @private + */ + private async _createPayment ( + body: CreatePaymentRequestDTO + ) : Promise { + const headers = this._createHeaders('POST'); + const bodyString = JSON.stringify(body, null, 2); + headers['signature'] = this._calculateHmac(this._secret, headers, bodyString); + const resultString = await RequestClientImpl.postText( + `${this._url}/payments`, + bodyString, + headers + ); + const result = parseJson(resultString); + if (!isPaytrailCreatePaymentDTO(result)) { + throw new Error(`Response was not PaytrailCreatePaymentDTO: ${explainPaytrailCreatePaymentDTO(result)}`); + } + LOG.debug(`_createPayment: result = `, result); + return result; + } + + private _getCreatePaymentRequestBody ( + stamp : string, + reference : string, + amount : number, + customer : PaytrailCustomer, + items : readonly PaytrailItem[] | undefined, + redirectUrls : PaytrailCallbackUrl, + callbackUrls ?: PaytrailCallbackUrl, + currency : PaytrailCurrency = PaytrailCurrency.EUR, + language : PaytrailLanguage = PaytrailLanguage.FI, + deliveryAddress ?: PaytrailAddress, + invoicingAddress ?: PaytrailAddress, + groups ?: readonly PaytrailPaymentMethodGroup[], + ): CreatePaymentRequestDTO { + const dto : CreatePaymentRequestDTO = { + stamp, + reference, + amount, + currency, + language, + customer, + items: items && items.length ? map( items, (item: PaytrailItem) => item ) : undefined, + redirectUrls, + ...(callbackUrls ? {callbackUrls} : {}), + ...(deliveryAddress ? {deliveryAddress} : {}), + ...(invoicingAddress ? {invoicingAddress} : {}), + ...(groups ? {groups} : {}), + }; + if (!isCreatePaymentRequestDTO(dto)) { + throw new TypeError(`Invalid input CreatePaymentRequestDTO: ${explainCreatePaymentRequestDTO(dto)}`); + } + return dto; + } + + private _calculateHmac ( + secret : string, + params : {[key: string]: string}, + body ?: string + ): string { + + // Keep only checkout- params, more relevant for response validation. Filter query + // string parameters the same way - the signature includes only checkout- values. + let includedKeys = Object.keys(params).filter((key) => startsWith(key, 'checkout-')); + + // Keys must be sorted alphabetically + includedKeys.sort(); + + let hmacPayload = includedKeys.map((key) => `${key}:${params[key]}`); + hmacPayload.push(body ?? ''); + const hmac = createHmac('sha256', secret); + const data = hmacPayload.join('\n'); + hmac.update(data); + return hmac.digest('hex'); + } + + private static _hash (algorithm: string, data: string): string { + const hash = createHash(algorithm); + hash.update(data); + return hash.digest('hex'); + } + +} diff --git a/paytrail/PaytrailClient.ts b/paytrail/PaytrailClient.ts new file mode 100644 index 0000000..aae4a8d --- /dev/null +++ b/paytrail/PaytrailClient.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { PaytrailCreatePaymentDTO } from "./dtos/PaytrailCreatePaymentDTO"; +import { PaytrailCustomer } from "./types/PaytrailCustomer"; +import { PaytrailItem } from "./types/PaytrailItem"; +import { PaytrailCallbackUrl } from "./types/PaytrailCallbackUrl"; +import { PaytrailCurrency } from "./types/PaytrailCurrency"; +import { PaytrailLanguage } from "./types/PaytrailLanguage"; +import { PaytrailPaymentDTO } from "./dtos/PaytrailPaymentDTO"; +import { PaytrailAddress } from "./types/PaytrailAddress"; +import { PaytrailPaymentMethodGroup } from "./types/PaytrailPaymentMethodGroup"; +import { PaytrailPaymentProviderListDTO } from "./dtos/PaytrailPaymentProviderListDTO"; +import { PaytrailLimitedProvider } from "./types/PaytrailLimitedProvider"; + +/** + * Paytrail payment API client + */ +export interface PaytrailClient { + + /** + * Creates a new open payment and returns a DTO object + * that includes the available payment methods. The merchant web shop + * renders HTML forms from the response objects (see example). The client + * browser will submit the form to the payment method provider. + * + * Once the payment has been completed the client browser will return to the + * merchant provided redirect URL. + * + * List providers endpoint can be used to receive available payment methods + * without opening a new payment. + * + * @param stamp + * @param reference + * @param amount + * @param customer + * @param items + * @param redirectUrls + * @param callbackUrls + * @param currency + * @param language + * @param deliveryAddress + * @param invoicingAddress + * @param groups + */ + createPayment ( + stamp : string, + reference : string, + amount : number, + customer : PaytrailCustomer, + items : readonly PaytrailItem[] | undefined, + redirectUrls : PaytrailCallbackUrl, + callbackUrls ?: PaytrailCallbackUrl, + currency ?: PaytrailCurrency, + language ?: PaytrailLanguage, + deliveryAddress ?: PaytrailAddress, + invoicingAddress ?: PaytrailAddress, + groups ?: readonly PaytrailPaymentMethodGroup[], + ): Promise; + + /** + * Returns the payment + * + * @param transactionId + */ + getPayment (transactionId: string): Promise; + + /** + * Validate request parameters. + * + * @param params + * @param body + */ + validateRequestParams ( + params : {[key: string]: string}, + body ?: string + ): boolean; + + /** + * Check if the request was paid + * + * @param data The request params + */ + isPaid (data: { [key: string]: any }): boolean; + + /** + * Returns a list of available providers for the merchant. This endpoint can + * be used for example to show + * available payment methods in checkout without initializing a new payment + * before the user actually proceeds to pay their order. + * + * @see https://docs.paytrail.com/#/?id=payments + * @see https://docs.paytrail.com/#/?id=list-providers + * @param amount Purchase amount in currency's minor unit. Some payment + * methods have minimum or maximum purchase limits. When the + * amount is provided, only the methods suitable for the + * amount are returned. Otherwise, all merchant's payment + * methods are returned. + * @param groups List of payment method groups to include. Otherwise all + * enabled methods are returned. + */ + getMerchantsPaymentProviders ( + amount ?: number, + groups ?: readonly PaytrailPaymentMethodGroup[] + ) : Promise; + + /** + * HTTP GET /merchants/grouped-payment-providers is similar to the List + * providers-endpoint, but in addition of returning a flat list of + * providers, it returns payment group data containing localized group + * names, icons for the groups and grouped providers. Returns also a + * localized text with a link to the terms of payment. + * + * @see https://docs.paytrail.com/#/?id=list-grouped-providers + * @param amount Purchase amount in currency's minor unit. Some payment + * methods have minimum or maximum purchase limits. When the + * amount is provided, only the methods suitable for the + * amount are returned. Otherwise, all merchant's payment + * methods are returned. + * @param groups Comma separated list of payment method groups to include. + * Otherwise all enabled methods are returned. + * @param language Code of the language the terms of payment and the payment + * group names will be displayed in. Supports only FI, EN + * and SV. FI is the default if left undefined. + */ + getMerchantsGroupedPaymentProviders ( + language ?: PaytrailLanguage, + amount ?: number, + groups ?: readonly PaytrailPaymentMethodGroup[] + ) : Promise; + +} diff --git a/paytrail/dtos/CreatePaymentRequestDTO.test.ts b/paytrail/dtos/CreatePaymentRequestDTO.test.ts new file mode 100644 index 0000000..cd3f620 --- /dev/null +++ b/paytrail/dtos/CreatePaymentRequestDTO.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { PaytrailCurrency } from "../types/PaytrailCurrency"; +import { PaytrailCustomer } from "../types/PaytrailCustomer"; +import { PaytrailCallbackUrl } from "../types/PaytrailCallbackUrl"; +import { PaytrailLanguage } from "../types/PaytrailLanguage"; +import { createCreatePaymentRequestDTO, CreatePaymentRequestDTO, isCreatePaymentRequestDTO, parseCreatePaymentRequestDTO } from "./CreatePaymentRequestDTO"; + +describe('CreatePaymentRequestDTO', () => { + + const validDTO : CreatePaymentRequestDTO = { + stamp: 'stamp', + reference: 'reference', + amount: 100, + currency: PaytrailCurrency.EUR, + language: PaytrailLanguage.EN, + customer: { + email: 'john.doe@example.org', + firstName: 'John', + lastName: 'Doe', + phone: '358451031234', + vatId: 'FI02454583', + companyName: 'Example company' + }, + redirectUrls: { + success: 'https://example.org/51/success', + cancel: 'https://example.org/51/cancel' + }, + }; + + const invalidDTO : any = { + stamp: 'stamp', + reference: 'reference', + amount: '100', // should be a number + currency: PaytrailCurrency.EUR, + language: PaytrailLanguage.EN, + customer: {} as PaytrailCustomer, + redirectUrls: {} as PaytrailCallbackUrl, + }; + + describe('createCreatePaymentRequestDTO', () => { + + it('should return an object matching the input arguments', () => { + const dto = createCreatePaymentRequestDTO( + validDTO.stamp, + validDTO.reference, + validDTO.amount, + validDTO.currency, + validDTO.language, + validDTO.customer, + validDTO.redirectUrls + ); + expect(dto).toEqual(validDTO); + }); + + }); + + describe('isCreatePaymentRequestDTO', () => { + + it('should return true for a valid DTO', () => { + expect(isCreatePaymentRequestDTO(validDTO)).toBe(true); + }); + + it('should return false for an invalid DTO', () => { + expect(isCreatePaymentRequestDTO(invalidDTO)).toBe(false); + }); + + }); + + describe('parseCreatePaymentRequestDTO', () => { + + it('should return the original object for a valid DTO', () => { + expect(parseCreatePaymentRequestDTO(validDTO)).toEqual(validDTO); + }); + + it('should return undefined for an invalid DTO', () => { + expect(parseCreatePaymentRequestDTO(invalidDTO)).toBeUndefined(); + }); + + }); + +}); diff --git a/paytrail/dtos/CreatePaymentRequestDTO.ts b/paytrail/dtos/CreatePaymentRequestDTO.ts new file mode 100644 index 0000000..12522eb --- /dev/null +++ b/paytrail/dtos/CreatePaymentRequestDTO.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; +import { explainPaytrailCurrency, isPaytrailCurrency, PaytrailCurrency } from "../types/PaytrailCurrency"; +import { explainPaytrailLanguage, isPaytrailLanguage, PaytrailLanguage } from "../types/PaytrailLanguage"; +import { explainPaytrailItem, isPaytrailItem, PaytrailItem } from "../types/PaytrailItem"; +import { explainPaytrailCustomer, isPaytrailCustomer, PaytrailCustomer } from "../types/PaytrailCustomer"; +import { explainPaytrailAddressOrUndefined, isPaytrailAddressOrUndefined, PaytrailAddress } from "../types/PaytrailAddress"; +import { explainPaytrailCallbackUrl, explainPaytrailCallbackUrlOrUndefined, isPaytrailCallbackUrl, isPaytrailCallbackUrlOrUndefined, PaytrailCallbackUrl } from "../types/PaytrailCallbackUrl"; +import { explainPaytrailPaymentMethodGroup, isPaytrailPaymentMethodGroup, PaytrailPaymentMethodGroup } from "../types/PaytrailPaymentMethodGroup"; +import { explainNumber, explainNumberOrUndefined, isNumber, isNumberOrUndefined } from "../../types/Number"; +import { explainArrayOfOrUndefined, isArrayOfOrUndefined } from "../../types/Array"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../types/Boolean"; + +/** + * @see https://docs.paytrail.com/#/?id=payloads + */ +export interface CreatePaymentRequestDTO { + + /** + * Merchant unique identifier for the order. Maximum of 200 characters. + */ + readonly stamp: string; + + /** + * Order reference. Maximum of 200 characters. + */ + readonly reference: string; + + /** + * Total amount of the payment in currency's minor units, e.g. for Euros use + * cents. Must match the total sum of items and must be more than zero. By + * default amount should include VAT, unless usePricesWithoutVat is set to + * true. Maximum value of 99999999. + */ + readonly amount: number; + + /** + * Currency, only EUR supported at the moment + */ + readonly currency: PaytrailCurrency; + + /** + * Payment's language, currently supported are FI, SV, and EN + */ + readonly language: PaytrailLanguage; + + /** + * Order ID. Used for e.g. Walley/Collector payments order ID. If not given, + * merchant reference is used instead. + */ + readonly orderId ?: string; + + /** + * Array of items. Always required for Shop-in-Shop payments. Required if + * VAT calculations are wanted in settlement reports. + */ + readonly items ?: readonly PaytrailItem[]; + + /** + * Customer information + */ + readonly customer : PaytrailCustomer; + + /** + * Delivery address + */ + readonly deliveryAddress ?: PaytrailAddress; + + /** + * Invoicing address + */ + readonly invoicingAddress ?: PaytrailAddress; + + /** + * If paid with invoice payment method, the invoice will not be activated + * automatically immediately. Currently only supported with Walley/Collector. + */ + readonly manualInvoiceActivation ?: boolean; + + /** + * Where to redirect browser after a payment is paid or cancelled. + */ + readonly redirectUrls : PaytrailCallbackUrl; + + /** + * Which url to ping after this payment is paid or cancelled + */ + readonly callbackUrls ?: PaytrailCallbackUrl; + + /** + * Callback URL polling delay in seconds. If callback URLs are given, the + * call can be delayed up to 900 seconds. Default: 0 + */ + readonly callbackDelay ?: number; + + /** + * Instead of all enabled payment methods, return only those of given groups. + * It is highly recommended to use list providers before initiating the + * payment if filtering by group. If the payment methods are rendered in the + * webshop the grouping functionality can be implemented based on the group + * attribute of each returned payment instead of filtering when creating a + * payment. + */ + readonly groups ?: readonly PaytrailPaymentMethodGroup[]; + + /** + * If true, amount and items.unitPrice should be sent to API not including + * VAT, and final amount is calculated by Paytrail's system using the items' + * unitPrice and vatPercentage (with amounts rounded to closest cent). Also, + * when true, items must be included. + */ + readonly usePricesWithoutVat ?: boolean; + +} + +export function createCreatePaymentRequestDTO ( + stamp : string, + reference : string, + amount : number, + currency : PaytrailCurrency, + language : PaytrailLanguage, + customer : PaytrailCustomer, + redirectUrls : PaytrailCallbackUrl, + callbackUrls ?: PaytrailCallbackUrl, + orderId ?: string, + items ?: readonly PaytrailItem[], + deliveryAddress ?: PaytrailAddress, + invoicingAddress ?: PaytrailAddress, + manualInvoiceActivation ?: boolean, + callbackDelay ?: number, + groups ?: readonly PaytrailPaymentMethodGroup[], + usePricesWithoutVat ?: boolean, +) : CreatePaymentRequestDTO { + return { + stamp, + reference, + amount, + currency, + language, + orderId, + items, + customer, + deliveryAddress, + invoicingAddress, + manualInvoiceActivation, + redirectUrls, + callbackUrls, + callbackDelay, + groups, + usePricesWithoutVat, + }; +} + +export function isCreatePaymentRequestDTO (value: unknown) : value is CreatePaymentRequestDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'stamp', + 'reference', + 'amount', + 'currency', + 'language', + 'orderId', + 'items', + 'customer', + 'deliveryAddress', + 'invoicingAddress', + 'manualInvoiceActivation', + 'redirectUrls', + 'callbackUrls', + 'callbackDelay', + 'groups', + 'usePricesWithoutVat', + ]) + && isString(value?.stamp) + && isString(value?.reference) + && isNumber(value?.amount) + && isPaytrailCurrency(value?.currency) + && isPaytrailLanguage(value?.language) + && isStringOrUndefined(value?.orderId) + && isArrayOfOrUndefined(value?.items, isPaytrailItem) + && isPaytrailCustomer(value?.customer) + && isPaytrailAddressOrUndefined(value?.deliveryAddress) + && isPaytrailAddressOrUndefined(value?.invoicingAddress) + && isBooleanOrUndefined(value?.manualInvoiceActivation) + && isPaytrailCallbackUrl(value?.redirectUrls) + && isPaytrailCallbackUrlOrUndefined(value?.callbackUrls) + && isNumberOrUndefined(value?.callbackDelay) + && isArrayOfOrUndefined(value?.groups, isPaytrailPaymentMethodGroup) + && isBooleanOrUndefined(value?.usePricesWithoutVat) + ); +} + +export function explainCreatePaymentRequestDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'stamp', + 'reference', + 'amount', + 'currency', + 'language', + 'orderId', + 'items', + 'customer', + 'deliveryAddress', + 'invoicingAddress', + 'manualInvoiceActivation', + 'redirectUrls', + 'callbackUrls', + 'callbackDelay', + 'groups', + 'usePricesWithoutVat', + ]) + , explainProperty("stamp", explainString(value?.stamp)) + , explainProperty("reference", explainString(value?.reference)) + , explainProperty("amount", explainNumber(value?.amount)) + , explainProperty("currency", explainPaytrailCurrency(value?.currency)) + , explainProperty("language", explainPaytrailLanguage(value?.language)) + , explainProperty("orderId", explainStringOrUndefined(value?.orderId)) + , explainProperty("items", explainArrayOfOrUndefined("PaytrailItem", explainPaytrailItem, value?.items, isPaytrailItem)) + , explainProperty("customer", explainPaytrailCustomer(value?.customer)) + , explainProperty("deliveryAddress", explainPaytrailAddressOrUndefined(value?.deliveryAddress)) + , explainProperty("invoicingAddress", explainPaytrailAddressOrUndefined(value?.invoicingAddress)) + , explainProperty("manualInvoiceActivation", explainBooleanOrUndefined(value?.manualInvoiceActivation)) + , explainProperty("redirectUrls", explainPaytrailCallbackUrl(value?.redirectUrls)) + , explainProperty("callbackUrls", explainPaytrailCallbackUrlOrUndefined(value?.callbackUrls)) + , explainProperty("callbackDelay", explainNumberOrUndefined(value?.callbackDelay)) + , explainProperty("groups", explainArrayOfOrUndefined("PaytrailPaymentMethodGroup", explainPaytrailPaymentMethodGroup, value?.groups, isPaytrailPaymentMethodGroup)) + , explainProperty("usePricesWithoutVat", explainBooleanOrUndefined(value?.usePricesWithoutVat)) + ] + ); +} + +export function parseCreatePaymentRequestDTO (value: unknown) : CreatePaymentRequestDTO | undefined { + if (isCreatePaymentRequestDTO(value)) return value; + return undefined; +} diff --git a/paytrail/dtos/PaytrailCreatePaymentDTO.test.ts b/paytrail/dtos/PaytrailCreatePaymentDTO.test.ts new file mode 100644 index 0000000..68f35f2 --- /dev/null +++ b/paytrail/dtos/PaytrailCreatePaymentDTO.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailCreatePaymentDTO, explainPaytrailCreatePaymentDTO, explainPaytrailCreatePaymentDTOOrUndefined, isPaytrailCreatePaymentDTO, isPaytrailCreatePaymentDTOOrUndefined, parsePaytrailCreatePaymentDTO } from "./PaytrailCreatePaymentDTO"; +import { PaytrailPaymentMethodGroup } from "../types/PaytrailPaymentMethodGroup"; +import { PaytrailProvider } from "../types/PaytrailProvider"; + +describe('PaytrailCreatePaymentDTO', () => { + + const mockedPaytrailProvider : PaytrailProvider = { + url: 'https://testurl.com', + icon: 'https://testiconurl.com', + svg: 'https://testsvgurl.com', + group: PaytrailPaymentMethodGroup.CREDIT, + name: 'testName', + id: 'testId', + parameters: [], + }; + + const mockedPaytrailPaymentMethodGroupData = { + id: PaytrailPaymentMethodGroup.CREDIT, + name: 'testName', + icon: 'https://testiconurl.com', + svg: 'https://testsvgurl.com', + }; + + const mockedReadonlyJsonObject = { + key: 'testKey', + value: 'testValue', + }; + + const mockedPaytrailCreatePaymentDTO = { + transactionId: 'testTransactionId', + href: 'https://testhref.com', + terms: 'testTerms', + groups: [mockedPaytrailPaymentMethodGroupData], + reference: 'testReference', + providers: [mockedPaytrailProvider], + customProviders: mockedReadonlyJsonObject, + }; + + describe('createPaytrailCreatePaymentDTO', () => { + it('creates PaytrailCreatePaymentDTO correctly', () => { + const result = createPaytrailCreatePaymentDTO( + 'testTransactionId', + 'https://testhref.com', + 'testTerms', + [{ + id: PaytrailPaymentMethodGroup.CREDIT, + name: 'testName', + icon: 'https://testiconurl.com', + svg: 'https://testsvgurl.com', + }], + 'testReference', + [mockedPaytrailProvider], + mockedReadonlyJsonObject + ); + expect(result).toEqual(mockedPaytrailCreatePaymentDTO); + }); + }); + + describe('isPaytrailCreatePaymentDTO', () => { + + it('validates if an object is PaytrailCreatePaymentDTO', () => { + const result = isPaytrailCreatePaymentDTO(mockedPaytrailCreatePaymentDTO); + expect(result).toBe(true); + }); + + it('returns false if the object is not PaytrailCreatePaymentDTO', () => { + const result = isPaytrailCreatePaymentDTO({ + transactionId: 'testTransactionId', + // Missing other properties. + }); + expect(result).toBe(false); + }); + + }); + + describe('explainPaytrailCreatePaymentDTO', () => { + it('explains why an object cannot be parsed into PaytrailCreatePaymentDTO', () => { + const result = explainPaytrailCreatePaymentDTO({ + transactionId: 'testTransactionId', + // Missing other properties. + }); + expect(result).toContain('property "href" not string'); + expect(result).toContain('property "terms" not string'); + expect(result).toContain('property "groups" not PaytrailPaymentMethodGroupData'); + expect(result).toContain('property "reference" not string'); + expect(result).toContain('property "providers" not PaytrailProvider'); + expect(result).not.toContain('property "customProviders" not ReadonlyJsonObject'); + }); + }); + + describe('parsePaytrailCreatePaymentDTO', () => { + it('parses object into PaytrailCreatePaymentDTO if it is valid', () => { + const result = parsePaytrailCreatePaymentDTO(mockedPaytrailCreatePaymentDTO); + expect(result).toEqual(mockedPaytrailCreatePaymentDTO); + }); + + it('returns undefined if the object cannot be parsed into PaytrailCreatePaymentDTO', () => { + const result = parsePaytrailCreatePaymentDTO({ + transactionId: 'testTransactionId', + // Missing other properties. + }); + expect(result).toBeUndefined(); + }); + }); + + describe('isPaytrailCreatePaymentDTOOrUndefined', () => { + it('validates if a value is PaytrailCreatePaymentDTO or undefined', () => { + expect(isPaytrailCreatePaymentDTOOrUndefined(mockedPaytrailCreatePaymentDTO)).toBe(true); + expect(isPaytrailCreatePaymentDTOOrUndefined(undefined)).toBe(true); + expect(isPaytrailCreatePaymentDTOOrUndefined({ transactionId: 'testTransactionId' })).toBe(false); // Missing other properties. + }); + }); + + describe('explainPaytrailCreatePaymentDTOOrUndefined', () => { + it('explains why a value is not PaytrailCreatePaymentDTO or undefined', () => { + const result = explainPaytrailCreatePaymentDTOOrUndefined({ transactionId: 'testTransactionId' }); // Missing other properties. + expect(result).toContain('not PaytrailCreatePaymentDTO or undefined'); + }); + }); + +}); diff --git a/paytrail/dtos/PaytrailCreatePaymentDTO.ts b/paytrail/dtos/PaytrailCreatePaymentDTO.ts new file mode 100644 index 0000000..902d6f9 --- /dev/null +++ b/paytrail/dtos/PaytrailCreatePaymentDTO.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, isString } from "../../types/String"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainReadonlyJsonObjectOrUndefined, isReadonlyJsonObjectOrUndefined, ReadonlyJsonObject } from "../../Json"; +import { explainPaytrailProvider, isPaytrailProvider, PaytrailProvider } from "../types/PaytrailProvider"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; +import { explainPaytrailPaymentMethodGroupData, isPaytrailPaymentMethodGroupData, PaytrailPaymentMethodGroupData } from "../types/PaytrailPaymentMethodGroupData"; + +/** + * The response JSON object contains the transaction ID of the payment and list + * of provider forms. + * + * It is highly recommended to render the icons and forms in + * the shop, but if this is not possible the response also contains a link to + * the hosted payment gateway. + * + * The response contains also HMAC verification + * headers and cof-request-id header. Storing or logging the request ID header + * is advised for possible debug needs. + */ +export interface PaytrailCreatePaymentDTO { + + /** + * Assigned transaction ID for the payment + */ + readonly transactionId: string; + + /** + * URL to hosted payment gateway. Redirect (HTTP GET) user here if the + * payment forms cannot be rendered directly inside the web shop. + */ + readonly href: string; + + /** + * Localized text with a link to the terms of payment + */ + readonly terms: string; + + /** + * Array of payment method group data with localized names and URLs to icons. + * Contains only the groups found in the providers of the response + */ + readonly groups: readonly PaytrailPaymentMethodGroupData[]; + + /** + * The bank reference used for the payments + */ + readonly reference: string; + + /** + * Array of providers. Render these elements as HTML forms + */ + readonly providers: readonly PaytrailProvider[]; + + /** + * Providers which require custom implementation. Currently used only by + * Apple Pay. + */ + readonly customProviders ?: ReadonlyJsonObject; + +} + +export function createPaytrailCreatePaymentDTO ( + transactionId: string, + href: string, + terms: string, + groups: readonly PaytrailPaymentMethodGroupData[], + reference: string, + providers: readonly PaytrailProvider[], + customProviders: ReadonlyJsonObject | undefined +) : PaytrailCreatePaymentDTO { + return { + transactionId, + href, + terms, + groups, + reference, + providers, + ...(customProviders ? {customProviders} : {}), + }; +} + +export function isPaytrailCreatePaymentDTO (value: unknown) : value is PaytrailCreatePaymentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'transactionId', + 'href', + 'terms', + 'groups', + 'reference', + 'providers', + 'customProviders', + ]) + && isString(value?.transactionId) + && isString(value?.href) + && isString(value?.terms) + && isArrayOf(value?.groups, isPaytrailPaymentMethodGroupData) + && isString(value?.reference) + && isArrayOf(value?.providers, isPaytrailProvider) + && isReadonlyJsonObjectOrUndefined(value?.customProviders) + ); +} + +export function explainPaytrailCreatePaymentDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'transactionId', + 'href', + 'terms', + 'groups', + 'reference', + 'providers', + 'customProviders', + ]) + , explainProperty("transactionId", explainString(value?.transactionId)) + , explainProperty("href", explainString(value?.href)) + , explainProperty("terms", explainString(value?.terms)) + , explainProperty("groups", explainArrayOf("PaytrailPaymentMethodGroupData", explainPaytrailPaymentMethodGroupData, value?.groups, isPaytrailPaymentMethodGroupData)) + , explainProperty("reference", explainString(value?.reference)) + , explainProperty("providers", explainArrayOf("PaytrailProvider", explainPaytrailProvider, value?.providers, isPaytrailProvider)) + , explainProperty("customProviders", explainReadonlyJsonObjectOrUndefined(value?.customProviders)) + ] + ); +} + +export function parsePaytrailCreatePaymentDTO (value: unknown) : PaytrailCreatePaymentDTO | undefined { + if (isPaytrailCreatePaymentDTO(value)) return value; + return undefined; +} + +export function isPaytrailCreatePaymentDTOOrUndefined (value: unknown): value is PaytrailCreatePaymentDTO | undefined { + return isUndefined(value) || isPaytrailCreatePaymentDTO(value); +} + +export function explainPaytrailCreatePaymentDTOOrUndefined (value: unknown): string { + return isPaytrailCreatePaymentDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailCreatePaymentDTO', 'undefined'])); +} + diff --git a/paytrail/dtos/PaytrailErrorDTO.test.ts b/paytrail/dtos/PaytrailErrorDTO.test.ts new file mode 100644 index 0000000..2a77526 --- /dev/null +++ b/paytrail/dtos/PaytrailErrorDTO.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailErrorDTO, explainPaytrailErrorDTO, explainPaytrailErrorDTOOrUndefined, isPaytrailErrorDTO, isPaytrailErrorDTOOrUndefined, parsePaytrailErrorDTO } from "./PaytrailErrorDTO"; + +describe('PaytrailErrorDTO', () => { + + const mockedErrorMessage = 'Test error message'; + + const mockedPaytrailErrorDTO = { + status: 'error', + message: mockedErrorMessage, + }; + + describe('createPaytrailErrorDTO', () => { + it('creates PaytrailErrorDTO correctly', () => { + const result = createPaytrailErrorDTO(mockedErrorMessage); + expect(result).toEqual(mockedPaytrailErrorDTO); + }); + }); + + describe('isPaytrailErrorDTO', () => { + it('validates if an object is PaytrailErrorDTO', () => { + const result = isPaytrailErrorDTO(mockedPaytrailErrorDTO); + expect(result).toBe(true); + }); + + it('returns false if the object is not PaytrailErrorDTO', () => { + const result = isPaytrailErrorDTO({ status: 'error' }); // Missing message property + expect(result).toBe(false); + }); + }); + + describe('explainPaytrailErrorDTO', () => { + it('explains why an object cannot be parsed into PaytrailErrorDTO', () => { + const result = explainPaytrailErrorDTO({ status: 'error' }); // Missing message property + expect(result).toContain('property "message" not string'); + }); + }); + + describe('parsePaytrailErrorDTO', () => { + it('parses object into PaytrailErrorDTO if it is valid', () => { + const result = parsePaytrailErrorDTO(mockedPaytrailErrorDTO); + expect(result).toEqual(mockedPaytrailErrorDTO); + }); + + it('returns undefined if the object cannot be parsed into PaytrailErrorDTO', () => { + const result = parsePaytrailErrorDTO({ status: 'error' }); // Missing message property + expect(result).toBeUndefined(); + }); + }); + + describe('isPaytrailErrorDTOOrUndefined', () => { + + it('validates if a value is PaytrailErrorDTO or undefined', () => { + expect(isPaytrailErrorDTOOrUndefined(mockedPaytrailErrorDTO)).toBe(true); + expect(isPaytrailErrorDTOOrUndefined(undefined)).toBe(true); + expect(isPaytrailErrorDTOOrUndefined({ status: 'error' })).toBe(false); // Missing message property + }); + + }); + + describe('explainPaytrailErrorDTOOrUndefined', () => { + it('explains why a value is not PaytrailErrorDTO or undefined', () => { + const result = explainPaytrailErrorDTOOrUndefined({ status: 'error' }); // Missing message property + expect(result).toContain('not PaytrailErrorDTO or undefined'); + }); + }); + +}); diff --git a/paytrail/dtos/PaytrailErrorDTO.ts b/paytrail/dtos/PaytrailErrorDTO.ts new file mode 100644 index 0000000..787b98b --- /dev/null +++ b/paytrail/dtos/PaytrailErrorDTO.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, isString } from "../../types/String"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; + +/** + * @see https://docs.paytrail.com/#/?id=response-6 + * @see https://docs.paytrail.com/#/?id=response-10 + */ +export interface PaytrailErrorDTO { + + /** + * Always "error" + */ + readonly status: 'error'; + + /** + * The error message + */ + readonly message: string; + +} + +export function createPaytrailErrorDTO ( + message : string +) : PaytrailErrorDTO { + return { + status: 'error', + message + }; +} + +export function isPaytrailErrorDTO (value: unknown) : value is PaytrailErrorDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'status', + 'message', + ]) + && value?.status === 'error' + && isString(value?.message) + ); +} + +export function explainPaytrailErrorDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'status', + 'message', + ]) + , explainProperty("status", value?.status === 'error' ? explainOk() : `Property "status" is not 'error'`) + , explainProperty("message", explainString(value?.message)) + ] + ); +} + +export function parsePaytrailErrorDTO (value: unknown) : PaytrailErrorDTO | undefined { + if (isPaytrailErrorDTO(value)) return value; + return undefined; +} + +export function isPaytrailErrorDTOOrUndefined (value: unknown): value is PaytrailErrorDTO | undefined { + return isUndefined(value) || isPaytrailErrorDTO(value); +} + +export function explainPaytrailErrorDTOOrUndefined (value: unknown): string { + return isPaytrailErrorDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailErrorDTO', 'undefined'])); +} diff --git a/paytrail/dtos/PaytrailPaymentDTO.test.ts b/paytrail/dtos/PaytrailPaymentDTO.test.ts new file mode 100644 index 0000000..50860a5 --- /dev/null +++ b/paytrail/dtos/PaytrailPaymentDTO.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailPaymentDTO, explainPaytrailPaymentDTO, isPaytrailPaymentDTO, parsePaytrailPaymentDTO, PaytrailPaymentDTO } from "./PaytrailPaymentDTO"; +import { PaytrailCurrency } from "../types/PaytrailCurrency"; +import { PaytrailStatus } from "../types/PaytrailStatus"; + +describe('PaytrailPaymentDTO', () => { + + const mockedPaymentDTO : PaytrailPaymentDTO = { + transactionId: '681538c4-fc84-11e9-83bc-2ffcef4c3453', + status: PaytrailStatus.OK, + amount: 1689, + currency: PaytrailCurrency.EUR, + stamp: '15725981193483', + reference: '4940046476', + createdAt: '2019-11-01T10:48:39.979Z', + href: 'https://pay.paytrail.com/pay/681538c4-fc84-11e9-83bc-2ffcef4c3453', + provider: undefined, + fillingCode: undefined, + paidAt: undefined, + settlementReference: undefined, + }; + + describe('createPaytrailPaymentDTO', () => { + it('creates PaytrailPaymentDTO correctly', () => { + const result = createPaytrailPaymentDTO( + mockedPaymentDTO.transactionId, + mockedPaymentDTO.status, + mockedPaymentDTO.amount, + mockedPaymentDTO.currency, + mockedPaymentDTO.stamp, + mockedPaymentDTO.reference, + mockedPaymentDTO.createdAt, + mockedPaymentDTO.href + ); + expect(result).toEqual(mockedPaymentDTO); + }); + }); + + describe('isPaytrailPaymentDTO', () => { + + it('validates if an object is PaytrailPaymentDTO', () => { + const result = isPaytrailPaymentDTO(mockedPaymentDTO); + expect(result).toBe(true); + }); + + it('returns false if the object is not PaytrailPaymentDTO', () => { + const result = isPaytrailPaymentDTO({ ...mockedPaymentDTO, amount: 'invalid' }); // invalid amount property + expect(result).toBe(false); + }); + + }); + + describe('explainPaytrailPaymentDTO', () => { + it('explains why an object cannot be parsed into PaytrailPaymentDTO', () => { + const result = explainPaytrailPaymentDTO({ ...mockedPaymentDTO, amount: 'invalid' }); // invalid amount property + expect(result).toContain('property "amount" not number'); + }); + }); + + describe('parsePaytrailPaymentDTO', () => { + + it('parses object into PaytrailPaymentDTO if it is valid', () => { + const result = parsePaytrailPaymentDTO(mockedPaymentDTO); + expect(result).toEqual(mockedPaymentDTO); + }); + + it('returns undefined if the object cannot be parsed into PaytrailPaymentDTO', () => { + const result = parsePaytrailPaymentDTO({ ...mockedPaymentDTO, amount: 'invalid' }); // invalid amount property + expect(result).toBeUndefined(); + }); + + }); + +}); diff --git a/paytrail/dtos/PaytrailPaymentDTO.ts b/paytrail/dtos/PaytrailPaymentDTO.ts new file mode 100644 index 0000000..9c6a15b --- /dev/null +++ b/paytrail/dtos/PaytrailPaymentDTO.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isPaytrailStatus, PaytrailStatus } from "../types/PaytrailStatus"; +import { explainPaytrailCurrency, isPaytrailCurrency, PaytrailCurrency } from "../types/PaytrailCurrency"; +import { explainNumber, isNumber } from "../../types/Number"; +import { isUndefined } from "../../types/undefined"; + +/** + * HTTP GET /payments/{transactionId} returns payment information. + * + * Get transaction info. Payments are reported primarily via callbacks, and + * implementations should mainly rely on receiving the info via them. All + * received payments will be eventually reported. + * + * Note! The transaction id needs to be sent on checkout-transaction-id header + * as well. + * + * @example + * { + * "transactionId": "681538c4-fc84-11e9-83bc-2ffcef4c3453", + * "status": "new", + * "amount": 1689, + * "currency": "EUR", + * "reference": "4940046476", + * "stamp": "15725981193483", + * "createdAt": "2019-11-01T10:48:39.979Z", + * "href": "https://pay.paytrail.com/pay/681538c4-fc84-11e9-83bc-2ffcef4c3453" + * } + * + * @see https://docs.paytrail.com/#/?id=get + */ +export interface PaytrailPaymentDTO { + + /** + * Assigned transaction ID for the payment + * + * Example: `"681538c4-fc84-11e9-83bc-2ffcef4c3453"` + */ + readonly transactionId: string; + + /** + * new, ok, fail, pending, or delayed. + * + * Example: `"new"` + */ + readonly status: PaytrailStatus; + + /** + * Total amount of the payment in currency's minor units, e.g. for Euros use + * cents + * + * Example: `1689` + */ + readonly amount: number; + + /** + * Currency + * + * Example: `"EUR"` + */ + readonly currency: PaytrailCurrency; + + /** + * Merchant unique identifier for the order + * + * Example: `"15725981193483"` + */ + readonly stamp : string; + + /** + * Order reference + * + * Example: `"4940046476"` + */ + readonly reference : string; + + /** + * Transaction creation timestamp + * + * Example: `"2019-11-01T10:48:39.979Z"` + */ + readonly createdAt : string; + + /** + * If transaction is in status new, link to the hosted payment gateway + * + * Example: `"https://pay.paytrail.com/pay/681538c4-fc84-11e9-83bc-2ffcef4c3453"` + */ + readonly href ?: string; + + /** + * If processed, the name of the payment method provider + */ + readonly provider ?: string; + + /** + * If paid, the filing code issued by the payment method provider if any. + * Some providers do not return the filing code. + */ + readonly fillingCode ?: string; + + /** + * Timestamp when the transaction was paid + */ + readonly paidAt ?: string; + + /** + * If payment is settled, corresponding settlement reference is included + */ + readonly settlementReference ?: string; + +} + +export function createPaytrailPaymentDTO ( + transactionId : string, + status: PaytrailStatus, + amount: number, + currency: PaytrailCurrency, + stamp : string, + reference : string, + createdAt : string, + href ?: string, + provider ?: string, + fillingCode ?: string, + paidAt ?: string, + settlementReference ?: string, +) : PaytrailPaymentDTO { + return { + transactionId, + status, + amount, + currency, + stamp, + reference, + createdAt, + href, + provider, + fillingCode, + paidAt, + settlementReference, + }; +} + +export function isPaytrailPaymentDTO (value: unknown) : value is PaytrailPaymentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'transactionId', + 'status', + 'amount', + 'currency', + 'stamp', + 'reference', + 'createdAt', + 'href', + 'provider', + 'fillingCode', + 'paidAt', + 'settlementReference', + ]) + && isString(value?.transactionId) + && isPaytrailStatus(value?.status) + && isNumber(value?.amount) + && isPaytrailCurrency(value?.currency) + && isString(value?.stamp) + && isString(value?.reference) + && isString(value?.createdAt) + && isStringOrUndefined(value?.href) + && isStringOrUndefined(value?.provider) + && isStringOrUndefined(value?.fillingCode) + && isStringOrUndefined(value?.paidAt) + && isStringOrUndefined(value?.settlementReference) + ); +} + +export function isPaytrailPaymentDTOOrUndefined (value: unknown): value is PaytrailPaymentDTO | undefined { + return isUndefined(value) || isPaytrailPaymentDTO(value); +} + +export function explainPaytrailPaymentDTOOrUndefined (value: unknown): string { + return isPaytrailPaymentDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailPaymentDTO', 'undefined'])); +} + +export function explainPaytrailPaymentDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'transactionId', + 'status', + 'amount', + 'currency', + 'stamp', + 'reference', + 'createdAt', + 'href', + 'provider', + 'fillingCode', + 'paidAt', + 'settlementReference', + ]) + , explainProperty("transactionId", explainString(value?.transactionId)) + , explainProperty("status", explainString(value?.status)) + , explainProperty("amount", explainNumber(value?.amount)) + , explainProperty("currency", explainPaytrailCurrency(value?.currency)) + , explainProperty("stamp", explainString(value?.stamp)) + , explainProperty("reference", explainString(value?.reference)) + , explainProperty("createdAt", explainString(value?.createdAt)) + , explainProperty("href", explainStringOrUndefined(value?.href)) + , explainProperty("provider", explainStringOrUndefined(value?.provider)) + , explainProperty("fillingCode", explainStringOrUndefined(value?.fillingCode)) + , explainProperty("paidAt", explainStringOrUndefined(value?.paidAt)) + , explainProperty("settlementReference", explainStringOrUndefined(value?.settlementReference)) + ] + ); +} + +export function parsePaytrailPaymentDTO (value: unknown) : PaytrailPaymentDTO | undefined { + if (isPaytrailPaymentDTO(value)) return value; + return undefined; +} diff --git a/paytrail/dtos/PaytrailPaymentProviderListDTO.test.ts b/paytrail/dtos/PaytrailPaymentProviderListDTO.test.ts new file mode 100644 index 0000000..2b72d55 --- /dev/null +++ b/paytrail/dtos/PaytrailPaymentProviderListDTO.test.ts @@ -0,0 +1,332 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailPaymentProviderListDTO, explainPaytrailPaymentProviderListDTO, isPaytrailPaymentProviderListDTO, parsePaytrailPaymentProviderListDTO, PaytrailPaymentProviderListDTO } from "./PaytrailPaymentProviderListDTO"; +import { PaytrailPaymentMethodGroup } from "../types/PaytrailPaymentMethodGroup"; + +describe('PaytrailPaymentProviderListDTO', () => { + + const mockedPaymentDTO : PaytrailPaymentProviderListDTO = { + "terms": "Valitsemalla maksutavan hyväksyt maksupalveluehdot", + "providers": [ + { + "id": "pivo", + "name": "Pivo", + "icon": "https://resources.paytrail.com/images/payment-method-logos/pivo.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/pivo.svg", + "group": PaytrailPaymentMethodGroup.MOBILE + }, + { + "id": "osuuspankki", + "name": "OP", + "icon": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "nordea", + "name": "Nordea", + "icon": "https://resources.paytrail.com/images/payment-method-logos/nordea.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/nordea.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "handelsbanken", + "name": "Handelsbanken", + "icon": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "pop", + "name": "POP Pankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/pop.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/pop.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "aktia", + "name": "Aktia", + "icon": "https://resources.paytrail.com/images/payment-method-logos/aktia.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/aktia.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "saastopankki", + "name": "Säästöpankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "omasp", + "name": "Oma Säästöpankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/omasp.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/omasp.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "spankki", + "name": "S-pankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/spankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/spankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "alandsbanken", + "name": "Ålandsbanken", + "icon": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "danske", + "name": "Danske Bank", + "icon": "https://resources.paytrail.com/images/payment-method-logos/danske.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/danske.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "creditcard", + "name": "Visa", + "icon": "https://resources.paytrail.com/images/payment-method-logos/visa.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/visa.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "creditcard", + "name": "Visa Electron", + "icon": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "creditcard", + "name": "Mastercard", + "icon": "https://resources.paytrail.com/images/payment-method-logos/mastercard.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/mastercard.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "amex", + "name": "American Express", + "icon": "https://resources.paytrail.com/images/payment-method-logos/amex.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/amex.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "collectorb2c", + "name": "Collector", + "icon": "https://resources.paytrail.com/images/payment-method-logos/walley.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/walley.svg", + "group": PaytrailPaymentMethodGroup.CREDIT + }, + { + "id": "collectorb2b", + "name": "Collector B2B", + "icon": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.svg", + "group": PaytrailPaymentMethodGroup.CREDIT + } + ], + "groups": [ + { + "id": PaytrailPaymentMethodGroup.MOBILE, + "name": "Mobiilimaksutavat", + "icon": "https://resources.paytrail.com/images/payment-group-icons/mobile.png", + "svg": "https://resources.paytrail.com/images/payment-group-icons/mobile.svg", + "providers": [ + { + "id": "pivo", + "name": "Pivo", + "icon": "https://resources.paytrail.com/images/payment-method-logos/pivo.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/pivo.svg", + "group": PaytrailPaymentMethodGroup.MOBILE + } + ] + }, + { + "id": PaytrailPaymentMethodGroup.BANK, + "name": "Pankkimaksutavat", + "icon": "https://resources.paytrail.com/images/payment-group-icons/bank.png", + "svg": "https://resources.paytrail.com/images/payment-group-icons/bank.svg", + "providers": [ + { + "id": "osuuspankki", + "name": "OP", + "icon": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "nordea", + "name": "Nordea", + "icon": "https://resources.paytrail.com/images/payment-method-logos/nordea.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/nordea.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "handelsbanken", + "name": "Handelsbanken", + "icon": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "pop", + "name": "POP Pankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/pop.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/pop.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "aktia", + "name": "Aktia", + "icon": "https://resources.paytrail.com/images/payment-method-logos/aktia.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/aktia.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "saastopankki", + "name": "Säästöpankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "omasp", + "name": "Oma Säästöpankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/omasp.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/omasp.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "spankki", + "name": "S-pankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/spankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/spankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "alandsbanken", + "name": "Ålandsbanken", + "icon": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "danske", + "name": "Danske Bank", + "icon": "https://resources.paytrail.com/images/payment-method-logos/danske.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/danske.svg", + "group": PaytrailPaymentMethodGroup.BANK + } + ] + }, + { + "id": PaytrailPaymentMethodGroup.CREDIT_CARD, + "name": "Korttimaksutavat", + "icon": "https://resources.paytrail.com/images/payment-group-icons/creditcard.png", + "svg": "https://resources.paytrail.com/images/payment-group-icons/creditcard.svg", + "providers": [ + { + "id": "creditcard", + "name": "Visa", + "icon": "https://resources.paytrail.com/images/payment-method-logos/visa.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/visa.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "creditcard", + "name": "Visa Electron", + "icon": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "creditcard", + "name": "Mastercard", + "icon": "https://resources.paytrail.com/images/payment-method-logos/mastercard.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/mastercard.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "amex", + "name": "American Express", + "icon": "https://resources.paytrail.com/images/payment-method-logos/amex.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/amex.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + } + ] + }, + { + "id": PaytrailPaymentMethodGroup.CREDIT, + "name": "Lasku- ja osamaksutavat", + "icon": "https://resources.paytrail.com/images/payment-group-icons/credit.png", + "svg": "https://resources.paytrail.com/images/payment-group-icons/credit.svg", + "providers": [ + { + "id": "collectorb2c", + "name": "Collector", + "icon": "https://resources.paytrail.com/images/payment-method-logos/walley.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/walley.svg", + "group": PaytrailPaymentMethodGroup.CREDIT + }, + { + "id": "collectorb2b", + "name": "Collector B2B", + "icon": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.svg", + "group": PaytrailPaymentMethodGroup.CREDIT + } + ] + } + ] + }; + + describe('createPaytrailPaymentProviderListDTO', () => { + it('creates PaytrailPaymentProviderListDTO correctly', () => { + const result = createPaytrailPaymentProviderListDTO( + mockedPaymentDTO.terms, + mockedPaymentDTO.groups, + mockedPaymentDTO.providers, + ); + expect(result).toEqual(mockedPaymentDTO); + }); + }); + + describe('isPaytrailPaymentProviderListDTO', () => { + + it('validates if an object is PaytrailPaymentProviderListDTO', () => { + const result = isPaytrailPaymentProviderListDTO(mockedPaymentDTO); + expect(result).toBe(true); + }); + + it('returns false if the object is not PaytrailPaymentProviderListDTO', () => { + const result = isPaytrailPaymentProviderListDTO({ ...mockedPaymentDTO, amount: 'invalid' }); // invalid amount property + expect(result).toBe(false); + }); + + }); + + describe('explainPaytrailPaymentProviderListDTO', () => { + it('explains why an object cannot be parsed into PaytrailPaymentProviderListDTO', () => { + const result = explainPaytrailPaymentProviderListDTO({ ...mockedPaymentDTO, amount: 'invalid' }); // invalid amount property + expect(result).toContain('Value had extra properties: amount'); + }); + }); + + describe('parsePaytrailPaymentProviderListDTO', () => { + + it('parses object into PaytrailPaymentProviderListDTO if it is valid', () => { + const result = parsePaytrailPaymentProviderListDTO(mockedPaymentDTO); + expect(result).toEqual(mockedPaymentDTO); + }); + + it('returns undefined if the object cannot be parsed into PaytrailPaymentProviderListDTO', () => { + const result = parsePaytrailPaymentProviderListDTO({ ...mockedPaymentDTO, amount: 'invalid' }); // invalid amount property + expect(result).toBeUndefined(); + }); + + }); + +}); diff --git a/paytrail/dtos/PaytrailPaymentProviderListDTO.ts b/paytrail/dtos/PaytrailPaymentProviderListDTO.ts new file mode 100644 index 0000000..e7612c6 --- /dev/null +++ b/paytrail/dtos/PaytrailPaymentProviderListDTO.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, isString } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainPaytrailPaymentMethodGroupData, isPaytrailPaymentMethodGroupData, PaytrailPaymentMethodGroupData } from "../types/PaytrailPaymentMethodGroupData"; +import { explainPaytrailProvider, isPaytrailProvider, PaytrailProvider } from "../types/PaytrailProvider"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; + +/** + * Response DTO for HTTP GET /merchants/payment-providers returns a list of + * available providers for the merchant. This endpoint can be used for example + * to show available payment methods in checkout without initializing a new + * payment before the user actually proceeds to pay their order. + * + * @see https://docs.paytrail.com/#/?id=list-providers + */ +export interface PaytrailPaymentProviderListDTO { + + /** + * Localized text with a link to the terms of payment + */ + readonly terms: string; + + /** + * Array of payment method group data with localized names and URLs to icons + * and providers. Contains only the groups the merchant has providers in. + * Can be limited by the request query parameters + */ + readonly groups: readonly PaytrailPaymentMethodGroupData[]; + + /** + * A flat list of all the providers the merchant has. Can be limited by + * query parameters. + */ + readonly providers : readonly PaytrailProvider[]; + +} + +export function createPaytrailPaymentProviderListDTO ( + terms : string, + groups: readonly PaytrailPaymentMethodGroupData[], + providers : readonly PaytrailProvider[] +) : PaytrailPaymentProviderListDTO { + return { + terms, + groups, + providers + }; +} + +export function isPaytrailPaymentProviderListDTO (value: unknown) : value is PaytrailPaymentProviderListDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'terms', + 'groups', + 'providers' + ]) + && isString(value?.terms) + && isArrayOf(value?.groups, isPaytrailPaymentMethodGroupData) + && isArrayOf(value?.providers, isPaytrailProvider) + ); +} + +export function explainPaytrailPaymentProviderListDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'terms', + 'groups', + 'providers' + ]) + , explainProperty("terms", explainString(value?.terms)) + , explainProperty("groups", explainArrayOf("PaytrailPaymentMethodGroupData", explainPaytrailPaymentMethodGroupData, value?.groups, isPaytrailPaymentMethodGroupData)) + , explainProperty("providers", explainArrayOf("PaytrailProvider", explainPaytrailProvider, value?.providers, isPaytrailProvider)) + ] + ); +} + +export function parsePaytrailPaymentProviderListDTO (value: unknown) : PaytrailPaymentProviderListDTO | undefined { + if (isPaytrailPaymentProviderListDTO(value)) return value; + return undefined; +} + +export function isPaytrailPaymentProviderListDTOOrUndefined (value: unknown): value is PaytrailPaymentProviderListDTO | undefined { + return isUndefined(value) || isPaytrailPaymentProviderListDTO(value); +} + +export function explainPaytrailPaymentProviderListDTOOrUndefined (value: unknown): string { + return isPaytrailPaymentProviderListDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailPaymentProviderListDTO', 'undefined'])); +} diff --git a/paytrail/paytrail-constants.ts b/paytrail/paytrail-constants.ts new file mode 100644 index 0000000..db21ac3 --- /dev/null +++ b/paytrail/paytrail-constants.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export const DEFAULT_PAYTRAIL_API_URL = 'https://services.paytrail.com'; diff --git a/paytrail/types/PaytrailAddress.test.ts b/paytrail/types/PaytrailAddress.test.ts new file mode 100644 index 0000000..56596c6 --- /dev/null +++ b/paytrail/types/PaytrailAddress.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailAddress, explainPaytrailAddress, isPaytrailAddress, isPaytrailAddressOrUndefined, parsePaytrailAddress, PaytrailAddress } from "./PaytrailAddress"; + +describe('PaytrailAddress', () => { + + const streetAddress = 'Fake Street 123'; + const postalCode = '00100'; + const city = 'Luleå'; + const county = 'Norbotten'; + const country = 'SE'; + + const validAddress = { + streetAddress, + postalCode, + city, + country, + county + }; + + describe('createPaytrailAddress', () => { + + it('should create a PaytrailAddress object when all inputs are valid', () => { + const result: PaytrailAddress = createPaytrailAddress(streetAddress, postalCode, city, country, county); + expect(result.streetAddress).toEqual(streetAddress); + expect(result.postalCode).toEqual(postalCode); + expect(result.city).toEqual(city); + expect(result.country).toEqual(country); + expect(result.county).toEqual(county); + }); + + it('should create a PaytrailAddress object with no county when county is undefined', () => { + const result: PaytrailAddress = createPaytrailAddress(streetAddress, postalCode, city, country); + expect(result.streetAddress).toEqual(streetAddress); + expect(result.postalCode).toEqual(postalCode); + expect(result.city).toEqual(city); + expect(result.country).toEqual(country); + expect(result.county).toBeUndefined(); + }); + + }); + + describe('isPaytrailAddress', () => { + + it('should return true for valid PaytrailAddress objects', () => { + expect(isPaytrailAddress(validAddress)).toBe(true); + }); + + it('should return false for invalid objects', () => { + expect(isPaytrailAddress({ ...validAddress, unknownProperty: 'Test' })).toBe(false); + expect(isPaytrailAddress({})).toBe(false); + expect(isPaytrailAddress('string')).toBe(false); + expect(isPaytrailAddress(null)).toBe(false); + }); + + }); + + describe('parsePaytrailAddress', () => { + + it('should return PaytrailAddress object if input is valid', () => { + expect(parsePaytrailAddress(validAddress)).toEqual(validAddress); + }); + + it('should return undefined if input is invalid', () => { + expect(parsePaytrailAddress({ ...validAddress, unknownProperty: 'Test' })).toBeUndefined(); + expect(parsePaytrailAddress({})).toBeUndefined(); + expect(parsePaytrailAddress('string')).toBeUndefined(); + expect(parsePaytrailAddress(null)).toBeUndefined(); + }); + + }); + + describe('isPaytrailAddressOrUndefined', () => { + + it('should return true for valid PaytrailAddress objects or undefined', () => { + expect(isPaytrailAddressOrUndefined(validAddress)).toBe(true); + expect(isPaytrailAddressOrUndefined(undefined)).toBe(true); + }); + + it('should return false for invalid objects', () => { + expect(isPaytrailAddressOrUndefined({ ...validAddress, unknownProperty: 'Test' })).toBe(false); + expect(isPaytrailAddressOrUndefined({})).toBe(false); + expect(isPaytrailAddressOrUndefined('string')).toBe(false); + expect(isPaytrailAddressOrUndefined(null)).toBe(false); + }); + + }); + + describe('explainPaytrailAddress', () => { + + it('should return explanation OK for valid PaytrailAddress objects', () => { + expect(explainPaytrailAddress(validAddress)).toEqual('OK'); + }); + + it('should return an explanation for invalid objects', () => { + expect(explainPaytrailAddress({})).toContain('property "streetAddress" not string'); + expect(explainPaytrailAddress({ ...validAddress, unknownProperty: 'Test' })).toContain('unknownProperty'); + expect(explainPaytrailAddress({ streetAddress: 123, city: 'Sample City', postalCode: '12345', country: 'Sample Country', county: 'Sample County' })).toContain('property "streetAddress" not string'); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailAddress.ts b/paytrail/types/PaytrailAddress.ts new file mode 100644 index 0000000..ba85ae8 --- /dev/null +++ b/paytrail/types/PaytrailAddress.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { isUndefined } from "../../types/undefined"; + +export interface PaytrailAddress { + /** + * Street address. Maximum of 50 characters. + */ + readonly streetAddress: string; + + /** + * Postal code. Maximum of 15 characters. + */ + readonly postalCode: string; + + /** + * City. maximum of 30 characters. + */ + readonly city: string; + + /** + * County/State + */ + readonly county ?: string; + + /** + * Alpha-2 country code + */ + readonly country : string; + +} + +export function createPaytrailAddress ( + streetAddress : string, + postalCode : string, + city : string, + country : string, + county ?: string | undefined, +) : PaytrailAddress { + return { + streetAddress, + postalCode, + city, + county, + country, + }; +} + +export function isPaytrailAddress (value: unknown) : value is PaytrailAddress { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'streetAddress', + 'postalCode', + 'city', + 'county', + 'country', + ]) + && isString(value?.streetAddress) + && isString(value?.postalCode) + && isString(value?.city) + && isStringOrUndefined(value?.county) + && isString(value?.country) + ); +} + +export function explainPaytrailAddress (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'streetAddress', + 'postalCode', + 'city', + 'county', + 'country', + ]) + , explainProperty("streetAddress", explainString(value?.streetAddress)) + , explainProperty("postalCode", explainString(value?.postalCode)) + , explainProperty("city", explainString(value?.city)) + , explainProperty("county", explainStringOrUndefined(value?.county)) + , explainProperty("country", explainString(value?.country)) + ] + ); +} + +export function parsePaytrailAddress (value: unknown) : PaytrailAddress | undefined { + if (isPaytrailAddress(value)) return value; + return undefined; +} + +export function isPaytrailAddressOrUndefined (value: unknown): value is PaytrailAddress | undefined { + return isUndefined(value) || isPaytrailAddress(value); +} + +export function explainPaytrailAddressOrUndefined (value: unknown): string { + return isPaytrailAddressOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailAddress', 'undefined'])); +} diff --git a/paytrail/types/PaytrailCallbackUrl.test.ts b/paytrail/types/PaytrailCallbackUrl.test.ts new file mode 100644 index 0000000..ab3b887 --- /dev/null +++ b/paytrail/types/PaytrailCallbackUrl.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailCallbackUrl, explainPaytrailCallbackUrl, isPaytrailCallbackUrl, parsePaytrailCallbackUrl } from "./PaytrailCallbackUrl"; + +describe('PaytrailCallbackUrl', () => { + + const validCallbackUrl = { + success: 'https://example.org/51/success', + cancel: 'https://example.org/51/cancel' + }; + + describe('createPaytrailCallbackUrl', () => { + + it('should create a valid PaytrailCallbackUrl object', () => { + const result = createPaytrailCallbackUrl( + validCallbackUrl.success, + validCallbackUrl.cancel + ); + expect(result).toEqual(validCallbackUrl); + }); + + }); + + describe('isPaytrailCallbackUrl', () => { + + it('should return true for valid PaytrailCallbackUrl objects', () => { + expect(isPaytrailCallbackUrl(validCallbackUrl)).toBe(true); + }); + + it('should return false for invalid objects', () => { + expect(isPaytrailCallbackUrl({ ...validCallbackUrl, unknownProperty: 'Test' })).toBe(false); + expect(isPaytrailCallbackUrl({})).toBe(false); + expect(isPaytrailCallbackUrl('string')).toBe(false); + expect(isPaytrailCallbackUrl(null)).toBe(false); + }); + + }); + + describe('parsePaytrailCallbackUrl', () => { + + it('should return PaytrailCallbackUrl object if input is valid', () => { + expect(parsePaytrailCallbackUrl(validCallbackUrl)).toEqual(validCallbackUrl); + }); + + it('should return undefined if input is invalid', () => { + expect(parsePaytrailCallbackUrl({ ...validCallbackUrl, unknownProperty: 'Test' })).toBeUndefined(); + expect(parsePaytrailCallbackUrl({})).toBeUndefined(); + expect(parsePaytrailCallbackUrl('string')).toBeUndefined(); + expect(parsePaytrailCallbackUrl(null)).toBeUndefined(); + }); + + }); + + describe('explainPaytrailCallbackUrl', () => { + + it('should return explanation OK for valid PaytrailCallbackUrl objects', () => { + expect(explainPaytrailCallbackUrl(validCallbackUrl)).toEqual('OK'); + }); + + it('should return an explanation for invalid objects', () => { + expect(explainPaytrailCallbackUrl({})).toContain('property "success" not string'); + expect(explainPaytrailCallbackUrl({ ...validCallbackUrl, unknownProperty: 'Test' })).toContain('unknownProperty'); + expect(explainPaytrailCallbackUrl({ success: 123, cancel: 'https://example.com/cancel' })).toContain('property "success" not string'); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailCallbackUrl.ts b/paytrail/types/PaytrailCallbackUrl.ts new file mode 100644 index 0000000..b4911c0 --- /dev/null +++ b/paytrail/types/PaytrailCallbackUrl.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, isString } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +/** + * @see https://docs.paytrail.com/#/?id=callbackurl + */ +export interface PaytrailCallbackUrl { + readonly success: string; + readonly cancel: string; +} + +export function createPaytrailCallbackUrl ( + success : string, + cancel : string, +) : PaytrailCallbackUrl { + return { + success, + cancel + }; +} + +export function isPaytrailCallbackUrl (value: unknown) : value is PaytrailCallbackUrl { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'success', + 'cancel', + ]) + && isString(value?.success) + && isString(value?.cancel) + ); +} + +export function explainPaytrailCallbackUrl (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'success', + 'cancel', + ]) + , explainProperty("success", explainString(value?.success)) + , explainProperty("cancel", explainString(value?.cancel)) + ] + ); +} + +export function parsePaytrailCallbackUrl (value: unknown) : PaytrailCallbackUrl | undefined { + if (isPaytrailCallbackUrl(value)) return value; + return undefined; +} + +export function isPaytrailCallbackUrlOrUndefined (value: unknown): value is PaytrailCallbackUrl | undefined { + return isUndefined(value) || isPaytrailCallbackUrl(value); +} + +export function explainPaytrailCallbackUrlOrUndefined (value: unknown): string { + return isPaytrailCallbackUrlOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailCallbackUrl', 'undefined'])); +} diff --git a/paytrail/types/PaytrailComission.test.ts b/paytrail/types/PaytrailComission.test.ts new file mode 100644 index 0000000..1b345d6 --- /dev/null +++ b/paytrail/types/PaytrailComission.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailComission, explainPaytrailComission, isPaytrailComission, parsePaytrailComission } from "./PaytrailComission"; + +describe('PaytrailComission', () => { + + const validComission = { + merchant: '695874', + amount: 250 + }; + + describe('createPaytrailComission', () => { + + it('should create a valid PaytrailComission object', () => { + const result = createPaytrailComission(validComission.merchant, validComission.amount); + expect(result).toEqual(validComission); + }); + + }); + + describe('isPaytrailComission', () => { + + it('should return true for valid PaytrailComission objects', () => { + expect(isPaytrailComission(validComission)).toBe(true); + }); + + it('should return false for invalid objects', () => { + expect(isPaytrailComission({ ...validComission, unknownProperty: 'Test' })).toBe(false); + expect(isPaytrailComission({})).toBe(false); + expect(isPaytrailComission('string')).toBe(false); + expect(isPaytrailComission(null)).toBe(false); + }); + + }); + + describe('parsePaytrailComission', () => { + + it('should return PaytrailComission object if input is valid', () => { + expect(parsePaytrailComission(validComission)).toEqual(validComission); + }); + + it('should return undefined if input is invalid', () => { + expect(parsePaytrailComission({ ...validComission, unknownProperty: 'Test' })).toBeUndefined(); + expect(parsePaytrailComission({})).toBeUndefined(); + expect(parsePaytrailComission('string')).toBeUndefined(); + expect(parsePaytrailComission(null)).toBeUndefined(); + }); + + }); + + describe('explainPaytrailComission', () => { + + it('should return explanation OK for valid PaytrailComission objects', () => { + expect(explainPaytrailComission(validComission)).toEqual('OK'); + }); + + it('should return an explanation for invalid objects', () => { + expect(explainPaytrailComission({})).toContain('property "merchant" not string'); + expect(explainPaytrailComission({ ...validComission, unknownProperty: 'Test' })).toContain('unknownProperty'); + expect(explainPaytrailComission({ merchant: 123, amount: 250 })).toContain('property "merchant" not string'); + expect(explainPaytrailComission({ merchant: '695874', amount: '250' })).toContain('property "amount" not number'); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailComission.ts b/paytrail/types/PaytrailComission.ts new file mode 100644 index 0000000..5515f18 --- /dev/null +++ b/paytrail/types/PaytrailComission.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainString, isString } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNumber, isNumber } from "../../types/Number"; +import { isUndefined } from "../../types/undefined"; + +/** + * @see https://docs.paytrail.com/#/?id=commission + */ +export interface PaytrailComission { + + /** + * Merchant who gets the commission + * + * Example: `695874` + */ + readonly merchant: string; + + /** + * Amount of commission in currency's minor units, e.g. for Euros use cents. + * VAT not applicable. + * + * Example: `250` + */ + readonly amount: number; + +} + +export function createPaytrailComission ( + merchant : string, + amount : number, +) : PaytrailComission { + return { + merchant, + amount, + }; +} + +export function isPaytrailComission (value: unknown) : value is PaytrailComission { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'merchant', + 'amount', + ]) + && isString(value?.merchant) + && isNumber(value?.amount) + ); +} + +export function explainPaytrailComission (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'merchant', + 'amount', + ]) + , explainProperty("merchant", explainString(value?.merchant)) + , explainProperty("amount", explainNumber(value?.amount)) + ] + ); +} + +export function parsePaytrailComission (value: unknown) : PaytrailComission | undefined { + if (isPaytrailComission(value)) return value; + return undefined; +} + +export function isPaytrailComissionOrUndefined (value: unknown): value is PaytrailComission | undefined { + return isUndefined(value) || isPaytrailComission(value); +} + +export function explainPaytrailComissionOrUndefined (value: unknown): string { + return isPaytrailComissionOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailComission', 'undefined'])); +} diff --git a/paytrail/types/PaytrailCurrency.test.ts b/paytrail/types/PaytrailCurrency.test.ts new file mode 100644 index 0000000..e1ec453 --- /dev/null +++ b/paytrail/types/PaytrailCurrency.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainPaytrailCurrency, isPaytrailCurrency, parsePaytrailCurrency, PaytrailCurrency, stringifyPaytrailCurrency } from "./PaytrailCurrency"; + +describe('PaytrailCurrency functions', () => { + + const validCurrency = PaytrailCurrency.EUR; + + describe('isPaytrailCurrency', () => { + it('should return true for valid PaytrailCurrency values', () => { + expect(isPaytrailCurrency(validCurrency)).toBe(true); + }); + + it('should return false for invalid values', () => { + expect(isPaytrailCurrency('USD')).toBe(false); + expect(isPaytrailCurrency(123)).toBe(false); + expect(isPaytrailCurrency({})).toBe(false); + }); + }); + + describe('explainPaytrailCurrency', () => { + it('should return explanation OK for valid PaytrailCurrency values', () => { + expect(explainPaytrailCurrency(validCurrency)).toEqual('OK'); + }); + + it('should return an explanation for invalid values', () => { + expect(explainPaytrailCurrency('USD')).toContain('incorrect enum value'); + }); + }); + + describe('stringifyPaytrailCurrency', () => { + it('should correctly convert PaytrailCurrency to string', () => { + expect(stringifyPaytrailCurrency(validCurrency)).toEqual('EUR'); + }); + }); + + describe('parsePaytrailCurrency', () => { + + it('should correctly parse string into PaytrailCurrency', () => { + expect(parsePaytrailCurrency('EUR')).toEqual(PaytrailCurrency.EUR); + expect(parsePaytrailCurrency('eur')).toEqual(PaytrailCurrency.EUR); + }); + + it('should return undefined for invalid strings', () => { + expect(parsePaytrailCurrency('USD')).toBeUndefined(); + expect(parsePaytrailCurrency(123)).toBeUndefined(); + expect(parsePaytrailCurrency({})).toBeUndefined(); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailCurrency.ts b/paytrail/types/PaytrailCurrency.ts new file mode 100644 index 0000000..c46faf1 --- /dev/null +++ b/paytrail/types/PaytrailCurrency.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; + +export enum PaytrailCurrency { + EUR = "EUR" +} + +export function isPaytrailCurrency (value: unknown) : value is PaytrailCurrency { + return isEnum(PaytrailCurrency, value); +} + +export function explainPaytrailCurrency (value : unknown) : string { + return explainEnum("PaytrailCurrency", PaytrailCurrency, isPaytrailCurrency, value); +} + +export function stringifyPaytrailCurrency (value : PaytrailCurrency) : string { + return stringifyEnum(PaytrailCurrency, value); +} + +export function parsePaytrailCurrency (value: any) : PaytrailCurrency | undefined { + return parseEnum(PaytrailCurrency, value) as PaytrailCurrency | undefined; +} + +export function isPaytrailCurrencyOrUndefined (value: unknown): value is PaytrailCurrency | undefined { + return isUndefined(PaytrailCurrency) || isPaytrailCurrency(value); +} + +export function explainPaytrailCurrencyOrUndefined (value: unknown): string { + return isPaytrailCurrencyOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailCurrency', 'undefined'])); +} diff --git a/paytrail/types/PaytrailCustomer.test.ts b/paytrail/types/PaytrailCustomer.test.ts new file mode 100644 index 0000000..4c8c14f --- /dev/null +++ b/paytrail/types/PaytrailCustomer.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailCustomer, explainPaytrailCustomer, isPaytrailCustomer, parsePaytrailCustomer, PaytrailCustomer } from "./PaytrailCustomer"; + +describe('PaytrailCustomer functions', () => { + + const validCustomer: PaytrailCustomer = { + email: 'john.doe@example.org', + firstName: 'John', + lastName: 'Doe', + phone: '358451031234', + vatId: 'FI02454583', + companyName: 'Example company' + }; + + describe('createPaytrailCustomer', () => { + + it('should correctly create a PaytrailCustomer', () => { + const customer = createPaytrailCustomer( + 'john.doe@example.org', + 'John', + 'Doe', + '358451031234', + 'FI02454583', + 'Example company' + ); + expect(customer).toEqual(validCustomer); + }); + + }); + + describe('isPaytrailCustomer', () => { + + it('should return true for valid PaytrailCustomer objects', () => { + expect(isPaytrailCustomer(validCustomer)).toBe(true); + }); + + it('should return false for invalid objects', () => { + expect(isPaytrailCustomer({...validCustomer, email: 123})).toBe(false); + expect(isPaytrailCustomer({...validCustomer, unknownProp: 'test'})).toBe(false); + }); + + }); + + describe('explainPaytrailCustomer', () => { + + it('should return explanation OK for valid PaytrailCustomer objects', () => { + expect(explainPaytrailCustomer(validCustomer)).toEqual('OK'); + }); + + it('should return an explanation for invalid objects', () => { + expect(explainPaytrailCustomer({...validCustomer, email: 123})).toContain('property "email"'); + expect(explainPaytrailCustomer({...validCustomer, unknownProp: 'test'})).toContain('had extra properties: unknownProp'); + }); + + }); + + describe('parsePaytrailCustomer', () => { + + it('should correctly parse valid PaytrailCustomer objects', () => { + expect(parsePaytrailCustomer(validCustomer)).toEqual(validCustomer); + }); + + it('should return undefined for invalid objects', () => { + expect(parsePaytrailCustomer({...validCustomer, email: 123})).toBeUndefined(); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailCustomer.ts b/paytrail/types/PaytrailCustomer.ts new file mode 100644 index 0000000..b3d2d6d --- /dev/null +++ b/paytrail/types/PaytrailCustomer.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; + +/** + * @see https://docs.paytrail.com/#/?id=customer-1 + */ +export interface PaytrailCustomer { + + /** + * Email. Maximum of 200 characters. + * + * Example: `john.doe@example.org` + */ + readonly email : string; + + /** + * First name (required for OPLasku and Walley/Collector). Maximum of 50 + * characters. + * + * Example: `John` + */ + readonly firstName ?: string; + + /** + * Last name (required for OPLasku and Walley/Collector). Maximum of 50 + * characters. + * + * Example: `Doe` + */ + readonly lastName ?: string; + + /** + * Phone number + * + * Example: `358451031234` + */ + readonly phone ?: string; + + /** + * VAT ID, if any + * + * Example: `FI02454583` + */ + readonly vatId ?: string; + + /** + * Company name, if any + * + * Example: `Example company` + */ + readonly companyName ?: string; + +} + +export function createPaytrailCustomer ( + email : string, + firstName ?: string, + lastName ?: string, + phone ?: string, + vatId ?: string, + companyName ?: string, +) : PaytrailCustomer { + return { + email, + firstName, + lastName, + phone, + vatId, + companyName, + }; +} + +export function isPaytrailCustomer (value: unknown) : value is PaytrailCustomer { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'email', + 'firstName', + 'lastName', + 'phone', + 'vatId', + 'companyName', + ]) + && isString(value?.email) + && isStringOrUndefined(value?.firstName) + && isStringOrUndefined(value?.lastName) + && isStringOrUndefined(value?.phone) + && isStringOrUndefined(value?.vatId) + && isStringOrUndefined(value?.companyName) + ); +} + +export function explainPaytrailCustomer (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'email', + 'firstName', + 'lastName', + 'phone', + 'vatId', + 'companyName', + ]) + , explainProperty("email", explainString(value?.email)) + , explainProperty("firstName", explainStringOrUndefined(value?.firstName)) + , explainProperty("lastName", explainStringOrUndefined(value?.lastName)) + , explainProperty("phone", explainStringOrUndefined(value?.phone)) + , explainProperty("vatId", explainStringOrUndefined(value?.vatId)) + , explainProperty("companyName", explainStringOrUndefined(value?.companyName)) + ] + ); +} + +export function parsePaytrailCustomer (value: unknown) : PaytrailCustomer | undefined { + if (isPaytrailCustomer(value)) return value; + return undefined; +} diff --git a/paytrail/types/PaytrailFormField.test.ts b/paytrail/types/PaytrailFormField.test.ts new file mode 100644 index 0000000..f7d7b86 --- /dev/null +++ b/paytrail/types/PaytrailFormField.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailFormField, explainPaytrailFormField, explainPaytrailFormFieldOrUndefined, isPaytrailFormField, isPaytrailFormFieldOrUndefined, parsePaytrailFormField } from "./PaytrailFormField"; + +describe('PaytrailFormField', () => { + + const mockedPaytrailFormField = { + name: 'testName', + value: 'testValue', + }; + + describe('createPaytrailFormField', () => { + + it( 'should create PaytrailFormField correctly', () => { + const result = createPaytrailFormField( 'testName', 'testValue' ); + expect( result ).toEqual( mockedPaytrailFormField ); + } ); + + }); + + describe('isPaytrailFormField', () => { + + it('should validate if an object is PaytrailFormField', () => { + const result = isPaytrailFormField(mockedPaytrailFormField); + expect(result).toBe(true); + }); + + it('should return false if the object is not PaytrailFormField', () => { + const result = isPaytrailFormField({ name: 'testName' }); // missing 'value' + expect(result).toBe(false); + }); + + }); + + describe('explainPaytrailFormField', () => { + + it( 'should explain why an object cannot be parsed into PaytrailFormField', () => { + const result = explainPaytrailFormField( { name: 'testName' } ); // missing 'value' + expect( result ).toContain( 'property "value" not string' ); + } ); + + }); + + describe('parsePaytrailFormField', () => { + + it( 'should parse object into PaytrailFormField if it is valid', () => { + const result = parsePaytrailFormField( mockedPaytrailFormField ); + expect( result ).toEqual( mockedPaytrailFormField ); + } ); + + it( 'should return undefined if the object cannot be parsed into PaytrailFormField', () => { + const result = parsePaytrailFormField( { name: 'testName' } ); // missing 'value' + expect( result ).toBeUndefined(); + } ); + }); + + describe('isPaytrailFormFieldOrUndefined', () => { + it( 'should validate if a value is PaytrailFormField or undefined', () => { + expect( isPaytrailFormFieldOrUndefined( mockedPaytrailFormField ) ).toBe( true ); + expect( isPaytrailFormFieldOrUndefined( undefined ) ).toBe( true ); + expect( isPaytrailFormFieldOrUndefined( { name: 'testName' } ) ).toBe( false ); // missing 'value' + } ); + }); + + describe('explainPaytrailFormFieldOrUndefined', () => { + + it('should explain why a value is not PaytrailFormField or undefined', () => { + const result = explainPaytrailFormFieldOrUndefined({ name: 'testName' }); // missing 'value' + expect(result).toContain('not PaytrailFormField or undefined'); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailFormField.ts b/paytrail/types/PaytrailFormField.ts new file mode 100644 index 0000000..d7d529e --- /dev/null +++ b/paytrail/types/PaytrailFormField.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, isString } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; + +/** + * The form field values are rendered as hidden elements in the form. See form rendering example. + * + * @see https://docs.paytrail.com/#/?id=formfield + */ +export interface PaytrailFormField { + + /** + * Name of the input + */ + readonly name: string; + + /** + * Value of the input + */ + readonly value: string; + +} + +export function createPaytrailFormField ( + name : string, + value : string +) : PaytrailFormField { + return { + name, + value + }; +} + +export function isPaytrailFormField (value: unknown) : value is PaytrailFormField { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'name', + 'value', + ]) + && isString(value?.name) + && isString(value?.value) + ); +} + +export function explainPaytrailFormField (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'name', + 'value', + ]) + , explainProperty("name", explainString(value?.name)) + , explainProperty("value", explainString(value?.value)) + ] + ); +} + +export function parsePaytrailFormField (value: unknown) : PaytrailFormField | undefined { + if (isPaytrailFormField(value)) return value; + return undefined; +} + +export function isPaytrailFormFieldOrUndefined (value: unknown): value is PaytrailFormField | undefined { + return isUndefined(value) || isPaytrailFormField(value); +} + +export function explainPaytrailFormFieldOrUndefined (value: unknown): string { + return isPaytrailFormFieldOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailFormField', 'undefined'])); +} diff --git a/paytrail/types/PaytrailItem.test.ts b/paytrail/types/PaytrailItem.test.ts new file mode 100644 index 0000000..eff2d26 --- /dev/null +++ b/paytrail/types/PaytrailItem.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailItem, explainPaytrailItem, isPaytrailItem, parsePaytrailItem, PaytrailItem } from "./PaytrailItem"; + +describe('PaytrailItem', () => { + + const validItem: PaytrailItem = { + unitPrice: 1000, + units: 5, + vatPercentage: 24, + productCode: '9a', + description: 'Toy dog', + category: 'happy toys', + orderId: '123', + stamp: 'abc123', + reference: 'dog-toy-5', + merchant: '695874', + commission: { + merchant: '695874', + amount: 250 + }, + deliveryDate: '2019-12-31' + }; + + describe('createPaytrailItem', () => { + it('should correctly create a PaytrailItem', () => { + const item = createPaytrailItem( + 1000, + 5, + 24, + '9a', + 'Toy dog', + 'happy toys', + '123', + 'abc123', + 'dog-toy-5', + '695874', + { + merchant: '695874', + amount: 250 + }, + '2019-12-31' + ); + expect(item).toEqual(validItem); + }); + }); + + describe('isPaytrailItem', () => { + + it('should return true for valid PaytrailItem objects', () => { + expect(isPaytrailItem(validItem)).toBe(true); + }); + + it('should return false for invalid objects', () => { + expect(isPaytrailItem({...validItem, unitPrice: 'invalid'})).toBe(false); + expect(isPaytrailItem({...validItem, unknownProp: 'test'})).toBe(false); + }); + + }); + + describe('explainPaytrailItem', () => { + + it('should return explanation OK for valid PaytrailItem objects', () => { + expect(explainPaytrailItem(validItem)).toEqual('OK'); + }); + + it('should return an explanation for invalid objects', () => { + expect(explainPaytrailItem({...validItem, productCode: 123})).toContain('property "productCode"'); + expect(explainPaytrailItem({...validItem, unknownProp: 'test'})).toContain('had extra properties: unknownProp'); + }); + + }); + + describe('parsePaytrailItem', () => { + + it('should correctly parse valid PaytrailItem objects', () => { + expect(parsePaytrailItem(validItem)).toEqual(validItem); + }); + + it('should return undefined for invalid objects', () => { + expect(parsePaytrailItem({...validItem, unitPrice: 'invalid'})).toBeUndefined(); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailItem.ts b/paytrail/types/PaytrailItem.ts new file mode 100644 index 0000000..868a7ed --- /dev/null +++ b/paytrail/types/PaytrailItem.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainProperty } from "../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainPaytrailComissionOrUndefined, isPaytrailComissionOrUndefined, PaytrailComission } from "./PaytrailComission"; +import { explainNumber, isNumber } from "../../types/Number"; + +/** + * @see https://docs.paytrail.com/#/?id=item + */ +export interface PaytrailItem { + + /** + * Price per unit, in each country's minor unit, e.g. for Euros use cents. + * By default price should include VAT, unless usePricesWithoutVat is set to + * true. No negative values accepted. Maximum value of 2147483647, minimum + * value is 0. + * + * Example: `1000` + */ + readonly unitPrice: number; + + /** + * Quantity, how many items ordered. Negative values are not supported. + * + * Example: `5` + */ + readonly units: number; + + /** + * VAT percentage + * + * Example: `24` + */ + readonly vatPercentage: number; + + /** + * Merchant product code. May appear on invoices of certain payment methods. + * Maximum of 100 characters + * + * Example: `9a` + */ + readonly productCode: string; + + /** + * Item description. May appear on invoices of certain payment methods. + * Maximum of 1000 characters. + * + * Example: `Toy dog` + */ + readonly description ?: string; + + /** + * Merchant specific item category + * + * Example: `happy toys` + */ + readonly category ?: string; + + /** + * Item level order ID (suborder ID). Mainly useful for Shop-in-Shop + * purchases. + */ + readonly orderId ?: string; + + /** + * Unique identifier for this item. Required for Shop-in-Shop payments. + * Required for item refunds. + */ + readonly stamp ?: string; + + /** + * Reference for this item. Required for Shop-in-Shop payments. + * + * Example: `dog-toy-5` + */ + readonly reference ?: string; + + /** + * Merchant ID for the item. Required for Shop-in-Shop payments, do not use + * for normal payments. + * + * Example: `695874` + */ + readonly merchant ?: string; + + /** + * Shop-in-Shop commission. Do not use for normal payments. + */ + readonly commission ?: PaytrailComission; + + /** + * When is this item going to be delivered. + * + * This field is deprecated but remains here as a reference for old integrations. + * + * Example: `2019-12-31` + * + * @deprecated + */ + readonly deliveryDate ?: string; + +} + +export function createPaytrailItem ( + unitPrice: number, + units: number, + vatPercentage: number, + productCode: string, + description ?: string, + category ?: string, + orderId ?: string, + stamp ?: string, + reference ?: string, + merchant ?: string, + commission ?: PaytrailComission, + deliveryDate ?: string, +) : PaytrailItem { + return { + unitPrice, + units, + vatPercentage, + productCode, + description, + category, + orderId, + stamp, + reference, + merchant, + commission, + deliveryDate + }; +} + +export function isPaytrailItem (value: unknown) : value is PaytrailItem { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'unitPrice', + 'units', + 'vatPercentage', + 'productCode', + 'description', + 'category', + 'orderId', + 'stamp', + 'reference', + 'merchant', + 'commission', + 'deliveryDate' + ]) + && isNumber(value?.unitPrice) + && isNumber(value?.units) + && isNumber(value?.vatPercentage) + && isString(value?.productCode) + && isStringOrUndefined(value?.description) + && isStringOrUndefined(value?.category) + && isStringOrUndefined(value?.orderId) + && isStringOrUndefined(value?.stamp) + && isStringOrUndefined(value?.reference) + && isStringOrUndefined(value?.merchant) + && isPaytrailComissionOrUndefined(value?.commission) + && isStringOrUndefined(value?.deliveryDate) + ); +} + +export function explainPaytrailItem (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'unitPrice', + 'units', + 'vatPercentage', + 'productCode', + 'description', + 'category', + 'orderId', + 'stamp', + 'reference', + 'merchant', + 'commission', + 'deliveryDate' + ]) + , explainProperty("unitPrice", explainNumber(value?.unitPrice)) + , explainProperty("units", explainNumber(value?.units)) + , explainProperty("vatPercentage", explainNumber(value?.vatPercentage)) + , explainProperty("productCode", explainString(value?.productCode)) + , explainProperty("description", explainStringOrUndefined(value?.description)) + , explainProperty("category", explainStringOrUndefined(value?.category)) + , explainProperty("orderId", explainStringOrUndefined(value?.orderId)) + , explainProperty("stamp", explainStringOrUndefined(value?.stamp)) + , explainProperty("reference", explainStringOrUndefined(value?.reference)) + , explainProperty("merchant", explainStringOrUndefined(value?.merchant)) + , explainProperty("commission", explainPaytrailComissionOrUndefined(value?.commission)) + , explainProperty("deliveryDate", explainStringOrUndefined(value?.deliveryDate)) + ] + ); +} + +export function parsePaytrailItem (value: unknown) : PaytrailItem | undefined { + if (isPaytrailItem(value)) return value; + return undefined; +} diff --git a/paytrail/types/PaytrailLanguage.test.ts b/paytrail/types/PaytrailLanguage.test.ts new file mode 100644 index 0000000..e309f55 --- /dev/null +++ b/paytrail/types/PaytrailLanguage.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainPaytrailLanguage, explainPaytrailLanguageOrUndefined, isPaytrailLanguage, isPaytrailLanguageOrUndefined, parsePaytrailLanguage, PaytrailLanguage, stringifyPaytrailLanguage } from "./PaytrailLanguage"; + +describe('PaytrailLanguage', () => { + + const validLanguages = [PaytrailLanguage.FI, PaytrailLanguage.SV, PaytrailLanguage.EN]; + + const invalidLanguage = 'ES'; + + describe('isPaytrailLanguage', () => { + + validLanguages.forEach(language => { + it(`should return true for "${language}"`, () => { + expect(isPaytrailLanguage(language)).toBe(true); + }); + }); + + it(`should return false for invalid language`, () => { + expect(isPaytrailLanguage(invalidLanguage)).toBe(false); + }); + + }); + + describe('explainPaytrailLanguage', () => { + + validLanguages.forEach(language => { + it(`should return OK for "${language}"`, () => { + expect(explainPaytrailLanguage(language)).toEqual('OK'); + }); + }); + + it(`should return an explanation for invalid language`, () => { + expect(explainPaytrailLanguage(invalidLanguage)).toContain('incorrect enum value "ES" for PaytrailLanguage: Accepted values FI, SV, EN'); + }); + + }); + + describe('stringifyPaytrailLanguage', () => { + + validLanguages.forEach(language => { + it(`should correctly stringify "${language}"`, () => { + expect(stringifyPaytrailLanguage(language)).toEqual(language); + }); + }); + + }); + + describe('parsePaytrailLanguage', () => { + + validLanguages.forEach(language => { + it(`should correctly parse "${language}"`, () => { + expect(parsePaytrailLanguage(language)).toEqual(language); + }); + }); + + it(`should return undefined for invalid language`, () => { + expect(parsePaytrailLanguage(invalidLanguage)).toBeUndefined(); + }); + + }); + + describe('isPaytrailLanguageOrUndefined', () => { + + it(`should return true for undefined`, () => { + expect(isPaytrailLanguageOrUndefined(undefined)).toBe(true); + }); + + validLanguages.forEach(language => { + it(`should return true for "${language}"`, () => { + expect(isPaytrailLanguageOrUndefined(language)).toBe(true); + }); + }); + + it(`should return false for invalid language`, () => { + expect(isPaytrailLanguageOrUndefined(invalidLanguage)).toBe(false); + }); + + }); + + describe('explainPaytrailLanguageOrUndefined', () => { + + it(`should return OK for undefined`, () => { + expect(explainPaytrailLanguageOrUndefined(undefined)).toEqual('OK'); + }); + + validLanguages.forEach(language => { + it(`should return OK for "${language}"`, () => { + expect(explainPaytrailLanguageOrUndefined(language)).toEqual('OK'); + }); + }); + + it(`should return an explanation for invalid language`, () => { + expect(explainPaytrailLanguageOrUndefined(invalidLanguage)).toContain('not PaytrailLanguage or undefined'); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailLanguage.ts b/paytrail/types/PaytrailLanguage.ts new file mode 100644 index 0000000..4eea8cb --- /dev/null +++ b/paytrail/types/PaytrailLanguage.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { Language } from "../../types/Language"; + +export enum PaytrailLanguage { + FI = "FI", + SV = "SV", + EN = "EN", +} + +export function isPaytrailLanguage (value: unknown) : value is PaytrailLanguage { + return isEnum(PaytrailLanguage, value); +} + +export function explainPaytrailLanguage (value : unknown) : string { + return explainEnum("PaytrailLanguage", PaytrailLanguage, isPaytrailLanguage, value); +} + +export function stringifyPaytrailLanguage (value : PaytrailLanguage) : string { + return stringifyEnum(PaytrailLanguage, value); +} + +export function parsePaytrailLanguage (value: any) : PaytrailLanguage | undefined { + return parseEnum(PaytrailLanguage, value) as PaytrailLanguage | undefined; +} + +export function isPaytrailLanguageOrUndefined (value: unknown): value is PaytrailLanguage | undefined { + return isUndefined(value) || isPaytrailLanguage(value); +} + +export function explainPaytrailLanguageOrUndefined (value: unknown): string { + return isPaytrailLanguageOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailLanguage', 'undefined'])); +} + +export function getPaytrailLanguageFromLanguage (value: Language) : PaytrailLanguage { + switch (value) { + case Language.FINNISH: return PaytrailLanguage.FI; + case Language.ENGLISH: return PaytrailLanguage.EN; + default: return PaytrailLanguage.EN; + } +} \ No newline at end of file diff --git a/paytrail/types/PaytrailLimitedProvider.test.ts b/paytrail/types/PaytrailLimitedProvider.test.ts new file mode 100644 index 0000000..e655f2b --- /dev/null +++ b/paytrail/types/PaytrailLimitedProvider.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailLimitedProvider, explainPaytrailLimitedProvider, explainPaytrailLimitedProviderOrUndefined, isPaytrailLimitedProvider, isPaytrailLimitedProviderOrUndefined, parsePaytrailLimitedProvider } from "./PaytrailLimitedProvider"; +import { PaytrailPaymentMethodGroup } from "./PaytrailPaymentMethodGroup"; + +describe('PaytrailLimitedProvider', () => { + + const mockedPaytrailLimitedProvider = { + id: 'testId', + name: 'testName', + icon: 'https://testiconurl.com', + svg: 'https://testsvgurl.com', + group: PaytrailPaymentMethodGroup.MOBILE, + }; + + describe('createPaytrailLimitedProvider', () => { + + it('creates PaytrailLimitedProvider correctly', () => { + const result = createPaytrailLimitedProvider( + 'testId', + 'testName', + PaytrailPaymentMethodGroup.MOBILE, + 'https://testiconurl.com', + 'https://testsvgurl.com', + ); + expect(result).toEqual(mockedPaytrailLimitedProvider); + }); + + }); + + describe('isPaytrailLimitedProvider', () => { + + it('validates if an object is PaytrailLimitedProvider', () => { + const result = isPaytrailLimitedProvider(mockedPaytrailLimitedProvider); + expect(result).toBe(true); + }); + + it('returns false if the object is not PaytrailLimitedProvider', () => { + const result = isPaytrailLimitedProvider({ + id: 'testId', + // Missing other properties. + }); + expect(result).toBe(false); + }); + + }); + + describe('explainPaytrailLimitedProvider', () => { + it('explains why an object cannot be parsed into PaytrailLimitedProvider', () => { + const result = explainPaytrailLimitedProvider({ + id: 'testId', + // Missing other properties. + }); + expect(result).toContain('property "icon" not string'); + }); + }); + + describe('parsePaytrailLimitedProvider', () => { + + it('parses object into PaytrailLimitedProvider if it is valid', () => { + const result = parsePaytrailLimitedProvider(mockedPaytrailLimitedProvider); + expect(result).toEqual(mockedPaytrailLimitedProvider); + }); + + it('returns undefined if the object cannot be parsed into PaytrailLimitedProvider', () => { + const result = parsePaytrailLimitedProvider({ + id: 'testId', + // Missing other properties. + }); + expect(result).toBeUndefined(); + }); + + }); + + describe('isPaytrailLimitedProviderOrUndefined', () => { + it('validates if a value is PaytrailLimitedProvider', () => { + expect(isPaytrailLimitedProviderOrUndefined(mockedPaytrailLimitedProvider)).toBe(true); + expect(isPaytrailLimitedProviderOrUndefined({ id: 'testId' })).toBe(false); // Missing other properties. + }); + it('validates if a value is undefined', () => { + expect(isPaytrailLimitedProviderOrUndefined(undefined)).toBe(true); + }); + }); + + describe('explainPaytrailLimitedProviderOrUndefined', () => { + it('explains why a value is not PaytrailLimitedProvider or undefined', () => { + const result = explainPaytrailLimitedProviderOrUndefined({ id: 'testId' }); // Missing other properties. + expect(result).toContain('not PaytrailLimitedProvider or undefined'); + }); + }); + +}); diff --git a/paytrail/types/PaytrailLimitedProvider.ts b/paytrail/types/PaytrailLimitedProvider.ts new file mode 100644 index 0000000..58df63f --- /dev/null +++ b/paytrail/types/PaytrailLimitedProvider.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, isString } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainPaytrailPaymentMethodGroup, isPaytrailPaymentMethodGroup, PaytrailPaymentMethodGroup } from "./PaytrailPaymentMethodGroup"; + +/** + * @example + * { + * "id": "pivo", + * "name": "Pivo", + * "icon": "https://resources.paytrail.com/images/payment-method-logos/pivo.png", + * "svg": "https://resources.paytrail.com/images/payment-method-logos/pivo.svg", + * "group": "mobile" + * } + * @see https://docs.paytrail.com/#/?id=provider + */ +export interface PaytrailLimitedProvider { + + /** + * ID of the provider + */ + readonly id : string; + + /** + * Display name of the provider. + */ + readonly name : string; + + /** + * URL to PNG version of the provider icon + */ + readonly icon: string; + + /** + * URL to SVG version of the provider icon. Using the SVG icon is preferred. + */ + readonly svg: string; + + /** + * Provider group. Provider groups allow presenting same type of providers + * in separate groups which usually makes it easier for the customer to + * select a payment method. + */ + readonly group: PaytrailPaymentMethodGroup; + +} + +export function createPaytrailLimitedProvider ( + id : string, + name : string, + group: PaytrailPaymentMethodGroup, + icon: string, + svg: string, +) : PaytrailLimitedProvider { + return { + icon, + svg, + group, + name, + id, + }; +} + +export function isPaytrailLimitedProvider (value: unknown) : value is PaytrailLimitedProvider { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'icon', + 'svg', + 'group', + 'name', + 'id', + ]) + && isString(value?.icon) + && isString(value?.svg) + && isPaytrailPaymentMethodGroup(value?.group) + && isString(value?.name) + && isString(value?.id) + ); +} + +export function explainPaytrailLimitedProvider (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'icon', + 'svg', + 'group', + 'name', + 'id', + ]) + , explainProperty("icon", explainString(value?.icon)) + , explainProperty("svg", explainString(value?.svg)) + , explainProperty("group", explainPaytrailPaymentMethodGroup(value?.group)) + , explainProperty("name", explainString(value?.name)) + , explainProperty("id", explainString(value?.id)) + ] + ); +} + +export function parsePaytrailLimitedProvider (value: unknown) : PaytrailLimitedProvider | undefined { + if (isPaytrailLimitedProvider(value)) return value; + return undefined; +} + +export function isPaytrailLimitedProviderOrUndefined (value: unknown): value is PaytrailLimitedProvider | undefined { + return isUndefined(value) || isPaytrailLimitedProvider(value); +} + +export function explainPaytrailLimitedProviderOrUndefined (value: unknown): string { + return isPaytrailLimitedProviderOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailLimitedProvider', 'undefined'])); +} diff --git a/paytrail/types/PaytrailPaymentMethodGroup.test.ts b/paytrail/types/PaytrailPaymentMethodGroup.test.ts new file mode 100644 index 0000000..f7e140f --- /dev/null +++ b/paytrail/types/PaytrailPaymentMethodGroup.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainPaytrailPaymentMethodGroup, explainPaytrailPaymentMethodGroupOrUndefined, isPaytrailPaymentMethodGroup, isPaytrailPaymentMethodGroupOrUndefined, parsePaytrailPaymentMethodGroup, PaytrailPaymentMethodGroup, stringifyPaytrailPaymentMethodGroup } from "./PaytrailPaymentMethodGroup"; + +describe('PaytrailPaymentMethodGroup', () => { + + const validGroups = [ + PaytrailPaymentMethodGroup.MOBILE, + PaytrailPaymentMethodGroup.BANK, + PaytrailPaymentMethodGroup.CREDIT_CARD, + PaytrailPaymentMethodGroup.CREDIT, + ]; + + const validGroupTexts = [ + 'MOBILE', + 'BANK', + 'CREDIT_CARD', + 'CREDIT', + ]; + + const invalidGroup = 'cash'; + + describe('isPaytrailPaymentMethodGroup', () => { + + validGroups.forEach(group => { + it(`should return true for "${group}"`, () => { + expect(isPaytrailPaymentMethodGroup(group)).toBe(true); + }); + }); + + it(`should return false for invalid group`, () => { + expect(isPaytrailPaymentMethodGroup(invalidGroup)).toBe(false); + }); + + }); + + describe('explainPaytrailPaymentMethodGroup', () => { + + validGroups.forEach(group => { + it(`should return OK for "${group}"`, () => { + expect(explainPaytrailPaymentMethodGroup(group)).toEqual('OK'); + }); + }); + + it(`should return an explanation for invalid group`, () => { + expect(explainPaytrailPaymentMethodGroup(invalidGroup)).toContain('incorrect enum value "cash" for PaytrailPaymentMethodGroup: Accepted values mobile, bank, creditcard, credit'); + }); + + }); + + describe('stringifyPaytrailPaymentMethodGroup', () => { + + validGroups.forEach((group, index) => { + it(`should correctly stringify "${group}"`, () => { + expect(stringifyPaytrailPaymentMethodGroup(group)).toEqual(validGroupTexts[index]); + }); + }); + + }); + + describe('parsePaytrailPaymentMethodGroup', () => { + + validGroups.forEach(group => { + it(`should correctly parse "${group}"`, () => { + expect(parsePaytrailPaymentMethodGroup(group)).toEqual(group); + }); + }); + + it(`should return undefined for invalid group`, () => { + expect(parsePaytrailPaymentMethodGroup(invalidGroup)).toBeUndefined(); + }); + + }); + + describe('isPaytrailPaymentMethodGroupOrUndefined', () => { + + it(`should return true for undefined`, () => { + expect(isPaytrailPaymentMethodGroupOrUndefined(undefined)).toBe(true); + }); + + validGroups.forEach(group => { + it(`should return true for "${group}"`, () => { + expect(isPaytrailPaymentMethodGroupOrUndefined(group)).toBe(true); + }); + }); + + it(`should return false for invalid group`, () => { + expect(isPaytrailPaymentMethodGroupOrUndefined(invalidGroup)).toBe(false); + }); + + }); + + describe('explainPaytrailPaymentMethodGroupOrUndefined', () => { + + it(`should return OK for undefined`, () => { + expect(explainPaytrailPaymentMethodGroupOrUndefined(undefined)).toEqual('OK'); + }); + + validGroups.forEach(group => { + it(`should return OK for "${group}"`, () => { + expect(explainPaytrailPaymentMethodGroupOrUndefined(group)).toEqual('OK'); + }); + }); + + it(`should return an explanation for invalid group`, () => { + expect(explainPaytrailPaymentMethodGroupOrUndefined(invalidGroup)).toContain('not PaytrailPaymentMethodGroup or undefined'); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailPaymentMethodGroup.ts b/paytrail/types/PaytrailPaymentMethodGroup.ts new file mode 100644 index 0000000..1c14dee --- /dev/null +++ b/paytrail/types/PaytrailPaymentMethodGroup.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; +import { isUndefined } from "../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; + +export enum PaytrailPaymentMethodGroup { + MOBILE = "mobile", + BANK = "bank", + CREDIT_CARD = "creditcard", + CREDIT = "credit", +} + +export function isPaytrailPaymentMethodGroup (value: unknown) : value is PaytrailPaymentMethodGroup { + return isEnum(PaytrailPaymentMethodGroup, value); +} + +export function explainPaytrailPaymentMethodGroup (value : unknown) : string { + return explainEnum("PaytrailPaymentMethodGroup", PaytrailPaymentMethodGroup, isPaytrailPaymentMethodGroup, value); +} + +export function stringifyPaytrailPaymentMethodGroup (value : PaytrailPaymentMethodGroup) : string { + return stringifyEnum(PaytrailPaymentMethodGroup, value); +} + +export function parsePaytrailPaymentMethodGroup (value: any) : PaytrailPaymentMethodGroup | undefined { + return parseEnum(PaytrailPaymentMethodGroup, value) as PaytrailPaymentMethodGroup | undefined; +} + +export function isPaytrailPaymentMethodGroupOrUndefined (value: unknown): value is PaytrailPaymentMethodGroup | undefined { + return isUndefined(value) || isPaytrailPaymentMethodGroup(value); +} + +export function explainPaytrailPaymentMethodGroupOrUndefined (value: unknown): string { + return isPaytrailPaymentMethodGroupOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailPaymentMethodGroup', 'undefined'])); +} diff --git a/paytrail/types/PaytrailPaymentMethodGroupData.test.ts b/paytrail/types/PaytrailPaymentMethodGroupData.test.ts new file mode 100644 index 0000000..54684ad --- /dev/null +++ b/paytrail/types/PaytrailPaymentMethodGroupData.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailPaymentMethodGroupData, explainPaytrailPaymentMethodGroupData, explainPaytrailPaymentMethodGroupDataOrUndefined, isPaytrailPaymentMethodGroupData, isPaytrailPaymentMethodGroupDataOrUndefined, parsePaytrailPaymentMethodGroupData } from "./PaytrailPaymentMethodGroupData"; +import { PaytrailPaymentMethodGroup } from "./PaytrailPaymentMethodGroup"; + +describe('PaytrailPaymentMethodGroupData', () => { + + const mockedPaytrailPaymentMethodGroupData = { + id: PaytrailPaymentMethodGroup.CREDIT, + name: 'testName', + icon: 'https://testiconurl.com', + svg: 'https://testsvgurl.com', + }; + + describe('createPaytrailPaymentMethodGroupData', () => { + it('creates PaytrailPaymentMethodGroupData correctly', () => { + const result = createPaytrailPaymentMethodGroupData( + PaytrailPaymentMethodGroup.CREDIT, + 'testName', + 'https://testiconurl.com', + 'https://testsvgurl.com' + ); + expect(result).toEqual(mockedPaytrailPaymentMethodGroupData); + }); + }); + + describe('isPaytrailPaymentMethodGroupData', () => { + it('validates if an object is PaytrailPaymentMethodGroupData', () => { + const result = isPaytrailPaymentMethodGroupData(mockedPaytrailPaymentMethodGroupData); + expect(result).toBe(true); + }); + + it('returns false if the object is not PaytrailPaymentMethodGroupData', () => { + const result = isPaytrailPaymentMethodGroupData({ + id: PaytrailPaymentMethodGroup.CREDIT, + // Missing other properties. + }); + expect(result).toBe(false); + }); + }); + + describe('explainPaytrailPaymentMethodGroupData', () => { + it('explains why an object cannot be parsed into PaytrailPaymentMethodGroupData', () => { + const result = explainPaytrailPaymentMethodGroupData({ + id: PaytrailPaymentMethodGroup.CREDIT, + // Missing other properties. + }); + expect(result).toContain('property "name" not string'); + expect(result).toContain('property "icon" not string'); + expect(result).toContain('property "svg" not string'); + }); + }); + + describe('parsePaytrailPaymentMethodGroupData', () => { + it('parses object into PaytrailPaymentMethodGroupData if it is valid', () => { + const result = parsePaytrailPaymentMethodGroupData(mockedPaytrailPaymentMethodGroupData); + expect(result).toEqual(mockedPaytrailPaymentMethodGroupData); + }); + + it('returns undefined if the object cannot be parsed into PaytrailPaymentMethodGroupData', () => { + const result = parsePaytrailPaymentMethodGroupData({ + id: PaytrailPaymentMethodGroup.CREDIT, + // Missing other properties. + }); + expect(result).toBeUndefined(); + }); + }); + + describe('isPaytrailPaymentMethodGroupDataOrUndefined', () => { + it('validates if a value is PaytrailPaymentMethodGroupData or undefined', () => { + expect(isPaytrailPaymentMethodGroupDataOrUndefined(mockedPaytrailPaymentMethodGroupData)).toBe(true); + expect(isPaytrailPaymentMethodGroupDataOrUndefined(undefined)).toBe(true); + expect(isPaytrailPaymentMethodGroupDataOrUndefined({ id: PaytrailPaymentMethodGroup.CREDIT })).toBe(false); // Missing other properties. + }); + }); + + describe('explainPaytrailPaymentMethodGroupDataOrUndefined', () => { + it('explains why a value is not PaytrailPaymentMethodGroupData or undefined', () => { + const result = explainPaytrailPaymentMethodGroupDataOrUndefined({ id: PaytrailPaymentMethodGroup.CREDIT }); // Missing other properties. + expect(result).toContain('not PaytrailPaymentMethodGroupData or undefined'); + }); + }); + +}); diff --git a/paytrail/types/PaytrailPaymentMethodGroupData.ts b/paytrail/types/PaytrailPaymentMethodGroupData.ts new file mode 100644 index 0000000..0ce0b1b --- /dev/null +++ b/paytrail/types/PaytrailPaymentMethodGroupData.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, isString } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainPaytrailPaymentMethodGroup, isPaytrailPaymentMethodGroup, PaytrailPaymentMethodGroup } from "./PaytrailPaymentMethodGroup"; +import { explainPaytrailProvider, isPaytrailProvider, PaytrailProvider } from "./PaytrailProvider"; +import { explainArrayOfOrUndefined, isArrayOfOrUndefined } from "../../types/Array"; + +/** + * @see https://docs.paytrail.com/#/?id=paymentmethodgroupdata + * @see https://docs.paytrail.com/#/?id=paymentmethodgroupdatawithproviders + */ +export interface PaytrailPaymentMethodGroupData { + + /** + * ID of the group + */ + readonly id: PaytrailPaymentMethodGroup; + + /** + * Localized name of the group + */ + readonly name: string; + + /** + * URL to PNG version of the group icon + */ + readonly icon: string; + + /** + * URL to SVG version of the group icon. Using the SVG icon is preferred. + */ + readonly svg: string; + + /** + * Providers for the payment group + * @see https://docs.paytrail.com/#/?id=paymentmethodgroupdatawithproviders + */ + readonly providers ?: readonly PaytrailProvider[]; + +} + +export function createPaytrailPaymentMethodGroupData ( + id : PaytrailPaymentMethodGroup, + name : string, + icon : string, + svg : string, + providers ?: readonly PaytrailProvider[] +) : PaytrailPaymentMethodGroupData { + return { + id, + name, + icon, + svg, + ...(providers ? {providers} : {}) + }; +} + +export function isPaytrailPaymentMethodGroupData (value: unknown) : value is PaytrailPaymentMethodGroupData { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'id', + 'name', + 'icon', + 'svg', + 'providers', + ]) + && isPaytrailPaymentMethodGroup(value?.id) + && isString(value?.name) + && isString(value?.icon) + && isString(value?.svg) + && isArrayOfOrUndefined(value?.providers, isPaytrailProvider) + ); +} + +export function explainPaytrailPaymentMethodGroupData (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'id', + 'name', + 'icon', + 'svg', + 'providers', + ]) + , explainProperty("id", explainPaytrailPaymentMethodGroup(value?.id)) + , explainProperty("name", explainString(value?.name)) + , explainProperty("icon", explainString(value?.icon)) + , explainProperty("svg", explainString(value?.svg)) + , explainProperty("providers", explainArrayOfOrUndefined("PaytrailProvider", explainPaytrailProvider, value?.providers, isPaytrailProvider)) + ] + ); +} + +export function parsePaytrailPaymentMethodGroupData (value: unknown) : PaytrailPaymentMethodGroupData | undefined { + if (isPaytrailPaymentMethodGroupData(value)) return value; + return undefined; +} + +export function isPaytrailPaymentMethodGroupDataOrUndefined (value: unknown): value is PaytrailPaymentMethodGroupData | undefined { + return isUndefined(value) || isPaytrailPaymentMethodGroupData(value); +} + +export function explainPaytrailPaymentMethodGroupDataOrUndefined (value: unknown): string { + return isPaytrailPaymentMethodGroupDataOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailPaymentMethodGroupData', 'undefined'])); +} diff --git a/paytrail/types/PaytrailProvider.test.ts b/paytrail/types/PaytrailProvider.test.ts new file mode 100644 index 0000000..b155c62 --- /dev/null +++ b/paytrail/types/PaytrailProvider.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createPaytrailProvider, explainPaytrailProvider, explainPaytrailProviderOrUndefined, isPaytrailProvider, isPaytrailProviderOrUndefined, parsePaytrailProvider } from "./PaytrailProvider"; +import { PaytrailPaymentMethodGroup } from "./PaytrailPaymentMethodGroup"; + +describe('PaytrailProvider', () => { + + const mockedPaytrailFormField = { + name: 'testName', + value: 'testValue', + }; + + const mockedPaytrailProvider = { + url: 'https://testurl.com', + icon: 'https://testiconurl.com', + svg: 'https://testsvgurl.com', + group: PaytrailPaymentMethodGroup.MOBILE, + name: 'testName', + id: 'testId', + parameters: [mockedPaytrailFormField], + }; + + describe('createPaytrailProvider', () => { + + it('creates PaytrailProvider correctly', () => { + const result = createPaytrailProvider( + 'https://testiconurl.com', + 'https://testsvgurl.com', + PaytrailPaymentMethodGroup.MOBILE, + 'testName', + 'testId', + 'https://testurl.com', + [mockedPaytrailFormField] + ); + expect(result).toEqual(mockedPaytrailProvider); + }); + + }); + + describe('isPaytrailProvider', () => { + + it('validates if an object is PaytrailProvider', () => { + const result = isPaytrailProvider(mockedPaytrailProvider); + expect(result).toBe(true); + }); + + it('returns false if the object is not PaytrailProvider', () => { + const result = isPaytrailProvider({ + url: 'https://testurl.com', + // Missing other properties. + }); + expect(result).toBe(false); + }); + + }); + + describe('explainPaytrailProvider', () => { + it('explains why an object cannot be parsed into PaytrailProvider', () => { + const result = explainPaytrailProvider({ + url: 'https://testurl.com', + // Missing other properties. + }); + expect(result).toContain('property "icon" not string'); + }); + }); + + describe('parsePaytrailProvider', () => { + + it('parses object into PaytrailProvider if it is valid', () => { + const result = parsePaytrailProvider(mockedPaytrailProvider); + expect(result).toEqual(mockedPaytrailProvider); + }); + + it('returns undefined if the object cannot be parsed into PaytrailProvider', () => { + const result = parsePaytrailProvider({ + url: 'https://testurl.com', + // Missing other properties. + }); + expect(result).toBeUndefined(); + }); + + }); + + describe('isPaytrailProviderOrUndefined', () => { + it('validates if a value is PaytrailProvider or undefined', () => { + expect(isPaytrailProviderOrUndefined(mockedPaytrailProvider)).toBe(true); + expect(isPaytrailProviderOrUndefined(undefined)).toBe(true); + expect(isPaytrailProviderOrUndefined({ url: 'https://testurl.com' })).toBe(false); // Missing other properties. + }); + }); + + describe('explainPaytrailProviderOrUndefined', () => { + it('explains why a value is not PaytrailProvider or undefined', () => { + const result = explainPaytrailProviderOrUndefined({ url: 'https://testurl.com' }); // Missing other properties. + expect(result).toContain('not PaytrailProvider or undefined'); + }); + }); + +}); diff --git a/paytrail/types/PaytrailProvider.ts b/paytrail/types/PaytrailProvider.ts new file mode 100644 index 0000000..0873fa2 --- /dev/null +++ b/paytrail/types/PaytrailProvider.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainPaytrailPaymentMethodGroup, isPaytrailPaymentMethodGroup, PaytrailPaymentMethodGroup } from "./PaytrailPaymentMethodGroup"; +import { explainPaytrailFormField, isPaytrailFormField, PaytrailFormField } from "./PaytrailFormField"; +import { explainArrayOfOrUndefined, isArrayOfOrUndefined } from "../../types/Array"; + +/** + * @see https://docs.paytrail.com/#/?id=provider + */ +export interface PaytrailProvider { + + /** + * Form target URL. Use POST as method. + */ + readonly url ?: string; + + /** + * URL to PNG version of the provider icon + */ + readonly icon: string; + + /** + * URL to SVG version of the provider icon. Using the SVG icon is preferred. + */ + readonly svg: string; + + /** + * Provider group. Provider groups allow presenting same type of providers + * in separate groups which usually makes it easier for the customer to + * select a payment method. + */ + readonly group: PaytrailPaymentMethodGroup; + + /** + * Display name of the provider. + */ + readonly name : string; + + /** + * ID of the provider + */ + readonly id : string; + + /** + * Array of form fields + * + * May be undefined for `.getMerchantsPaymentProviders()` end-point + */ + readonly parameters ?: readonly PaytrailFormField[]; + +} + +export function createPaytrailProvider ( + icon: string, + svg: string, + group: PaytrailPaymentMethodGroup, + name : string, + id : string, + url ?: string, + parameters ?: readonly PaytrailFormField[], +) : PaytrailProvider { + return { + icon, + svg, + group, + name, + id, + ...(url !== undefined ? {url} : {}), + ...(parameters ? {parameters}: {}), + }; +} + +export function isPaytrailProvider (value: unknown) : value is PaytrailProvider { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'url', + 'icon', + 'svg', + 'group', + 'name', + 'id', + 'parameters', + ]) + && isStringOrUndefined(value?.url) + && isString(value?.icon) + && isString(value?.svg) + && isPaytrailPaymentMethodGroup(value?.group) + && isString(value?.name) + && isString(value?.id) + && isArrayOfOrUndefined(value?.parameters, isPaytrailFormField) + ); +} + +export function explainPaytrailProvider (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'url', + 'icon', + 'svg', + 'group', + 'name', + 'id', + 'parameters', + ]) + , explainProperty("url", explainStringOrUndefined(value?.url)) + , explainProperty("icon", explainString(value?.icon)) + , explainProperty("svg", explainString(value?.svg)) + , explainProperty("group", explainPaytrailPaymentMethodGroup(value?.group)) + , explainProperty("name", explainString(value?.name)) + , explainProperty("id", explainString(value?.id)) + , explainProperty("parameters", explainArrayOfOrUndefined("PaytrailFormField", explainPaytrailFormField, value?.parameters, isPaytrailFormField)) + ] + ); +} + +export function parsePaytrailProvider (value: unknown) : PaytrailProvider | undefined { + if (isPaytrailProvider(value)) return value; + return undefined; +} + +export function isPaytrailProviderOrUndefined (value: unknown): value is PaytrailProvider | undefined { + return isUndefined(value) || isPaytrailProvider(value); +} + +export function explainPaytrailProviderOrUndefined (value: unknown): string { + return isPaytrailProviderOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailProvider', 'undefined'])); +} diff --git a/paytrail/types/PaytrailStatus.test.ts b/paytrail/types/PaytrailStatus.test.ts new file mode 100644 index 0000000..b076790 --- /dev/null +++ b/paytrail/types/PaytrailStatus.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainPaytrailStatus, explainPaytrailStatusOrUndefined, isPaytrailStatus, isPaytrailStatusOrUndefined, parsePaytrailStatus, PaytrailStatus, stringifyPaytrailStatus } from "./PaytrailStatus"; + +describe('PaytrailStatus functions', () => { + + const validStatuses = [ + PaytrailStatus.NEW, + PaytrailStatus.OK, + PaytrailStatus.FAIL, + PaytrailStatus.PENDING, + PaytrailStatus.DELAYED, + ]; + const validStatusTexts = [ + 'NEW', + 'OK', + 'FAIL', + 'PENDING', + 'DELAYED', + ]; + const invalidStatus = 'invalid_status'; + + describe('isPaytrailStatus', () => { + + validStatuses.forEach(status => { + it(`should return true for "${status}"`, () => { + expect(isPaytrailStatus(status)).toBe(true); + }); + }); + + it(`should return false for invalid status`, () => { + expect(isPaytrailStatus(invalidStatus)).toBe(false); + }); + + }); + + describe('explainPaytrailStatus', () => { + + validStatuses.forEach(status => { + it(`should return OK for "${status}"`, () => { + expect(explainPaytrailStatus(status)).toEqual('OK'); + }); + }); + + it(`should return an explanation for invalid status`, () => { + expect(explainPaytrailStatus(invalidStatus)).toContain('incorrect enum value "invalid_status" for PaytrailStatus: Accepted values new, ok, fail, pending, delayed'); + }); + + }); + + describe('stringifyPaytrailStatus', () => { + + validStatuses.forEach((status, index) => { + it(`should correctly stringify "${status}"`, () => { + expect(stringifyPaytrailStatus(status)).toEqual(validStatusTexts[index]); + }); + }); + + }); + + describe('parsePaytrailStatus', () => { + + validStatuses.forEach(status => { + it(`should correctly parse "${status}"`, () => { + expect(parsePaytrailStatus(status)).toEqual(status); + }); + }); + + it(`should return undefined for invalid status`, () => { + expect(parsePaytrailStatus(invalidStatus)).toBeUndefined(); + }); + + }); + + describe('isPaytrailStatusOrUndefined', () => { + + it(`should return true for undefined`, () => { + expect(isPaytrailStatusOrUndefined(undefined)).toBe(true); + }); + + validStatuses.forEach(status => { + it(`should return true for "${status}" or undefined`, () => { + expect(isPaytrailStatusOrUndefined(status)).toBe(true); + }); + }); + + it(`should return false for invalid status`, () => { + expect(isPaytrailStatusOrUndefined(invalidStatus)).toBe(false); + }); + + }); + + describe('explainPaytrailStatusOrUndefined', () => { + + it(`should return OK for undefined`, () => { + expect(explainPaytrailStatusOrUndefined(undefined)).toEqual('OK'); + }); + + validStatuses.forEach(status => { + it(`should return OK for "${status}"`, () => { + expect(explainPaytrailStatusOrUndefined(status)).toEqual('OK'); + }); + }); + + it(`should return an explanation for invalid status`, () => { + expect(explainPaytrailStatusOrUndefined(invalidStatus)).toContain('not PaytrailStatus or undefined'); + }); + + }); + +}); diff --git a/paytrail/types/PaytrailStatus.ts b/paytrail/types/PaytrailStatus.ts new file mode 100644 index 0000000..206b96d --- /dev/null +++ b/paytrail/types/PaytrailStatus.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../types/Enum"; + +/** + * @see https://docs.paytrail.com/#/?id=statuses + */ +export enum PaytrailStatus { + + /** + * Payment has been created but nothing more. Never returned as a result, + * but can be received from the GET /payments/{transactionId} endpoint + */ + NEW = "new", + + /** + * Payment was accepted by the provider and confirmed successfully + */ + OK = "ok", + + /** + * Payment was cancelled by the user or rejected by the provider + */ + FAIL = "fail", + + /** + * Payment was initially approved by the provider but further processing is + * required, used in e.g. these cases: + * + * 1. anti-fraud check is ongoing + * 2. invoice requires manual activation + * 3. Refund has been initiated but waiting for approval (only used for + * merchants which require refund approvals) + */ + PENDING = "pending", + + /** + * A rare status related to a single payment method that is not generally + * enabled. May take days to complete. If completed, will be reported as ok + * via the callback or the redirect URL. This can be handled the same way as + * pending. + */ + DELAYED = "delayed", + +} + +export function isPaytrailStatus (value: unknown) : value is PaytrailStatus { + return isEnum(PaytrailStatus, value); +} + +export function explainPaytrailStatus (value : unknown) : string { + return explainEnum("PaytrailStatus", PaytrailStatus, isPaytrailStatus, value); +} + +export function stringifyPaytrailStatus (value : PaytrailStatus) : string { + return stringifyEnum(PaytrailStatus, value); +} + +export function parsePaytrailStatus (value: any) : PaytrailStatus | undefined { + return parseEnum(PaytrailStatus, value) as PaytrailStatus | undefined; +} + +export function isPaytrailStatusOrUndefined (value: unknown): value is PaytrailStatus | undefined { + return isUndefined(value) || isPaytrailStatus(value); +} + +export function explainPaytrailStatusOrUndefined (value: unknown): string { + return isPaytrailStatusOrUndefined(value) ? explainOk() : explainNot(explainOr(['PaytrailStatus', 'undefined'])); +} diff --git a/request/ApiResponse.test.ts b/request/ApiResponse.test.ts new file mode 100644 index 0000000..ca77f2a --- /dev/null +++ b/request/ApiResponse.test.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { GetMapping } from "./GetMapping"; +import { RequestHeader } from "./RequestHeader"; +import { getOpenApiDocumentFromRequestController } from "./types/RequestController"; +import { Operation } from "./Operation"; +import { OpenAPIV3 } from "../types/openapi"; +import { ApiResponse } from "./ApiResponse"; +import { RequestStatus } from "./types/RequestStatus"; + +RequestHeader.setLogLevel(LogLevel.NONE); +ApiResponse.setLogLevel(LogLevel.NONE); +PathVariable.setLogLevel(LogLevel.NONE); + +describe('ApiResponse', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + Headers.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @GetMapping('/hello') + @ApiResponse(RequestStatus.OK, 'Successful operation') + public static getHello ( + @RequestHeader('Authorization') + authorization: string + ) : string { + return `The Authorization header is ${authorization}`; + } + + } + + // let router : RequestRouter; + // + // beforeEach( () => { + // // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + // router = RequestRouterImpl.create(Controller); + // } ); + + it('can set OpenAPI responses', async () => { + + const expected : OpenAPIV3.Document = { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello": { + "get": { + "operationId": "getHello", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful operation" + }, + } + } + } + }, + "security": [], + "tags": [] + }; + + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( expected ); + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @Operation({summary: 'Get a test response using GET'}) + @GetMapping('/hello/{param}') + @ApiResponse(RequestStatus.OK, 'Successful operation') + public static getHello ( + @PathVariable('param') + // @ts-ignore + param: string, + @RequestHeader('Authorization') + authorization: string + ) : string { + return `The Authorization header is ${authorization}`; + } + + } + + // let router : RequestRouter; + // + // beforeEach( () => { + // // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + // router = RequestRouterImpl.create(Controller); + // } ); + + it('can set OpenAPI responses', async () => { + + const expected : OpenAPIV3.Document = { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello/{param}": { + "get": { + "operationId": "getHello", + "summary": "Get a test response using GET", + "parameters": [ + { + "name": "param", + "in": "path" + }, + { + "name": "Authorization", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful operation" + }, + } + } + } + }, + "security": [], + "tags": [] + }; + + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( expected ); + }); + + }); + + describe('GET with multiple responses', () => { + + @RequestMapping('/') + class Controller { + + @Operation({summary: 'Get a test response using GET'}) + @GetMapping('/hello/{param}') + @ApiResponse(RequestStatus.OK, 'Successful operation') + @ApiResponse(RequestStatus.InternalServerError, 'If error happens') + public static getHello ( + @PathVariable('param') + // @ts-ignore + param: string, + @RequestHeader('Authorization') + authorization: string + ) : string { + return `The Authorization header is ${authorization}`; + } + + } + + // let router : RequestRouter; + // + // beforeEach( () => { + // // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + // router = RequestRouterImpl.create(Controller); + // } ); + + it('can set multiple OpenAPI responses', async () => { + + const expected : OpenAPIV3.Document = { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello/{param}": { + "get": { + "operationId": "getHello", + "summary": "Get a test response using GET", + "parameters": [ + { + "name": "param", + "in": "path" + }, + { + "name": "Authorization", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful operation" + }, + "500": { + "description": "If error happens" + }, + } + } + } + }, + "security": [], + "tags": [] + }; + + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( expected ); + }); + + + }); + + }); + + }); + + }); + +}); diff --git a/request/ApiResponse.ts b/request/ApiResponse.ts new file mode 100644 index 0000000..2628606 --- /dev/null +++ b/request/ApiResponse.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { LogLevel } from "../types/LogLevel"; +import { LogService } from "../LogService"; +import { RequestStatus } from "./types/RequestStatus"; +import { OpenAPIV3 } from "../types/openapi"; +import { getOpenApiDocumentFromRequestControllerMappingObject } from "./types/RequestControllerMappingObject"; + +const LOG = LogService.createLogger( 'ResponseStatus' ); + +export function ApiResponse ( + status : RequestStatus, + description : string, + content ?: { [media: string]: OpenAPIV3.MediaTypeObject } +) { + return ( + target: any | Function, + propertyKey ?: string, + descriptor ?: PropertyDescriptor + ) => { + const requestController = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + if ( propertyKey === undefined ) { + } else { + + RequestControllerUtils.attachControllerOperation( requestController, propertyKey, { + "responses": { + [`${status}`]: { + description, + ...( content !== undefined ? { content } : {}) + } + } + } ); + + return; + } + } + LOG.debug( "mapping: for other: config=", status, 'target=', target, 'propertyKey=', propertyKey, 'descriptor=', descriptor ); + }; +} + +ApiResponse.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); + RequestControllerUtils.setLogLevel(LogLevel.NONE); + getOpenApiDocumentFromRequestControllerMappingObject.setLogLevel(LogLevel.NONE); +}; diff --git a/request/DeleteMapping.test.ts b/request/DeleteMapping.test.ts new file mode 100644 index 0000000..13ad89e --- /dev/null +++ b/request/DeleteMapping.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { ResponseEntity } from "./types/ResponseEntity"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { DeleteMapping } from "./DeleteMapping"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('DeleteMapping', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('DELETE', () => { + + @RequestMapping('/') + class Controller { + + @DeleteMapping('/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create DELETE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.DELETE, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('DELETE', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @DeleteMapping('/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create DELETE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.DELETE, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('DELETE', () => { + + @RequestMapping('/') + class Controller { + + @DeleteMapping('/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create DELETE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.DELETE, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('DELETE', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @DeleteMapping('/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create DELETE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.DELETE, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/DeleteMapping.ts b/request/DeleteMapping.ts new file mode 100644 index 0000000..7e7dede --- /dev/null +++ b/request/DeleteMapping.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestMappingValue } from "./types/RequestMappingValue"; +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; + +export function DeleteMapping (...config: readonly RequestMappingValue[]) { + return RequestMapping( RequestMethod.DELETE, ...config ); +} \ No newline at end of file diff --git a/request/GetMapping.test.ts b/request/GetMapping.test.ts new file mode 100644 index 0000000..39e53be --- /dev/null +++ b/request/GetMapping.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { ResponseEntity } from "./types/ResponseEntity"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { GetMapping } from "./GetMapping"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('GetMapping', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @GetMapping('/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('GET', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @GetMapping('/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @GetMapping('/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('GET', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @GetMapping('/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/GetMapping.ts b/request/GetMapping.ts new file mode 100644 index 0000000..ced02ee --- /dev/null +++ b/request/GetMapping.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestMappingValue } from "./types/RequestMappingValue"; +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; + +export function GetMapping (...config: readonly RequestMappingValue[]) { + return RequestMapping( RequestMethod.GET, ...config ); +} \ No newline at end of file diff --git a/request/ModelAttribute.test.ts b/request/ModelAttribute.test.ts new file mode 100644 index 0000000..968affc --- /dev/null +++ b/request/ModelAttribute.test.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { GetMapping } from "./GetMapping"; +import { ModelAttribute } from "./ModelAttribute"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); +ModelAttribute.setLogLevel(LogLevel.NONE); + +describe('ModelAttribute', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET with synchronous callbacks', () => { + + const MODEL_NAME = 'foo'; + + @RequestMapping('/') + class Controller { + + /** + * The `param` will be populated using the `.getFoo()` + * method. + * + * @param id + * @param param + */ + @GetMapping('/hello/{id}') + public static getHello ( + @PathVariable('id') + // @ts-ignore + id: string, + @ModelAttribute(MODEL_NAME) + param: string + ) : string { + return 'Hello '+param; + } + + /** + * This method will be called once per request to build + * the response and passed on to as the argument in to + * other methods. + * + * @private + */ + @ModelAttribute(MODEL_NAME) + // @ts-ignore + private static getFoo () { + return 'world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + describe('GET with asynchronous callbacks', () => { + + const MODEL_NAME = 'foo'; + + @RequestMapping('/') + class Controller { + + /** + * The `param` will be populated using the `.getFoo()` + * method. Since the model builder returns a promise, + * that promise will be resolved before calling this + * function. + * + * @param id + * @param param + */ + @GetMapping('/hello/{id}') + public static async getHello ( + @PathVariable('id') + // @ts-ignore + id: string, + @ModelAttribute(MODEL_NAME) + param: string + ) : Promise { + return 'Hello '+param; + } + + /** + * This method will be called once per request to build + * the response and when resolved will be passed on to + * as the argument in to other methods. + * + * @private + */ + @ModelAttribute(MODEL_NAME) + // @ts-ignore + private static async getFoo () : Promise { + return 'world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/ModelAttribute.ts b/request/ModelAttribute.ts new file mode 100644 index 0000000..530778c --- /dev/null +++ b/request/ModelAttribute.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { ParameterOrMethodDecoratorFunction } from "../decorators/types/ParameterOrMethodDecoratorFunction"; +import { isString } from "../types/String"; +import { RequestController } from "./types/RequestController"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { isNumber } from "../types/Number"; +import { RequestParamValueType } from "./types/RequestParamValueType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; + +const LOG = LogService.createLogger( 'ModelAttribute' ); + +export function ModelAttribute ( + attributeName: string +): ParameterOrMethodDecoratorFunction { + LOG.debug( 'modelAttribute: ', attributeName ); + if ( !isString( attributeName ) ) { + throw new TypeError( `ModelAttribute: Argument 1 is not string: ${attributeName}` ); + } + // Return types: + // - ParameterDecoratorFunction = any | Function, string, PropertyDescriptor + // - MethodDecoratorFunction = any | Function, string, number + return ( + target : any | Function, + propertyKey ?: string, + paramIndex ?: number | TypedPropertyDescriptor + ) : void => { + if ( isString( propertyKey ) ) { + const requestController: RequestController | undefined = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + if ( isNumber( paramIndex ) ) { + RequestControllerUtils.setControllerMethodModelAttributeParam( requestController, propertyKey, paramIndex, attributeName, RequestParamValueType.JSON ); + return; + } else if ( paramIndex !== undefined ) { + RequestControllerUtils.attachControllerMethodModelAttributeBuilder( requestController, propertyKey, paramIndex, attributeName ); + return; + } + } + } + LOG.warn( 'modelAttribute: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + }; +} + +ModelAttribute.setLogLevel = (level: LogLevel) : void => { + RequestControllerUtils.setLogLevel(level); + LOG.setLogLevel(level); +}; diff --git a/request/OpenAPIDefinition.test.ts b/request/OpenAPIDefinition.test.ts new file mode 100644 index 0000000..7f49bc8 --- /dev/null +++ b/request/OpenAPIDefinition.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { ResponseEntity } from "./types/ResponseEntity"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { GetMapping } from "./GetMapping"; +import { createErrorDTO, ErrorDTO } from "../types/ErrorDTO"; +import { getOpenApiDocumentFromRequestController } from "./types/RequestController"; +import { OpenAPIV3 } from "../types/openapi"; +import { OpenAPIDefinition } from "./OpenAPIDefinition"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { getOpenApiDocumentFromRequestControllerMappingObject } from "./types/RequestControllerMappingObject"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); +RequestControllerUtils.setLogLevel(LogLevel.NONE); +getOpenApiDocumentFromRequestControllerMappingObject.setLogLevel(LogLevel.NONE); + +describe('OpenAPIDefinition', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + @OpenAPIDefinition({ + info: { + title: "Test API", + description: "HTTP REST API reference documentation test", + version: '1' + } + }) + class Controller { + + /** + * Returns OpenAPI definitions + */ + @GetMapping('/openapi') + public static async getOpenApi (): Promise> { + try { + return ResponseEntity.ok( getOpenApiDocumentFromRequestController(Controller) ); + } catch (err) { + return ResponseEntity.internalServerError().body( + createErrorDTO('Internal Server Error', 500) + ); + } + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/openapi', + undefined, + Headers.create() + ); + expect( response.getStatusCode() ).toBe(200); + expect( response.getBody() ).toStrictEqual( + { + "components": {}, + "info": { + "description": "HTTP REST API reference documentation test", + "title": "Test API", + "version": "1" + }, + "openapi": "3.0.0", + "paths": { + "/openapi": { + "get": { + } + } + }, + "security": [], + "tags": [] + } + ); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/OpenAPIDefinition.ts b/request/OpenAPIDefinition.ts new file mode 100644 index 0000000..da00600 --- /dev/null +++ b/request/OpenAPIDefinition.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { OpenAPIV3 } from "../types/openapi"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { ClassOrMethodDecoratorFunction } from "../decorators/types/ClassOrMethodDecoratorFunction"; + +const LOG = LogService.createLogger( 'OpenAPIDefinition' ); + +/** + * Define OpenAPI document definition + * @param config + */ +export function OpenAPIDefinition (config: Partial): ClassOrMethodDecoratorFunction { + return ( + target : any | Function, + propertyKey ?: string, + descriptor ?: TypedPropertyDescriptor + ) => { + const requestController = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + RequestControllerUtils.attachControllerOpenApiDocument( requestController, config ); + } else { + LOG.debug( "mapping: for other: config=", config, 'target=', target, 'propertyKey=', propertyKey, 'descriptor=', descriptor ); + } + }; +} + +OpenAPIDefinition.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/request/Operation.test.ts b/request/Operation.test.ts new file mode 100644 index 0000000..1e1f285 --- /dev/null +++ b/request/Operation.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { ResponseEntity } from "./types/ResponseEntity"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { GetMapping } from "./GetMapping"; +import { Operation } from "./Operation"; +import { createErrorDTO, ErrorDTO } from "../types/ErrorDTO"; +import { getOpenApiDocumentFromRequestController } from "./types/RequestController"; +import { OpenAPIV3 } from "../types/openapi"; +import { OpenAPIDefinition } from "./OpenAPIDefinition"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { getOpenApiDocumentFromRequestControllerMappingObject } from "./types/RequestControllerMappingObject"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); +RequestControllerUtils.setLogLevel(LogLevel.NONE); +getOpenApiDocumentFromRequestControllerMappingObject.setLogLevel(LogLevel.NONE); + +describe('Operation', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + @OpenAPIDefinition({ + info: { + title: "Test API", + description: "HTTP REST API reference documentation test", + version: '1' + } + }) + class Controller { + + @GetMapping('/hello/{param}') + @Operation({summary: 'Returns a hello string'}) + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + /** + * Returns OpenAPI definitions + */ + @Operation({summary: 'Get openAPI v3 document describing test API'}) + @GetMapping('/openapi') + public static async getOpenApi (): Promise> { + try { + return ResponseEntity.ok( getOpenApiDocumentFromRequestController(Controller) ); + } catch (err) { + return ResponseEntity.internalServerError().body( + createErrorDTO('Internal Server Error', 500) + ); + } + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/openapi', + undefined, + Headers.create() + ); + expect( response.getStatusCode() ).toBe(200); + expect( response.getBody() ).toStrictEqual( + { + "components": {}, + "info": { + "description": "HTTP REST API reference documentation test", + "title": "Test API", + "version": "1" + }, + "openapi": "3.0.0", + "paths": { + "/hello/{param}": { + "get": { + "operationId": "getHello", + "parameters": [ + { + in: "path", + name: "param" + } + ], + "summary": "Returns a hello string" + } + }, + "/openapi": { + "get": { + "operationId": "getOpenApi", + "summary": "Get openAPI v3 document describing test API" + } + } + }, + "security": [], + "tags": [] + } + ); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/Operation.ts b/request/Operation.ts new file mode 100644 index 0000000..f058827 --- /dev/null +++ b/request/Operation.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { OpenAPIV3 } from "../types/openapi"; +import { MethodDecoratorFunction } from "../decorators/types/MethodDecoratorFunction"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; + +const LOG = LogService.createLogger( 'Operation' ); + +/** + * Define OpenAPI operation + * + * @param config + */ +export function Operation (config: Partial): MethodDecoratorFunction { + return ( + target: any | Function, + propertyKey ?: string, + descriptor ?: PropertyDescriptor + ) => { + const requestController = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + if ( propertyKey === undefined ) { + RequestControllerUtils.attachControllerOperation( requestController, undefined, config ); + } else { + RequestControllerUtils.attachControllerOperation( requestController, propertyKey, config ); + } + } else { + LOG.debug( "mapping: for other: config=", config, 'target=', target, 'propertyKey=', propertyKey, 'descriptor=', descriptor ); + } + }; +} + +Operation.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/request/OptionsMapping.test.ts b/request/OptionsMapping.test.ts new file mode 100644 index 0000000..69aa7d0 --- /dev/null +++ b/request/OptionsMapping.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { ResponseEntity } from "./types/ResponseEntity"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { OptionsMapping } from "./OptionsMapping"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('OptionsMapping', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('OPTIONS', () => { + + @RequestMapping('/') + class Controller { + + @OptionsMapping('/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create OPTIONS mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.OPTIONS, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('OPTIONS', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @OptionsMapping('/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create OPTIONS mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.OPTIONS, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('OPTIONS', () => { + + @RequestMapping('/') + class Controller { + + @OptionsMapping('/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create OPTIONS mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.OPTIONS, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('OPTIONS', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @OptionsMapping('/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create OPTIONS mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.OPTIONS, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/OptionsMapping.ts b/request/OptionsMapping.ts new file mode 100644 index 0000000..1c11227 --- /dev/null +++ b/request/OptionsMapping.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestMappingValue } from "./types/RequestMappingValue"; +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; + +export function OptionsMapping (...config: readonly RequestMappingValue[]) { + return RequestMapping( RequestMethod.OPTIONS, ...config ); +} \ No newline at end of file diff --git a/request/PathVariable.test.ts b/request/PathVariable.test.ts new file mode 100644 index 0000000..5b229e8 --- /dev/null +++ b/request/PathVariable.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { GetMapping } from "./GetMapping"; +import { Operation } from "./Operation"; +import { getOpenApiDocumentFromRequestController } from "./types/RequestController"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('PathVariable', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @GetMapping('/hello/{param}') + @Operation({summary: 'Get a test response using GET'}) + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + it('can set OpenAPI parameters information', async () => { + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( + { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello/{param}": { + "get": { + "operationId": "getHello", + "summary": "Get a test response using GET", + "parameters": [ + { + "name": "param", + "in": "path" + } + ] + } + } + }, + "security": [], + "tags": [] + } + ); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/PathVariable.ts b/request/PathVariable.ts new file mode 100644 index 0000000..ded1159 --- /dev/null +++ b/request/PathVariable.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestPathVariableListOptions } from "./types/RequestPathVariableListOptions"; +import { isRequestPathVariableOptionsOrUndefined, RequestPathVariableOptions } from "./types/RequestPathVariableOptions"; +import { ParameterDecoratorFunction } from "../decorators/types/ParameterDecoratorFunction"; +import { DefaultPathVariableMapValuesType } from "./types/DefaultPathVariableMapValuesType"; +import { RequestController } from "./types/RequestController"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { isString } from "../types/String"; +import { isNumber } from "../types/Number"; +import { isBoolean } from "../types/Boolean"; +import { isObject } from "../types/Object"; +import { RequestParamValueType } from "./types/RequestParamValueType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { getOpenApiDocumentFromRequestControllerMappingObject } from "./types/RequestControllerMappingObject"; + +const LOG = LogService.createLogger( 'PathVariable' ); + +export function PathVariable ( + opts ?: RequestPathVariableListOptions +) : ParameterDecoratorFunction; + +export function PathVariable ( + variableName : string, + opts ?: RequestPathVariableOptions +) : ParameterDecoratorFunction; + +export function PathVariable ( + target : any | Function, + propertyKey : string, + paramIndex : number +) : void; + +export function PathVariable ( + arg1 ?: string | RequestPathVariableListOptions | any | Function, + arg2 ?: string | RequestPathVariableOptions | boolean | undefined, + arg3 ?: number +): void | ParameterDecoratorFunction { + + function _setPathVariableMap ( + target: any | Function, + propertyKey: string, + paramIndex: number, + defaultValues: DefaultPathVariableMapValuesType | undefined + ) { + const requestController: RequestController | undefined = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + RequestControllerUtils.setControllerMethodPathVariableMap( requestController, propertyKey, paramIndex, defaultValues ); + + return; + } + LOG.warn( '_setPathVariableMap: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + } + + // LOG.debug( 'pathVariable: ', arg1, arg2, arg3 ); + + if ( isString( arg2 ) && isNumber( arg3 ) ) { + const target: any | Function = arg1; + const propertyKey: string = arg2; + const paramIndex: number = arg3; + _setPathVariableMap( target, propertyKey, paramIndex, undefined ); + return; + } + + const variableName: string | RequestPathVariableListOptions | undefined = arg1; + + if ( isString( variableName ) ) { + if ( !isRequestPathVariableOptionsOrUndefined( arg2 ) ) { + throw new TypeError( `PathVariable: Argument 2 is not type of RequestPathVariableOptions: ${arg2}` ); + } + const headerNameOpts: RequestPathVariableOptions | undefined = arg2; + let isRequired: boolean | undefined = undefined; + let defaultValue: string | undefined = undefined; + let decodeValue: boolean = true; + if ( headerNameOpts === undefined ) { + } else if ( isBoolean( headerNameOpts ) ) { + isRequired = headerNameOpts; + } else if ( isObject( headerNameOpts ) ) { + isRequired = headerNameOpts?.required ?? undefined; + defaultValue = headerNameOpts?.defaultValue ?? undefined; + decodeValue = headerNameOpts?.decodeValue ?? true; + } else { + throw new TypeError( 'PathVariable: Invalid type of options' ); + } + // LOG.debug( 'pathVariable: init: ', variableName ); + return ( + target: any | Function, + propertyKey ?: string, + paramIndex ?: number + ) => { + if ( isString( propertyKey ) && isNumber( paramIndex ) ) { + const requestController: RequestController | undefined = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + RequestControllerUtils.setControllerMethodPathVariable( requestController, propertyKey, paramIndex, variableName, RequestParamValueType.STRING, isRequired, decodeValue, defaultValue ); + + RequestControllerUtils.attachControllerOperation( requestController, propertyKey, { + parameters: [{ + name: variableName, + in: 'path' + }] + } ); + + return; + } + } + LOG.warn( 'pathVariable: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + }; + } + + let opts: RequestPathVariableListOptions | undefined = variableName; + if ( opts === undefined || isObject( opts?.defaultValues ) ) { + } else { + throw new TypeError( 'PathVariable: Invalid type of options' ); + } + const defaultValues: DefaultPathVariableMapValuesType | undefined = opts ? opts?.defaultValues ?? undefined : undefined; + return ( + target: any | Function, + propertyKey ?: string, + paramIndex ?: number + ) => { + + if ( isString( propertyKey ) && isNumber( paramIndex ) ) { + + _setPathVariableMap( target, propertyKey, paramIndex, defaultValues ); + + } else { + LOG.warn( 'pathVariable: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + } + + }; +} + +PathVariable.setLogLevel = (level: LogLevel) : void => { + RequestControllerUtils.setLogLevel(level); + getOpenApiDocumentFromRequestControllerMappingObject.setLogLevel(level); + LOG.setLogLevel(level); +}; diff --git a/request/PostMapping.test.ts b/request/PostMapping.test.ts new file mode 100644 index 0000000..3874b7b --- /dev/null +++ b/request/PostMapping.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { ResponseEntity } from "./types/ResponseEntity"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { PostMapping } from "./PostMapping"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('PostMapping', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('POST', () => { + + @RequestMapping('/') + class Controller { + + @PostMapping('/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create POST mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.POST, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('POST', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @PostMapping('/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create POST mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.POST, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('POST', () => { + + @RequestMapping('/') + class Controller { + + @PostMapping('/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create POST mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.POST, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('POST', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @PostMapping('/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create POST mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.POST, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/PostMapping.ts b/request/PostMapping.ts new file mode 100644 index 0000000..db25c64 --- /dev/null +++ b/request/PostMapping.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestMappingValue } from "./types/RequestMappingValue"; +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; + +export function PostMapping (...config: readonly RequestMappingValue[]) { + return RequestMapping( RequestMethod.POST, ...config ); +} \ No newline at end of file diff --git a/request/PutMapping.test.ts b/request/PutMapping.test.ts new file mode 100644 index 0000000..0f27d99 --- /dev/null +++ b/request/PutMapping.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { ResponseEntity } from "./types/ResponseEntity"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { PutMapping } from "./PutMapping"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('PutMapping', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('PUT', () => { + + @RequestMapping('/') + class Controller { + + @PutMapping('/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PUT mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PUT, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('PUT', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @PutMapping('/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PUT mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PUT, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('PUT', () => { + + @RequestMapping('/') + class Controller { + + @PutMapping('/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PUT mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PUT, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('PUT', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @PutMapping('/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PUT mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PUT, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/PutMapping.ts b/request/PutMapping.ts new file mode 100644 index 0000000..45731ee --- /dev/null +++ b/request/PutMapping.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestMappingValue } from "./types/RequestMappingValue"; +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; + +export function PutMapping (...config: readonly RequestMappingValue[]) { + return RequestMapping( RequestMethod.PUT, ...config ); +} \ No newline at end of file diff --git a/request/README.md b/request/README.md new file mode 100644 index 0000000..37c9c79 --- /dev/null +++ b/request/README.md @@ -0,0 +1,37 @@ + +Moved import paths: + + * Moved annotations as: `import { NAME } from "./fi/hg/core/Request"` -> `./fi/hg/core/request/NAME.ts` + * Moved utils under `./fi/hg/core/request/utils/NAME.ts` + * Moved types under `./fi/hg/core/request/types/NAME.ts` + +Renamed methods: + + * `new RequestRouter()` -> `RequestRouterImpl.create()` + * `RequestRouter.` -> `RequestRouterImpl.` + * `Request.Method` -> `RequestMethod` + * `Request.Status` -> `RequestStatus` + * `Request.ParamType` -> `RequestParamType` + * `Request.Type` -> `RequestType` + * `Request.Error` -> `RequestError` + * `Request.createBadRequestError` -> `RequestError.createBadRequestError` + * `Request.createNotFoundRequestError` -> `RequestError.createNotFoundRequestError` + * `Request.createMethodNotAllowedRequestError` -> `RequestError.createMethodNotAllowedRequestError` + * `Request.createConflictRequestError` -> `RequestError.createConflictRequestError` + * `Request.createInternalErrorRequestError` -> `RequestError.createInternalErrorRequestError` + * `Request.throwBadRequestError` -> `RequestError.throwBadRequestError` + * `Request.throwNotFoundRequestError` -> `RequestError.throwNotFoundRequestError` + * `Request.throwMethodNotAllowedRequestError` -> `RequestError.throwMethodNotAllowedRequestError` + * `Request.throwConflictRequestError` -> `RequestError.throwConflictRequestError` + * `Request.throwInternalErrorRequestError` -> `RequestError.throwInternalErrorRequestError` + * `Request.Header` -> `RequestHeader` + * `Request.PathVariable` -> `PathVariable` + * `Request.ModelAttribute` -> `ModelAttribute` + * `Request.OptionsMapping` -> `OptionsMapping` + * `Request.Get` -> `GetMapping` + * `Request.Post` -> `PostMapping` + * `Request.Put` -> `PutMapping` + * `Request.Delete` -> `DeleteMapping` + * `Request.Body` -> `RequestBody` + * `Request.Operation` -> `Operation` + * `Request.OpenAPIDefinition` -> `OpenAPIDefinition` diff --git a/request/RequestBody.test.ts b/request/RequestBody.test.ts new file mode 100644 index 0000000..7bd4a42 --- /dev/null +++ b/request/RequestBody.test.ts @@ -0,0 +1,248 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { GetMapping } from "./GetMapping"; +import { RequestBody } from "./RequestBody"; +import type { ReadonlyJsonAny } from "../Json"; +import { OpenAPIV3 } from "../types/openapi"; +import { getOpenApiDocumentFromRequestController } from "./types/RequestController"; +import { Operation } from "./Operation"; +import { ApiResponse } from "./ApiResponse"; +import { RequestStatus } from "./types/RequestStatus"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('RequestBody', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @Operation({summary: 'Get a test response using GET'}) + @GetMapping('/hello') + @ApiResponse(RequestStatus.OK, 'Successful operation') + public static getHello ( + @RequestBody + body: ReadonlyJsonAny + ) : string { + return 'Hello '+JSON.stringify(body); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can handle GET request with body with string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello', + () => ({ + hello: 'world' + }), + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello {"hello":"world"}'); + }); + + it('can set OpenAPI parameters information', async () => { + + const expected : OpenAPIV3.Document = { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello": { + "get": { + "operationId": "getHello", + "summary": "Get a test response using GET", + "responses": { + "200": { + "description": "Successful operation" + }, + }, + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object" + } + } + } + } + } + } + }, + "security": [], + "tags": [] + }; + + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( expected ); + }); + + }); + + describe('GET with manual examples', () => { + + @RequestMapping('/') + class Controller { + + @Operation({ + summary: 'Get a test response using GET', + requestBody: { + description: 'Sample body', + required: true, + content: { + "application/json": { + examples: { + "sample1": { + summary: "Example 1", + value: { + name: "John", + gender: "MALE", + age: 18 + } + }, + "sample2": { + summary: "Example 2", + value: { + name: "Lisa", + gender: "FEMALE", + age: 30 + } + } + } + } + } + } + }) + @GetMapping('/hello') + @ApiResponse(RequestStatus.OK, 'Successful operation') + public static getHello ( + @RequestBody + body: ReadonlyJsonAny + ) : string { + return 'Hello '+JSON.stringify(body); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can handle GET request with body with string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello', + () => ({ + hello: 'world' + }), + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello {"hello":"world"}'); + }); + + it('can set OpenAPI parameters information', async () => { + + const expected : OpenAPIV3.Document = { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello": { + "get": { + "operationId": "getHello", + "summary": "Get a test response using GET", + "responses": { + "200": { + "description": "Successful operation" + }, + }, + requestBody: { + description: 'Sample body', + required: true, + content: { + "application/json": { + schema: { + type: "object" + }, + examples: { + "sample1": { + summary: "Example 1", + value: { + name: "John", + gender: "MALE", + age: 18 + } + }, + "sample2": { + summary: "Example 2", + value: { + name: "Lisa", + gender: "FEMALE", + age: 30 + } + } + } + } + } + } + } + } + }, + "security": [], + "tags": [] + }; + + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( expected ); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/RequestBody.ts b/request/RequestBody.ts new file mode 100644 index 0000000..ace6189 --- /dev/null +++ b/request/RequestBody.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestController } from "./types/RequestController"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { isString } from "../types/String"; +import { isNumber } from "../types/Number"; +import { getOpenApiTypeStringFromRequestParamValueType, RequestParamValueType } from "./types/RequestParamValueType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; + +const LOG = LogService.createLogger( 'RequestBody' ); + +export function RequestBody ( + target: any | Function, + propertyKey ?: string, + paramIndex ?: number +): void { + const requestController: RequestController | undefined = RequestControllerUtils.findController( target ); + if ( requestController !== undefined && isString( propertyKey ) && isNumber( paramIndex ) ) { + + RequestControllerUtils.setControllerMethodBodyParam( requestController, propertyKey, paramIndex, RequestParamValueType.JSON ); + + RequestControllerUtils.attachControllerOperation( requestController, propertyKey, { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: getOpenApiTypeStringFromRequestParamValueType( RequestParamValueType.JSON ) + } + } + } + } + } ); + + } else { + LOG.warn( 'body: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + } +} + +RequestBody.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/request/RequestHeader.test.ts b/request/RequestHeader.test.ts new file mode 100644 index 0000000..f290679 --- /dev/null +++ b/request/RequestHeader.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { GetMapping } from "./GetMapping"; +import { RequestHeader } from "./RequestHeader"; +import { getOpenApiDocumentFromRequestController } from "./types/RequestController"; +import { Operation } from "./Operation"; +import { OpenAPIV3 } from "../types/openapi"; +import { ApiResponse } from "./ApiResponse"; +import { RequestStatus } from "./types/RequestStatus"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); +RequestHeader.setLogLevel(LogLevel.NONE); + +describe('RequestHeader', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + Headers.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @GetMapping('/hello') + public static getHello ( + @RequestHeader('Authorization') + authorization: string + ) : string { + return `The Authorization header is ${authorization}`; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello', + undefined, + Headers.create({ + 'Authorization': '1234' + }) + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('The Authorization header is 1234'); + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @Operation({summary: 'Get a test response using GET'}) + @GetMapping('/hello/{param}') + @ApiResponse(RequestStatus.OK, 'Successful operation') + public static getHello ( + @PathVariable('param') + // @ts-ignore @TODO: Why not used? + param: string, + @RequestHeader('Authorization') + authorization: string + ) : string { + return `The Authorization header is ${authorization}`; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello/something', + undefined, + Headers.create({ + 'Authorization': '1234' + }) + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('The Authorization header is 1234'); + }); + + it('can set OpenAPI parameters information', async () => { + + const expected : OpenAPIV3.Document = { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello/{param}": { + "get": { + "operationId": "getHello", + "summary": "Get a test response using GET", + "parameters": [ + { + "name": "param", + "in": "path" + }, + { + "name": "Authorization", + "in": "header", + "schema": { + "type": "string" + } + }, + ], + "responses": { + "200": { + "description": "Successful operation" + }, + } + } + } + }, + "security": [], + "tags": [] + }; + + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( expected ); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/RequestHeader.ts b/request/RequestHeader.ts new file mode 100644 index 0000000..c17fe82 --- /dev/null +++ b/request/RequestHeader.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { isRequestHeaderListOptions, RequestHeaderListOptions } from "./types/RequestHeaderListOptions"; +import { isRequestHeaderOptionsOrUndefined, RequestHeaderOptions } from "./types/RequestHeaderOptions"; +import { ParameterDecoratorFunction } from "../decorators/types/ParameterDecoratorFunction"; +import { DefaultHeaderMapValuesType } from "./types/DefaultHeaderMapValuesType"; +import { RequestController } from "./types/RequestController"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { isString } from "../types/String"; +import { isNumber } from "../types/Number"; +import { isBoolean } from "../types/Boolean"; +import { isObject } from "../types/Object"; +import { RequestParamValueType } from "./types/RequestParamValueType"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; + +const LOG = LogService.createLogger( 'RequestHeader' ); + +export function RequestHeader ( + opts ?: RequestHeaderListOptions +) : ParameterDecoratorFunction; + +export function RequestHeader ( + headerName : string, + opts ?: RequestHeaderOptions +) : ParameterDecoratorFunction; + +export function RequestHeader ( + target : any | Function, + propertyKey : string, + paramIndex : number +) : void; + +export function RequestHeader ( + arg1 ?: string | RequestHeaderListOptions | Function | any, + arg2 ?: string | RequestHeaderOptions | boolean | undefined, + arg3 ?: number +): ParameterDecoratorFunction | void { + + /** + * Private helper + * + * @param target + * @param propertyKey + * @param paramIndex + * @param defaultValues + * @private + */ + function _setMethodHeaderMap ( + target: any, + propertyKey: string, + paramIndex: number, + defaultValues: DefaultHeaderMapValuesType | undefined + ) { + const requestController: RequestController | undefined = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + RequestControllerUtils.setControllerMethodHeaderMap( requestController, propertyKey, paramIndex, defaultValues ); + } else { + LOG.warn( '_setMethodHeaderMap: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + } + } + + // LOG.debug( 'RequestHeader: ', arg1, arg2, arg3 ); + if ( isString( arg2 ) && isNumber( arg3 ) ) { + _setMethodHeaderMap( arg1, arg2, arg3, undefined ); + return; + } + if ( isString( arg1 ) ) { + const headerName: string = arg1; + if ( !isRequestHeaderOptionsOrUndefined( arg2 ) ) { + throw new TypeError( `RequestHeader: Argument 2 is not type of RequestHeaderOptions: ${arg2}` ); + } + const headerNameOpts: RequestHeaderOptions | undefined = arg2; + let isRequired: boolean | undefined = undefined; + let defaultValue: string | undefined = undefined; + if ( headerNameOpts === undefined ) { + } else if ( isBoolean( headerNameOpts ) ) { + isRequired = headerNameOpts; + } else if ( isObject( headerNameOpts ) ) { + isRequired = headerNameOpts?.required ?? undefined; + defaultValue = headerNameOpts?.defaultValue ?? undefined; + } else { + throw new TypeError( 'RequestHeader: Invalid type of options' ); + } + // LOG.debug( 'header: init: ', headerName ); + return ( + target: any | Function, + propertyKey ?: string, + paramIndex ?: number + ) => { + if ( isString( propertyKey ) && isNumber( paramIndex ) ) { + const requestController: RequestController | undefined = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + + RequestControllerUtils.setControllerMethodHeader( requestController, propertyKey, paramIndex, headerName, RequestParamValueType.STRING, isRequired, defaultValue ); + + RequestControllerUtils.attachControllerOperation( requestController, propertyKey, { + parameters: [{ + "name": headerName, + "in": "header", + schema: { + type: "string" + } + }] + } ); + + return; + } + } + LOG.warn( 'header: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + }; + } + let opts: RequestHeaderListOptions | undefined = arg1; + if ( !(opts === undefined || isRequestHeaderListOptions( opts )) ) { + throw new TypeError( 'RequestHeader: Invalid type of options' ); + } + const defaultValues: DefaultHeaderMapValuesType | undefined = opts?.defaultValues; + return ( + target: any | Function, + propertyKey ?: string, + paramIndex ?: number + ) => { + if ( isString( propertyKey ) && isNumber( paramIndex ) ) { + _setMethodHeaderMap( target, propertyKey, paramIndex, defaultValues ); + } else { + LOG.warn( 'header: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + } + }; + +} + +RequestHeader.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/request/RequestMapping.test.ts b/request/RequestMapping.test.ts new file mode 100644 index 0000000..6e4344a --- /dev/null +++ b/request/RequestMapping.test.ts @@ -0,0 +1,1232 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { ResponseEntity } from "./types/ResponseEntity"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('RequestMapping', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.GET, '/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + describe('POST', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.POST, '/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create POST mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.POST, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + describe('PUT', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.PUT, '/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PUT mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PUT, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + describe('DELETE', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.DELETE, '/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create DELETE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.DELETE, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + describe('PATCH', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.PATCH, '/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PATCH mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PATCH, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + describe('TRACE', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.TRACE, '/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create TRACE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.TRACE, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + describe('HEAD', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.HEAD, '/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create HEAD mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.HEAD, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + describe('OPTIONS', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.OPTIONS, '/hello') + public static getHello () : string { + return 'Hello world'; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create OPTIONS mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.OPTIONS, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello world'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('GET', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.GET, '/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('POST', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.POST, '/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create POST mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.POST, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('PUT', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.PUT, '/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PUT mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PUT, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('DELETE', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.DELETE, '/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create DELETE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.DELETE, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('PATCH', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.PATCH, '/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PATCH mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PATCH, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('TRACE', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.TRACE, '/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create TRACE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.TRACE, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('HEAD', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.HEAD, '/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create HEAD mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.HEAD, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('OPTIONS', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.OPTIONS, '/hello') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create OPTIONS mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.OPTIONS, + '/hello', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.GET, '/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + describe('POST', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.POST, '/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create POST mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.POST, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + describe('PUT', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.PUT, '/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PUT mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PUT, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + describe('DELETE', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.DELETE, '/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create DELETE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.DELETE, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + describe('PATCH', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.PATCH, '/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PATCH mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PATCH, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + describe('TRACE', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.TRACE, '/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create TRACE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.TRACE, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + describe('HEAD', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.HEAD, '/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create HEAD mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.HEAD, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + describe('OPTIONS', () => { + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.OPTIONS, '/hello/{param}') + public static getHello ( + @PathVariable('param') + param: string + ) : string { + return 'Hello '+param; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create OPTIONS mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.OPTIONS, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + }); + + }); + + describe('Entity responses', () => { + + describe('GET', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.GET, '/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('POST', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.POST, '/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create POST mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.POST, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('PUT', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.PUT, '/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PUT mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PUT, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('DELETE', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.DELETE, '/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create DELETE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.DELETE, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('PATCH', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.PATCH, '/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create PATCH mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.PATCH, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('TRACE', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.TRACE, '/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create TRACE mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.TRACE, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('HEAD', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.HEAD, '/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create HEAD mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.HEAD, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + describe('OPTIONS', () => { + + interface HelloDTO { + readonly hello : string; + } + + @RequestMapping('/') + class Controller { + + @RequestMapping(RequestMethod.OPTIONS, '/hello/{param}') + public static getHello () : ResponseEntity { + return ResponseEntity.ok({ + hello: 'world' + }); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create OPTIONS mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.OPTIONS, + '/hello/something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toStrictEqual({ + hello: 'world' + }); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/RequestMapping.ts b/request/RequestMapping.ts new file mode 100644 index 0000000..567b248 --- /dev/null +++ b/request/RequestMapping.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { RequestMappingValue } from "./types/RequestMappingValue"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; +import { ClassOrMethodDecoratorFunction } from "../decorators/types/ClassOrMethodDecoratorFunction"; + +const LOG = LogService.createLogger( 'RequestMapping' ); + +export function RequestMapping ( + ...config: readonly RequestMappingValue[] +): ClassOrMethodDecoratorFunction { + return ( + target : any | Function, + propertyKey ?: string, + descriptor ?: TypedPropertyDescriptor + ) : void => { + const requestController = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + if ( propertyKey === undefined ) { + RequestControllerUtils.attachControllerMapping( requestController, config ); + } else { + RequestControllerUtils.attachControllerMethodMapping( requestController, config, propertyKey ); + } + } else { + LOG.debug( "mapping: for other: config=", config, 'target=', target, 'propertyKey=', propertyKey, 'descriptor=', descriptor ); + } + }; +} + +RequestMapping.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/request/RequestParam.test.ts b/request/RequestParam.test.ts new file mode 100644 index 0000000..b1835da --- /dev/null +++ b/request/RequestParam.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { GetMapping } from "./GetMapping"; +import { RequestParam } from "./RequestParam"; +import { OpenAPIV3 } from "../types/openapi"; +import { getOpenApiDocumentFromRequestController } from "./types/RequestController"; +import { Operation } from "./Operation"; +import { ApiResponse } from "./ApiResponse"; +import { RequestStatus } from "./types/RequestStatus"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('RequestParam', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @Operation({summary: 'Get a test response using GET'}) + @GetMapping('/hello') + @ApiResponse(RequestStatus.OK, 'Successful operation') + public static getHello ( + @RequestParam('q') + q: string + ) : string { + return 'Hello '+q; + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello?q=something', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello something'); + }); + + it('can set OpenAPI parameters information', async () => { + + const expected : OpenAPIV3.Document = { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello": { + "get": { + "operationId": "getHello", + "summary": "Get a test response using GET", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + ], + "responses": { + "200": { + "description": "Successful operation" + }, + } + } + } + }, + "security": [], + "tags": [] + }; + + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( expected ); + }); + + }); + + }); + + describe('All parameters', () => { + + describe('GET', () => { + + @RequestMapping('/') + class Controller { + + @Operation({summary: 'Get a test response using GET'}) + @GetMapping('/hello') + @ApiResponse(RequestStatus.OK, 'Successful operation') + public static getHello ( + @RequestParam() + params: {[key: string]: string} + ) : string { + return 'Hello '+JSON.stringify(params); + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + } ); + + it('can create GET mapping for string response', async () => { + const response = await router.handleRequest( + RequestMethod.GET, + '/hello?q=something&other=bar', + undefined, + Headers.create() + ); + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello {"q":"something","other":"bar"}'); + }); + + it('can set OpenAPI parameters information', async () => { + + const expected: OpenAPIV3.Document = { + "components": {}, + "info": { + "title": "API Reference", + "version": "0.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/hello": { + "get": { + "operationId": "getHello", + "parameters": [ { + "in": "query", + "name": undefined, + "schema": { + "type": "object" + } + } + ], + "summary": "Get a test response using GET", + "responses": { + "200": { + "description": "Successful operation" + } + } + } + } + }, + "security": [], + "tags": [] + }; + + expect( getOpenApiDocumentFromRequestController(Controller) ).toStrictEqual( expected ); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/RequestParam.ts b/request/RequestParam.ts new file mode 100644 index 0000000..d9d5298 --- /dev/null +++ b/request/RequestParam.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2022-2023 Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor . All rights reserved. + +import { getOpenApiTypeStringFromRequestParamValueType, isRequestParamValueTypeOrUndefined, RequestParamValueType } from "./types/RequestParamValueType"; +import { ParameterDecoratorFunction } from "../decorators/types/ParameterDecoratorFunction"; +import { RequestController } from "./types/RequestController"; +import { isString } from "../types/String"; +import { isNumber } from "../types/Number"; +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { LogService } from "../LogService"; +import { LogLevel } from "../types/LogLevel"; + +const LOG = LogService.createLogger( 'RequestParam' ); + +export function RequestParam () : ParameterDecoratorFunction; + +export function RequestParam ( + queryParam : string, + paramType ?: RequestParamValueType +) : ParameterDecoratorFunction; + +export function RequestParam ( + target : any | Function, + propertyKey ?: string, + paramIndex ?: number +) : void; + +// RequestHeader overloads & implementation + +export function RequestParam ( + arg1 ?: any | Function | string, + arg2 ?: string | RequestParamValueType | undefined, + arg3 ?: number +): ParameterDecoratorFunction | void { + + function _getRequestController ( + target: any, + propertyKey: any, + paramIndex: any + ): RequestController | undefined { + if ( isString( propertyKey ) && isNumber( paramIndex ) ) { + return RequestControllerUtils.findController( target ); + } else { + return undefined; + } + } + + function _param ( + target: any, + propertyKey: any, + paramIndex: any, + queryParam: string | undefined, + paramType: RequestParamValueType + ) { + const requestController = _getRequestController( target, propertyKey, paramIndex ); + if ( requestController !== undefined ) { + + RequestControllerUtils.setControllerMethodQueryParam( requestController, propertyKey, paramIndex, queryParam, paramType ); + + RequestControllerUtils.attachControllerOperation( requestController, propertyKey, { + parameters: [ { + "name": queryParam, + "in": "query", + schema: { + type: getOpenApiTypeStringFromRequestParamValueType( paramType ) + } + } ] + } ); + + } else { + LOG.warn( + '_param: Unrecognized configuration: ', + "; target=", target, + "; propertyKey=", propertyKey, + "; paramIndex=", paramIndex + ); + } + } + + if ( arg1 === undefined && arg2 === undefined && arg3 === undefined ) { + const queryParam : string | undefined = undefined; + const paramType = RequestParamValueType.REGULAR_OBJECT; + return ( + target: any | Function, + propertyKey ?: string, + paramIndex ?: number + ) => { + _param( target, propertyKey, paramIndex, queryParam, paramType ); + }; + } + + if ( isString( arg1 ) && (arg3 === undefined) && isRequestParamValueTypeOrUndefined( arg2 ) ) { + const queryParam = arg1; + const paramType: RequestParamValueType = arg2 ?? RequestParamValueType.STRING; + return ( + target: any | Function, + propertyKey ?: string, + paramIndex ?: number + ) => { + _param( target, propertyKey, paramIndex, queryParam, paramType ); + }; + } else { + const target = arg1; + const propertyKey = arg2; + const paramIndex = arg3; + const paramType = RequestParamValueType.STRING; + // FIXME: We cannot get the name of the query parameter yet, so this will break later! + const queryParam = `${paramIndex}`; + _param( target, propertyKey, paramIndex, queryParam, paramType ); + } + +} + +RequestParam.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/request/SynchronizedRequest.test.ts b/request/SynchronizedRequest.test.ts new file mode 100644 index 0000000..04c5634 --- /dev/null +++ b/request/SynchronizedRequest.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { RequestMapping } from "./RequestMapping"; +import { RequestMethod } from "./types/RequestMethod"; +import { RequestRouterImpl } from "../requestServer/RequestRouterImpl"; +import { Headers } from "./types/Headers"; +import { RequestRouter } from "../requestServer/RequestRouter"; +import { LogLevel } from "../types/LogLevel"; +import { StaticRoutes } from "../requestServer/types/StaticRoutes"; +import { PathVariable } from "./PathVariable"; +import { ParamRoutes } from "../requestServer/types/ParamRoutes"; +import { PutMapping } from "./PutMapping"; +import { SynchronizedRequest } from "./SynchronizedRequest"; +import { RequestParam } from "./RequestParam"; +import { AsyncSynchronizerImpl } from "../AsyncSynchronizerImpl"; + +PathVariable.setLogLevel(LogLevel.NONE); +ParamRoutes.setLogLevel(LogLevel.NONE); + +describe('SynchronizedRequest', () => { + + beforeAll( () => { + RequestRouterImpl.setLogLevel(LogLevel.NONE); + StaticRoutes.setLogLevel(LogLevel.NONE); + AsyncSynchronizerImpl.setLogLevel(LogLevel.NONE); + }); + + describe('Static controllers', () => { + + // Our internal router will use statoc routing option when there is no + // dynamic variables in the path + describe('Static routes', () => { + + describe('String responses', () => { + + describe('PUT', () => { + + @RequestMapping('/') + class Controller { + + private static _resolve : any[] = []; + private static _releasePromise : Promise | undefined; + private static _releaseLock : (() => void) | undefined; + + @PutMapping('/hello') + @SynchronizedRequest() + public static async getHello ( + @RequestParam('q') + arg ?: string + ) : Promise { + return await new Promise((resolve) => { + this._resolve.push( () => { + resolve(arg); + } ); + }); + } + + public static resolveHelloRequest () { + const one = this._resolve.shift(); + + if ( !this._resolve.length && this._releaseLock ) { + this._releaseLock(); + this._releaseLock = undefined; + } + + if (!one) throw new TypeError('No requests to resolve'); + one(); + + } + + /** Drain promises until controller has resolvers + */ + public static async drainUntilResolversExist () : Promise { + while (this._resolve.length === 0) { + + if (this._releasePromise !== undefined) { + let releaseInstantly : boolean = false; + if (this._releaseLock !== undefined) { + this._releaseLock(); + } + this._releaseLock = () => { + releaseInstantly = true; + }; + this._releasePromise = new Promise((resolve) => { + if (releaseInstantly) { + resolve(); + this._releaseLock = undefined; + } else { + this._releaseLock = () => { + resolve(); + }; + } + }); + } + + await this._releasePromise; + + } + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + jest.spyOn(Controller, 'getHello'); + } ); + + it('can create PUT mapping for string response', async () => { + + const responsePromise = router.handleRequest( + RequestMethod.PUT, + '/hello?q=hello', + undefined, + Headers.create() + ); + + const responsePromise2 = router.handleRequest( + RequestMethod.PUT, + '/hello?q=world', + undefined, + Headers.create() + ); + + await Controller.drainUntilResolversExist(); + expect(Controller.getHello).toHaveBeenCalledTimes(1); + + Controller.resolveHelloRequest(); + await Controller.drainUntilResolversExist(); + + Controller.resolveHelloRequest(); + + expect(Controller.getHello).toHaveBeenCalledTimes(2); + + const response = await responsePromise; + + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('hello'); + + const response2 = await responsePromise2; + + expect(response2.getStatusCode()).toBe(200); + expect(response2.getBody()).toBe('world'); + + }); + + }); + + }); + + }); + + // Our internal router will use different routing option when there is a + // dynamic variable in the path + describe('Dynamic routes', () => { + + describe('String responses', () => { + + describe('PUT', () => { + + @RequestMapping('/') + class Controller { + + private static _resolve : any[] = []; + private static _releasePromise : Promise | undefined; + private static _releaseLock : (() => void) | undefined; + + @PutMapping('/hello/{param}') + @SynchronizedRequest() + public static async getHello ( + @PathVariable('param') + param: string + ) : Promise { + return new Promise((resolve) => { + this._resolve.push( () => { + resolve('Hello '+param); + } ); + }); + } + + public static resolveHelloRequest () { + const one = this._resolve.shift(); + + if ( !this._resolve.length && this._releaseLock ) { + this._releaseLock(); + this._releaseLock = undefined; + } + + if (!one) throw new TypeError('No requests to resolve'); + one(); + + } + + /** Drain promises until controller has resolvers + */ + public static async drainUntilResolversExist () : Promise { + while (this._resolve.length === 0) { + + if (this._releasePromise !== undefined) { + let releaseInstantly : boolean = false; + if (this._releaseLock !== undefined) { + this._releaseLock(); + } + this._releaseLock = () => { + releaseInstantly = true; + }; + this._releasePromise = new Promise((resolve) => { + if (releaseInstantly) { + resolve(); + this._releaseLock = undefined; + } else { + this._releaseLock = () => { + resolve(); + }; + } + }); + } + + await this._releasePromise; + + } + } + + } + + let router : RequestRouter; + + beforeEach( () => { + // RequestRouterImpl.setLogLevel(LogLevel.DEBUG); + router = RequestRouterImpl.create(Controller); + jest.spyOn(Controller, 'getHello'); + } ); + + it('can create PUT mapping for string response', async () => { + + const responsePromise = router.handleRequest( + RequestMethod.PUT, + '/hello/hello', + undefined, + Headers.create() + ); + + const responsePromise2 = router.handleRequest( + RequestMethod.PUT, + '/hello/world', + undefined, + Headers.create() + ); + + await Controller.drainUntilResolversExist(); + expect(Controller.getHello).toHaveBeenCalledTimes(1); + + Controller.resolveHelloRequest(); + await Controller.drainUntilResolversExist(); + + Controller.resolveHelloRequest(); + + expect(Controller.getHello).toHaveBeenCalledTimes(2); + + const response = await responsePromise; + + expect(response.getStatusCode()).toBe(200); + expect(response.getBody()).toBe('Hello hello'); + + const response2 = await responsePromise2; + + expect(response2.getStatusCode()).toBe(200); + expect(response2.getBody()).toBe('Hello world'); + + }); + + }); + + }); + + }); + + }); + +}); diff --git a/request/SynchronizedRequest.ts b/request/SynchronizedRequest.ts new file mode 100644 index 0000000..ef79dd7 --- /dev/null +++ b/request/SynchronizedRequest.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2023 Heusala Group Oy . All rights reserved. + +import { RequestControllerUtils } from "./utils/RequestControllerUtils"; +import { LogService } from "../LogService"; + +const LOG = LogService.createLogger( 'SynchronizedRequest' ); + +export function SynchronizedRequest () { + return ( + target: any | Function, + propertyKey ?: string, + descriptor ?: PropertyDescriptor + ) => { + const requestController = RequestControllerUtils.findController( target ); + if ( requestController !== undefined ) { + RequestControllerUtils.attachControllerSynchronizedRequest( requestController, propertyKey ); + } else { + LOG.debug( "mapping: for other: target=", target, 'propertyKey=', propertyKey, 'descriptor=', descriptor ); + } + }; +} \ No newline at end of file diff --git a/request/types/ContentType.ts b/request/types/ContentType.ts new file mode 100644 index 0000000..9562ddd --- /dev/null +++ b/request/types/ContentType.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export enum ContentType { + TEXT = "text/plain", + CALENDAR = "text/calendar", + JSON = "application/json", + X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded" +} + +export function isContentType (value: any): value is ContentType { + switch (value) { + case ContentType.TEXT: + case ContentType.CALENDAR: + case ContentType.JSON: + case ContentType.X_WWW_FORM_URLENCODED: + return true; + default: + return false; + } +} + +export function stringifyContentType (value: ContentType): string { + switch (value) { + case ContentType.TEXT : return ContentType.TEXT; + case ContentType.CALENDAR : return ContentType.CALENDAR; + case ContentType.JSON : return ContentType.JSON; + case ContentType.X_WWW_FORM_URLENCODED : return ContentType.X_WWW_FORM_URLENCODED; + } + throw new TypeError(`Unsupported ContentType value: ${value}`); +} + +export function parseContentType (value: any): ContentType | undefined { + switch (`${value}`.toLowerCase()) { + + case ContentType.TEXT: + case 'text' : return ContentType.TEXT; + + case ContentType.CALENDAR: + case 'calendar' : return ContentType.CALENDAR; + + case ContentType.JSON: + case 'json' : return ContentType.JSON; + + case ContentType.X_WWW_FORM_URLENCODED: + case 'X_WWW_FORM_URLENCODED' : return ContentType.X_WWW_FORM_URLENCODED; + + default : return undefined; + } +} diff --git a/request/types/Cookie.ts b/request/types/Cookie.ts new file mode 100644 index 0000000..1b92064 --- /dev/null +++ b/request/types/Cookie.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CookieLike } from "./CookieLike"; +import { SameSite } from "../../types/SameSite"; + +export class Cookie implements CookieLike { + + private _name : string; + private _path : string | undefined; + private _value : string | undefined; + private _domain : string | undefined; + private _httpOnly : boolean | undefined; + private _secure : boolean | undefined; + private _maxAge : number | undefined; + private _sameSite : SameSite | undefined; + + private constructor ( + name : string, + value : string | undefined, + path : string | undefined, + domain : string | undefined, + httpOnly : boolean | undefined, + secure : boolean | undefined, + maxAge : number | undefined, + sameSite : SameSite | undefined, + ) { + this._path = path; + this._name = name; + this._value = value; + this._domain = domain; + this._httpOnly = httpOnly; + this._secure = secure; + this._maxAge = maxAge; + this._sameSite = sameSite; + } + + public static create ( + name : string, + value : string | undefined = undefined, + path : string | undefined = undefined, + domain : string | undefined = undefined, + httpOnly : boolean | undefined = undefined, + secure : boolean | undefined = undefined, + maxAge : number | undefined = undefined, + sameSite : SameSite | undefined = undefined, + ) : CookieLike { + return new Cookie( + name, + value, + path, + domain, + httpOnly, + secure, + maxAge, + sameSite, + ); + } + + public getDomain (): string | undefined { + return this._domain; + } + + public getHttpOnly (): boolean | undefined { + return this._httpOnly; + } + + public getMaxAge (): number | undefined { + return this._maxAge; + } + + public getName (): string { + return this._name; + } + + public getValue (): string | undefined { + return this._value; + } + + public getPath (): string | undefined { + return this._path; + } + + public getSameSite (): SameSite | undefined { + return this._sameSite; + } + + public getSecure (): boolean | undefined { + return this._secure; + } + + public setDomain (domain: string): void { + this._domain = domain; + } + + public setHttpOnly (httpOnly: boolean): void { + this._httpOnly = httpOnly; + } + + public setMaxAge (maxAge: number): void { + this._maxAge = maxAge; + } + + public setName (name: string): void { + this._name = name; + } + + public setValue (value: string | undefined): void { + this._value = value; + } + + public setPath (path: string): void { + this._path = path; + } + + public setSameSite (sameSite: SameSite): void { + this._sameSite = sameSite; + } + + public setSecure (secure: boolean): void { + this._secure = secure; + } + +} diff --git a/request/types/CookieLike.ts b/request/types/CookieLike.ts new file mode 100644 index 0000000..e15ad40 --- /dev/null +++ b/request/types/CookieLike.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { SameSite } from "../../types/SameSite"; + +export interface CookieLike { + + getDomain() : string | undefined; + + getHttpOnly() : boolean | undefined; + + getMaxAge() : number | undefined; + + getName() : string; + + getValue() : string | undefined; + getPath() : string | undefined; + + getSameSite() : SameSite | undefined; + + getSecure() : boolean | undefined; + + setDomain(domain : string) : void; + + setHttpOnly(httpOnly : boolean) : void; + + setMaxAge(maxAge: number) : void; + + setName(name : string) : void; + setValue(name : string | undefined) : void; + + setPath(path: string) : void; + + setSameSite(sameSite: SameSite) : void; + + setSecure(secure: boolean) : void; + +} diff --git a/request/types/DefaultHeaderMapValuesType.ts b/request/types/DefaultHeaderMapValuesType.ts new file mode 100644 index 0000000..de97203 --- /dev/null +++ b/request/types/DefaultHeaderMapValuesType.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { RequestInterfaceUtils } from "../utils/RequestInterfaceUtils"; +import { isArray } from "../../types/Array"; +import { isString } from "../../types/String"; +import { isObject } from "../../types/Object"; +import { every } from "../../functions/every"; + +export type DefaultHeaderMapValuesType = { [key: string]: string | string[] }; + +export function isDefaultHeaderMapValuesType(value: unknown): value is DefaultHeaderMapValuesType { + + return ( + !!value + && isObject(value) + && RequestInterfaceUtils.everyPropertyIs( + value, + (item: any): boolean => { + return ( + isString(item) + || (isArray(item) && every(item, isString)) + ); + } + ) + ); + +} + + diff --git a/request/types/DefaultPathVariableMapValuesType.ts b/request/types/DefaultPathVariableMapValuesType.ts new file mode 100644 index 0000000..26b2d35 --- /dev/null +++ b/request/types/DefaultPathVariableMapValuesType.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { RequestInterfaceUtils } from "../utils/RequestInterfaceUtils"; +import { isString } from "../../types/String"; +import { isObject } from "../../types/Object"; + +export type DefaultPathVariableMapValuesType = { [key: string]: string }; + +export function isDefaultPathVariableMapValuesType(value: any): value is DefaultPathVariableMapValuesType { + + return ( + !!value + && isObject(value) + && RequestInterfaceUtils.everyPropertyIs(value, isString) + ); + +} + + diff --git a/request/types/Filter.ts b/request/types/Filter.ts new file mode 100644 index 0000000..ffc8d5d --- /dev/null +++ b/request/types/Filter.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { ServletRequest } from "./ServletRequest"; +import { ServletResponse } from "./ServletResponse"; +import { FilterChain } from "./FilterChain"; + +export interface Filter { + + doFilter (request : ServletRequest, response : ServletResponse, chain : FilterChain) : Promise; + +} + + diff --git a/request/types/FilterChain.ts b/request/types/FilterChain.ts new file mode 100644 index 0000000..8a1ad9d --- /dev/null +++ b/request/types/FilterChain.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { ServletRequest } from "./ServletRequest"; +import { ServletResponse } from "./ServletResponse"; + +export interface FilterChain { + + doFilter (request : ServletRequest, response : ServletResponse) : Promise; + +} + + diff --git a/request/types/Headers.ts b/request/types/Headers.ts new file mode 100644 index 0000000..6f47c63 --- /dev/null +++ b/request/types/Headers.ts @@ -0,0 +1,360 @@ +// Copyright (c) 2023 Heusala Group. All rights reserved. +// Copyright (c) 2020-2023 Sendanor. All rights reserved. + +import { HeadersObject, ChangeableHeadersObject } from "./HeadersObject"; +import { concat } from "../../functions/concat"; +import { forEach } from "../../functions/forEach"; +import { has } from "../../functions/has"; +import { map } from "../../functions/map"; +import { LogService } from "../../LogService"; +import { isReadonlyJsonArray } from "../../Json"; +import { LogLevel } from "../../types/LogLevel"; +import { isArray } from "../../types/Array"; +import { isString } from "../../types/String"; +import { keys } from "../../functions/keys"; + +const LOG = LogService.createLogger('Headers'); + +export class Headers { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private _value : HeadersObject | undefined; + private _uninitializedValue : HeadersObject | undefined; + + constructor (value ?: HeadersObject) { + this._value = undefined; + this._uninitializedValue = value; + } + + public static create (value ?: HeadersObject) : Headers { + return new Headers(value); + } + + private _initializeValue () { + + const value = this._value; + const uninitializedValue = this._uninitializedValue; + try { + + if (uninitializedValue) { + this._uninitializedValue = undefined; + this.addAll(uninitializedValue); + } + + } catch(err) { + this._value = value; + this._uninitializedValue = uninitializedValue; + throw err; + } + + } + + public clear () { + this._value = {}; + this._uninitializedValue = undefined; + } + + public add (headerName: string, headerValue : string) { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + LOG.debug('add header: ', headerName, headerValue); + + headerName = headerName.toLowerCase(); + + const originalHeader : string | readonly string[] | undefined = this._value && has(this._value, headerName) ? this._value[headerName] : undefined; + + if (this._value === undefined) { + + this._value = { + [headerName]: headerValue + }; + + } else if (originalHeader !== undefined) { + + if (isReadonlyJsonArray(originalHeader)) { + + this._value = { + ...this._value, + [headerName]: concat([], originalHeader, [headerValue]) + }; + + } else { + + this._value = { + ...this._value, + [headerName]: [originalHeader, headerValue] + }; + + } + + } else { + + this._value = { + ...this._value, + [headerName]: headerValue + }; + + } + + } + + public containsKey (headerName : string) : boolean { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + headerName = headerName.toLowerCase(); + + return has(this._value, headerName); + + } + + public isEmpty () : boolean { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + const headersObject : HeadersObject | undefined = this._value; + + return !headersObject || keys(headersObject).length === 0; + + } + + public keySet () : Set { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + const set : Set = new Set(); + + if (this._value !== undefined) { + const list : string[] = keys(this._value); + forEach(list, (key : string) => { + set.add(key); + }); + } + + return set; + + } + + public getValue (headerName: string) : string | readonly string[] | undefined { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + if (!this._value) return undefined; + + headerName = headerName.toLowerCase(); + + return has(this._value, headerName) ? this._value[headerName] : undefined; + + } + + public getFirst (headerName: string) : string | undefined { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + const value : string | readonly string[] | undefined = this.getValue(headerName); + + if (isReadonlyJsonArray(value)) { + return value.length ? value[0] : undefined; + } + + return value; + + } + + public getHost () : string | undefined { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + return this.getFirst('host'); + + } + + public addAll (key : string, values : readonly string[]) : void; + public addAll (values : HeadersObject) : void; + + public addAll (arg1 : HeadersObject | string, arg2 ?: readonly string[]) { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + if (isString(arg1)) { + + const headerKey = arg1; + const headerValues = arg2; + if (!isArray(headerValues)) throw new TypeError('Headers.addAll signature must be (string, string[]) or (HeadersObject)'); + + forEach(headerValues, (item : string) => { + this.add(headerKey, item); + }); + + } else { + + const values = arg1; + + forEach(keys(values), (headerKey : string) => { + + const headerValue : string | readonly string[] | undefined = values[headerKey]; + + if (isReadonlyJsonArray(headerValue)) { + + forEach(headerValue, (item : string) => { + this.add(headerKey, item); + }); + + } else if (headerValue !== undefined) { + + this.add(headerKey, headerValue); + + } + + }); + + } + + } + + public remove (headerName : string) : string | readonly string[] | undefined { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + headerName = headerName.toLowerCase(); + + const originalValue = this.getValue(headerName); + + const newValues : ChangeableHeadersObject = {...this._value}; + + if (newValues && has(newValues, headerName)) { + delete newValues[headerName]; + } + + this._value = newValues; + + return originalValue; + + } + + public set (headerName : string, headerValue : string | undefined) { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + headerName = headerName.toLowerCase(); + + if (this._value === undefined) { + this._value = { + [headerName]: headerValue + } + } else { + this._value = { + ...this._value, + [headerName]: headerValue + }; + } + + } + + public setAll (values : { [key: string]: string }) { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + forEach(keys(values), (headerKey : string) => { + this.set(headerKey, values[headerKey]); + }); + + } + + public valueOf () : HeadersObject | undefined { + if (this._uninitializedValue) { + this._initializeValue(); + } + return this._value ?? undefined; + } + + public toJSON () : HeadersObject | undefined { + return this.valueOf(); + } + + public toString () : string { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + const headersObject = this._value; + + if ( !headersObject || this.isEmpty() ) return 'Headers()'; + + const headerKeys : Array = keys(headersObject); + + const items : Array = map(headerKeys, (headerKey : string) => { + + const headerValue : string | readonly string[] | undefined = headersObject[headerKey]; + + if (!headerValue) return `${headerKey}`; + + if (isArray(headerValue)) return `${headerKey}: ${headerValue.map((/*item : string*/) => { + + if ( headerValue.indexOf(';') >= 0 || headerValue.indexOf(',') >= 0 ) { + return `"${headerValue}"`; + } + + return headerValue; + + }).join(', ')}`; + + if (headerValue.indexOf(';') >= 0) { + return `${headerKey}: "${headerValue}"`; + } + + return `${headerKey}: ${headerValue}`; + + }); + + return `Headers(${items.join('; ')})`; + + } + + public clone () : Headers { + + if (this._uninitializedValue) { + this._initializeValue(); + } + + return new Headers( this._value ? { + ...this._value + } : undefined ); + + } + +} + +export function isHeaders (value : any) : value is Headers { + return ( + !!value + && value instanceof Headers + ); +} diff --git a/request/types/HeadersObject.ts b/request/types/HeadersObject.ts new file mode 100644 index 0000000..e8eb0e2 --- /dev/null +++ b/request/types/HeadersObject.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { isArray } from "../../types/Array"; +import { isString } from "../../types/String"; +import { isObject } from "../../types/Object"; +import { keys } from "../../functions/keys"; +import { every } from "../../functions/every"; + +export interface HeadersObject { + readonly [key: string]: string | readonly string[] | undefined; +} + +export interface ChangeableHeadersObject { + [key: string]: string | readonly string[] | undefined; +} + +export function isHeadersObject (value : any) : value is HeadersObject { + + if (!value) return false; + if (!isObject(value)) return false; + if (isArray(value)) return false; + + const propertyKeys : Array = keys(value); + + return every(propertyKeys, (key : string) => { + + // @ts-ignore + const propertyValue : any = value[key]; + + return propertyValue === undefined || isString(propertyValue) || (isArray(propertyValue) && every(propertyValue, isString)); + + }); + +} + + diff --git a/request/types/HttpRetryPolicy.ts b/request/types/HttpRetryPolicy.ts new file mode 100644 index 0000000..d76eacc --- /dev/null +++ b/request/types/HttpRetryPolicy.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explain, explainProperty } from "../../types/explain"; +import { isNumberArray } from "../../types/NumberArray"; +import { explainNumber, explainNumberOrUndefined, isNumber, isNumberOrUndefined } from "../../types/Number"; +import { explainArrayOf, isArray, isArrayOf } from "../../types/Array"; +import { map } from "../../functions/map"; +import { filter } from "../../functions/filter"; +import { explainNumberPair, isNumberPair, NumberPair } from "../../types/NumberPair"; +import { explainString, isString } from "../../types/String"; +import { some } from "../../functions/some"; +import { isMethod, Method } from "../../types/Method"; +import { ErrorCode, isErrorCode } from "../../types/ErrorCode"; + +/** + * HTTP Retry policy configuration + */ +export interface HttpRetryPolicy { + + /** + * An array of exception codes which enable this retry policy. + */ + readonly onCode : readonly string[]; + + /** + * An array of HTTP methods which enable this retry policy. + */ + readonly onMethod : readonly string[]; + + /** + * An array of response HTTP status codes which enable this retry policy. + */ + readonly onStatus : readonly NumberPair[]; + + /** + * The maximum retry attempts. + * + * Defaults to `10`. + */ + readonly maxAttempts : number; + + /** + * The base delay until another retry attempt is made, in milliseconds. + * + * Defaults to `1000`. + */ + readonly baseDelay : number; + + /** + * The increase in the retry delay for each attempt, in milliseconds. + * + * Defaults to `1000`. + */ + readonly increasingDelayStep : number; + + /** + * The maximum delay between retry attempts, in milliseconds. + * + * If not provided, there is no maximum delay. + */ + readonly maxDelay ?: number | undefined; + +} + +export type RetryForStatusCode = number; +export type RetryForStatusRange = readonly [number, number]; +export type RetryForMethod = string; +export type RetryForCode = ErrorCode; +export type RetryForAny = readonly ( RetryForStatusCode | RetryForMethod | RetryForCode | RetryForStatusRange )[] | RetryForStatusCode | RetryForMethod | RetryForCode; + +function normalizeStatusRange (value : unknown) : NumberPair | undefined { + if (isNumber(value)) return [value, value]; + if (!isNumberArray(value)) return undefined; + if (value.length === 1) return [value[0], value[0]]; + if (value.length !== 0) return [value[0], value[1]]; + return undefined; +} + +function filterRetryForStatusRange (value : RetryForAny) : readonly NumberPair[] { + return filter(map(normalizeAsArray(value), normalizeStatusRange), isNumberPair); +} + +function normalizeAsMethod (value: unknown) : Method | undefined { + return isMethod(value) ? value : undefined; +} +function normalizeAsErrorCode (value: unknown) : ErrorCode | undefined { + return isErrorCode(value) ? value : undefined; +} + +function normalizeAsArray (value: any) : readonly any[] { + return isArray(value) ? value : [value]; +} + +function filterRetryForMethod (value : RetryForAny) : readonly Method[] { + return filter(map(normalizeAsArray(value), normalizeAsMethod), isMethod); +} + +function filterRetryForCode (value : RetryForAny) : readonly ErrorCode[] { + return filter(map(normalizeAsArray(value), normalizeAsErrorCode), isErrorCode); +} + +/** + * + * @param retryFor This may be HTTP method, HTTP status code, Exception, or a status range like [number, number] + * + * @param maxAttempts + * @param baseDelay + * @param increasingDelayStep + * @param maxDelay + */ +export function createHttpRetryPolicy ( + retryFor ?: RetryForAny, + maxAttempts ?: number, + baseDelay ?: number, + increasingDelayStep ?: number, + maxDelay ?: number | undefined +) : HttpRetryPolicy { + retryFor = retryFor ?? []; + const onStatus = filterRetryForStatusRange(retryFor); + const onMethod = filterRetryForMethod(retryFor); + const onCode = filterRetryForCode(retryFor); + return { + onCode : onCode, + onMethod : onMethod, + onStatus : onStatus, + maxAttempts : maxAttempts ?? 10, + baseDelay : baseDelay ?? 1000, + increasingDelayStep : increasingDelayStep ?? 1000, + maxDelay : maxDelay ?? undefined + }; +} + +export function isHttpRetryPolicy (value: unknown) : value is HttpRetryPolicy { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'onCode', + 'onMethod', + 'onStatus', + 'maxAttempts', + 'baseDelay', + 'increasingDelayStep', + 'maxDelay' + ]) + && isArrayOf(value?.onCode, isString) + && isArrayOf(value?.onMethod, isString) + && isArrayOf(value?.onStatus, isNumberPair) + && isNumber(value?.maxAttempts) + && isNumber(value?.baseDelay) + && isNumber(value?.increasingDelayStep) + && isNumberOrUndefined(value?.maxDelay) + ); +} + +export function explainHttpRetryPolicy (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'onCode', + 'onMethod', + 'onStatus', + 'maxAttempts', + 'baseDelay', + 'increasingDelayStep', + 'maxDelay' + ]) + , explainProperty("onCode", explainArrayOf("string", explainString, value?.onCode, isString)) + , explainProperty("onMethod", explainArrayOf("string", explainString, value?.onMethod, isString)) + , explainProperty("onStatus", explainArrayOf("NumberPair", explainNumberPair, value?.onStatus, isNumberPair)) + , explainProperty("maxAttempts", explainNumber(value?.maxAttempts)) + , explainProperty("baseDelay", explainNumber(value?.baseDelay)) + , explainProperty("increasingDelayStep", explainNumber(value?.increasingDelayStep)) + , explainProperty("maxDelay", explainNumberOrUndefined(value?.maxDelay)) + ] + ); +} + +export function stringifyHttpRetryPolicy (value : HttpRetryPolicy) : string { + return `HttpRetryPolicy(${value})`; +} + +export function parseHttpRetryPolicy (value: unknown) : HttpRetryPolicy | undefined { + if (isHttpRetryPolicy(value)) return value; + return undefined; +} + +export function getNextRetryDelay (delay: number, value : HttpRetryPolicy) : number { + delay = delay + value?.increasingDelayStep; + if ( value?.maxDelay !== undefined && delay > value.maxDelay ) { + delay = value.maxDelay; + } + return delay; +} + +export function shouldRetry (value : HttpRetryPolicy, attempt: number, method: string, status: number, code?: any | undefined) : boolean { + if (attempt >= value.maxAttempts) return false; + return ( + some(value.onMethod, (item: string) => item === method) + && (some(value.onStatus, (item: NumberPair) : boolean => { + const [minValue, maxValue] = item; + return status >= minValue && status <= maxValue; + }) + || ( code !== undefined && some(value.onCode, (item: string) => item === code) )) + ); +} + +export function getDefaultRetryFor () : RetryForAny { + return [ + Method.GET, + ErrorCode.ETIMEDOUT, + ErrorCode.ENOTFOUND, + ErrorCode.ECONNRESET, + ErrorCode.ECONNABORTED, + ErrorCode.EHOSTUNREACH, + ErrorCode.ESOCKETTIMEDOUT, + ErrorCode.EAI_AGAIN, + ErrorCode.EPIPE, + ErrorCode.ECONNREFUSED, + ErrorCode.EADDRINUSE, + ErrorCode.ENETUNREACH, + ErrorCode.ENETRESET, + ErrorCode.EPROTO, + ErrorCode.EHOSTDOWN, + [500, 599], + 429 // Too many requests + ]; +} + +export function createDefaultHttpRetryPolicy () : HttpRetryPolicy { + return createHttpRetryPolicy( + getDefaultRetryFor(), + 10, + 1000, + 2500 + ); +} diff --git a/request/types/RequestBodyParamObject.ts b/request/types/RequestBodyParamObject.ts new file mode 100644 index 0000000..6f64112 --- /dev/null +++ b/request/types/RequestBodyParamObject.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestParamValueType, isRequestParamValueType} from "./RequestParamValueType"; +import { RequestParamObjectType } from "./RequestParamObjectType"; + +export interface RequestBodyParamObject { + + objectType : RequestParamObjectType.REQUEST_BODY; + valueType : RequestParamValueType; + +} + +export function isRequestBodyParamObject (value: any): value is RequestBodyParamObject { + return ( + !!value + && value?.objectType === RequestParamObjectType.REQUEST_BODY + && isRequestParamValueType(value?.valueType) + ); +} + + diff --git a/request/types/RequestController.ts b/request/types/RequestController.ts new file mode 100644 index 0000000..2ca3b52 --- /dev/null +++ b/request/types/RequestController.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestInterfaceUtils } from "../utils/RequestInterfaceUtils"; +import { + RequestControllerMappingObject, + explainRequestControllerMappingObject, + isRequestControllerMappingObject, + getOpenApiDocumentFromRequestControllerMappingObject, + InternalRequestControllerMappingObject +} from "./RequestControllerMappingObject"; +import { has } from "../../functions/has"; +import { LogService } from "../../LogService"; +import { OpenAPIV3 } from "../../types/openapi"; + +const LOG = LogService.createLogger('RequestController'); + +export const INTERNAL_KEYWORD = '__requestMappings'; + +export interface RequestController { + [INTERNAL_KEYWORD] ?: RequestControllerMappingObject | undefined; +} + +export function isRequestController (value: any) : value is RequestController { + if (!value) return false; + const hasInternalValue = RequestInterfaceUtils.hasProperty__requestMappings(value); + if (!hasInternalValue) return true; + const internalValue = value[INTERNAL_KEYWORD]; + if (isRequestControllerMappingObject(internalValue)) { + return true; + } + LOG.warn('The internal mapping object was not correct: ' + JSON.stringify(internalValue, null, 2) + ': ' + explainRequestControllerMappingObject(internalValue) ); + return false; +} + +export function getRequestControllerMappingObject ( + controller: any +) : RequestControllerMappingObject | undefined { + // @ts-ignore + if ( hasInternalRequestMappingObject(controller?.constructor) ) { + // @ts-ignore + return getInternalRequestMappingObject(controller?.constructor, controller); + } + // @ts-ignore + return getInternalRequestMappingObject(controller, controller); +} + +export function getInternalRequestMappingObject ( + value : RequestController, + controllerInstance : any +) : RequestControllerMappingObject | undefined { + if (!RequestInterfaceUtils.hasProperty__requestMappings(value)) { + return undefined; + } + return { + ...value[INTERNAL_KEYWORD], + controller: controllerInstance + }; +} + +export function hasInternalRequestMappingObject ( + value : RequestController +) : boolean { + return RequestInterfaceUtils.hasProperty__requestMappings(value); +} + +export function setInternalRequestMappingObject ( + value : RequestController, + mapping : RequestControllerMappingObject +) : InternalRequestControllerMappingObject { + const strippedMapping : RequestControllerMappingObject = { + ...mapping + }; + if (has(strippedMapping, 'controller')) { + delete strippedMapping.controller; + } + // LOG.debug(`setInternalRequestMappingObject: Setting "${INTERNAL_KEYWORD}" of: `, value, ' as: ', strippedMapping); + value[INTERNAL_KEYWORD] = strippedMapping; + return strippedMapping; +} + +export function getOpenApiDocumentFromRequestController ( + controller: any +) : OpenAPIV3.Document { + const data : RequestControllerMappingObject | undefined = getRequestControllerMappingObject(controller); + if (data === undefined) { + throw new TypeError('getOpenApiDocumentFromRequestController: Could not detect request mapping in the controller'); + } + return getOpenApiDocumentFromRequestControllerMappingObject(data); +} diff --git a/request/types/RequestControllerMappingObject.ts b/request/types/RequestControllerMappingObject.ts new file mode 100644 index 0000000..2a2f854 --- /dev/null +++ b/request/types/RequestControllerMappingObject.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestMappingObject, isRequestMappingObject} from "./RequestMappingObject"; +import { RequestInterfaceUtils } from "../utils/RequestInterfaceUtils"; +import { forEach } from "../../functions/forEach"; +import { has } from "../../functions/has"; +import { reduce } from "../../functions/reduce"; +import { + explainRequestControllerMethodObject, + isRequestControllerMethodObject, + RequestControllerMethodObject +} from "./RequestControllerMethodObject"; +import { OpenAPIV3 } from "../../types/openapi"; +import { LogService } from "../../LogService"; +import { getOpenApiMethodFromRequestMethod, RequestMethod } from "./RequestMethod"; +import { isArray } from "../../types/Array"; +import { isObject } from "../../types/Object"; +import { every } from "../../functions/every"; +import { LogLevel } from "../../types/LogLevel"; + +export interface InternalRequestControllerMappingObject { + + mappings : readonly RequestMappingObject[]; + controllerProperties : { readonly [key: string]: RequestControllerMethodObject }; + + /** + * OpenAPI v3 document annotations + */ + openApiPartials ?: readonly Partial[]; + + /** + * OpenAPI v3 operation annotations for undefined properties + */ + operations ?: readonly Partial[]; + +} + +export interface RequestControllerMappingObject { + + mappings : readonly RequestMappingObject[]; + controllerProperties : { readonly [key: string]: RequestControllerMethodObject }; + controller ?: any; + + /** + * OpenAPI v3 document annotations + */ + openApiPartials ?: readonly Partial[]; + + /** + * OpenAPI v3 operation annotations for undefined properties + */ + operations ?: readonly Partial[]; + +} + +export function isRequestControllerMappingObject (value: any) : value is RequestControllerMappingObject { + return ( + RequestInterfaceUtils.isObject(value) + && RequestInterfaceUtils.hasPropertyMappings(value) && isArray(value.mappings) && every(value.mappings, isRequestMappingObject) + && RequestInterfaceUtils.hasPropertyControllerProperties(value) && isObject(value.controllerProperties) && RequestInterfaceUtils.everyPropertyIs(value.controllerProperties, isRequestControllerMethodObject) + ); +} + +export function explainRequestControllerMappingObject (value: any) : string { + + if (!RequestInterfaceUtils.isObject(value)) return "not object"; + + if (!RequestInterfaceUtils.hasPropertyMappings(value)) { + return `Property "mappings" was not valid: Did not exist`; + } + + if (!isArray(value.mappings)) { + return `Property "mappings" was not valid: Was not array`; + } + + if (!every(value.mappings, isRequestMappingObject)) { + return `Property "mappings" was not valid: Some items were not valid`; + } + + + if (!RequestInterfaceUtils.hasPropertyControllerProperties(value)) { + return `Property "controllerProperties" was not valid: Property did not exist`; + } + + if (!isObject(value.controllerProperties)) { + return `Property "controllerProperties" was not valid: Property was not object`; + } + + if (!RequestInterfaceUtils.everyPropertyIs(value.controllerProperties, isRequestControllerMethodObject)) { + return `Property "controllerProperties" was not valid: Some properties were not valid: ${ RequestInterfaceUtils.explainEveryPropertyIs(value.controllerProperties, isRequestControllerMethodObject, explainRequestControllerMethodObject) }`; + } + + return "ok"; + +} + +const LOG = LogService.createLogger('getOpenApiDocumentFromRequestControllerMappingObject'); + +export function getOpenApiDocumentFromRequestControllerMappingObject ( + data: RequestControllerMappingObject +) : OpenAPIV3.Document { + LOG.debug(`data.mappings= `, data.mappings); + LOG.debug(`data.controllerProperties= `, data.controllerProperties); + const info : OpenAPIV3.InfoObject = { + title: "API Reference", + version: "0.0.0" + }; + const paths : OpenAPIV3.PathsObject = reduce( + data.controllerProperties, + (obj: OpenAPIV3.PathsObject, item: RequestControllerMethodObject): OpenAPIV3.PathsObject => { + + LOG.debug('item.requestBodyRequired = ', item.requestBodyRequired); + LOG.debug('item.modelAttributes = ', item.modelAttributes); + LOG.debug('item.mappings = ', item.mappings); + LOG.debug('item.params = ', item.params); + LOG.debug('item.operations = ', item.operations); + + // Go through every method + path mapping + forEach( + item.mappings, + (mapping: RequestMappingObject) => { + forEach( + mapping.methods, + (method: RequestMethod) => { + const openApiMethod = getOpenApiMethodFromRequestMethod(method); + forEach( + mapping.paths, + (path: string) => { + if (item?.operations?.length) { + // Set path operations + forEach( + item.operations, + (op: Partial) => { + obj = setOpenApiDocumentPathOperationsObject( + obj, + openApiMethod, + path, + op + ); + } + ); + } else { + // Initialize empty object if no operations defined by annotations + obj = setOpenApiDocumentPathOperationsObject( + obj, + openApiMethod, + path, + {} + ); + } + } + ); + } + ); + } + ); + + return obj; + }, + {} + ); + return reduce( + data?.openApiPartials ?? [], + (info : OpenAPIV3.Document, item: Partial) : OpenAPIV3.Document => { + return { + ...info, + ...item + }; + }, + { + info: info, + openapi: "3.0.0", + components: {}, + security: [], + tags: [], + paths + } + ); +} + +getOpenApiDocumentFromRequestControllerMappingObject.setLogLevel = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; + +function setOpenApiDocumentPathOperationsObject ( + obj : OpenAPIV3.PathsObject, + method : OpenAPIV3.HttpMethods, + path : string, + newOperations : Partial +) : OpenAPIV3.PathsObject { + + // Create initial path object if missing + if (!has(obj, path)) { + obj = { + ...obj, + [path]: {} + }; + } + + // Create initial path operations object if missing + if (!has(obj[path], method)) { + obj = { + ...obj, + [path]: { + ...obj[path], + [method]: {} + } + }; + } + + // Append our changes + const origObjPath : OpenAPIV3.PathItemObject | undefined = obj[path]; + if (!origObjPath) throw new TypeError('Path was undefined; this should not happen'); + obj = { + ...obj, + [path]: { + ...origObjPath, + [method]: { + ...origObjPath[method], + ...newOperations + } + } + }; + + return obj; + +} diff --git a/request/types/RequestControllerMethodObject.ts b/request/types/RequestControllerMethodObject.ts new file mode 100644 index 0000000..7756ca1 --- /dev/null +++ b/request/types/RequestControllerMethodObject.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestMappingObject, isRequestMappingObject } from "./RequestMappingObject"; +import { isRequestParamObject, RequestParamObject } from "./RequestParamObject"; +import { RequestInterfaceUtils } from "../utils/RequestInterfaceUtils"; +import { filter } from "../../functions/filter"; +import { isNull } from "../../types/Null"; +import { map } from "../../functions/map"; +import { OpenAPIV3 } from "../../types/openapi"; +import { isArray, isArrayOf } from "../../types/Array"; +import { every } from "../../functions/every"; +import { isStringArray } from "../../types/StringArray"; +import { isBoolean } from "../../types/Boolean"; + +export interface RequestControllerMethodObject { + requestBodyRequired ?: boolean, + mappings : readonly RequestMappingObject[]; + params : readonly (RequestParamObject | null)[]; + + /** If any defined, this method is a model attribute builder for these model names */ + modelAttributes : readonly string[]; + + /** + * If `true` this indicates the request server should synchronize every + * asynchronous operation to happen only one at the time + */ + synchronized : boolean; + + /** + * OpenAPI v3 operation annotations + */ + operations ?: readonly Partial[]; + +} + +export function isRequestControllerMethodObject(value: any): value is RequestControllerMethodObject { + return ( + RequestInterfaceUtils.isObject(value) + && RequestInterfaceUtils.hasPropertyMappings(value) && isArrayOf(value.mappings, isRequestMappingObject) + && RequestInterfaceUtils.hasPropertyParams(value) && isArrayOf(value.params, RequestInterfaceUtils.createOrFunction(isRequestParamObject, isNull)) + && RequestInterfaceUtils.hasPropertyModelAttributes(value) && isStringArray(value?.modelAttributes) + && RequestInterfaceUtils.hasPropertySynchronized(value) && isBoolean(value?.synchronized) + ); +} + +export function explainRequestControllerMethodObject(value: any): string { + + if (!RequestInterfaceUtils.isObject(value)) return "Value is not object"; + + if (!RequestInterfaceUtils.hasPropertyMappings(value)) { + return `Property "mappings" did not exist`; + } + + if (!isArray(value.mappings)) { + return `Property "mappings" was not an array`; + } + + if (!every(value.mappings, isRequestMappingObject)) { + return `Property "mappings" had some elements which were not RequestMappingObject`; + } + + + if (!RequestInterfaceUtils.hasPropertyParams(value)) { + return `Property "params" did not exist`; + } + if (!isArray(value.params)) { + return `Property "params" was not an array`; + } + const test = RequestInterfaceUtils.createOrFunction(isRequestParamObject, isNull); + if (!every(value.params, test)) { + return `Property "params" had some elements which were not RequestParamObject or null: ${ + filter(map(value.params, (item, index) => { + if (!test(item)) { + return `Item #${index} was not null or RequestParamObject`; + } + return ""; + }), item => !!item).join(', ') + }`; + } + + if (!RequestInterfaceUtils.hasPropertyModelAttributes(value)) { + return `Property "modelAttributes" did not exist`; + } + if (!isStringArray(value.modelAttributes)) { + return `Property "modelAttributes" was not an string array`; + } + + if (!RequestInterfaceUtils.hasPropertySynchronized(value)) { + return `Property "synchronized" did not exist`; + } + if (!isBoolean(value.synchronized)) { + return `Property "synchronized" was not a boolean`; + } + + return "ok"; + +} diff --git a/request/types/RequestError.ts b/request/types/RequestError.ts new file mode 100644 index 0000000..8983410 --- /dev/null +++ b/request/types/RequestError.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2023 Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestStatus, stringifyRequestStatus} from "./RequestStatus"; +import { JsonAny, ReadonlyJsonAny, ReadonlyJsonObject } from "../../Json"; +import { RequestType } from "./RequestType"; +import { RequestMethod } from "./RequestMethod"; +import { Headers } from "./Headers"; + +export class RequestError extends Error { + + public readonly status : RequestStatus; + public readonly method : RequestMethod | undefined; + public readonly url : string | undefined; + public readonly body : JsonAny | undefined; + public readonly headers : Headers | undefined; + + // @ts-ignore + private readonly __proto__: any; + + public constructor ( + status : number, + message : string | undefined = undefined, + method : RequestMethod | undefined = undefined, + url : string | undefined = undefined, + body : JsonAny | undefined = undefined, + headers : Headers | undefined = undefined + ) { + + super( message ? message : stringifyRequestStatus(status) ); + + const actualProto = new.target.prototype; + + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, actualProto); + } else { + this.__proto__ = actualProto; + } + + this.status = status; + this.method = method; + this.url = url; + this.body = body; + this.headers = headers; + } + + public valueOf () : number { + return this.status; + } + + public toString () : string { + return `${this.status} ${this.message}`; + } + + public toJSON () : ReadonlyJsonObject { + return { + type: RequestType.ERROR, + status: this.status, + message: this.message, + method: this.method, + url: this.url, + body: (this.body as ReadonlyJsonAny | undefined), + headers: this.headers ? this.headers.toJSON() : undefined, + }; + } + + public getStatusCode () : number { + return this.status; + } + + public getMethod () : RequestMethod | undefined { + return this.method; + } + + public getUrl () : string | undefined { + return this.url; + } + + public getBody () : JsonAny | undefined { + return this.body; + } + + public getHeaders () : Headers | undefined { + return this.headers; + } + + public static create ( + status : RequestStatus, + message : string | undefined = undefined + ) : RequestError { + return new RequestError(status, message); + } + + public static createBadRequestError (message : string) { + return createRequestError(RequestStatus.BadRequest, message); + } + + public static createNotFoundRequestError (message : string) { + return createRequestError(RequestStatus.NotFound, message); + } + + public static createMethodNotAllowedRequestError (message : string) { + return createRequestError(RequestStatus.MethodNotAllowed, message); + } + + public static createConflictRequestError (message : string) { + return createRequestError(RequestStatus.Conflict, message); + } + + public static createInternalErrorRequestError (message : string) { + return createRequestError(RequestStatus.InternalServerError, message); + } + + /** + * + * @param message + * @throws + */ + public static throwBadRequestError (message : string) { + throw RequestError.createBadRequestError(message); + } + + /** + * + * @param message + * @throws + */ + public static throwNotFoundRequestError (message : string) { + throw RequestError.createNotFoundRequestError(message); + } + + /** + * + * @param message + * @throws + */ + public static throwMethodNotAllowedRequestError (message : string) { + throw RequestError.createMethodNotAllowedRequestError(message); + } + + /** + * + * @param message + * @throws + */ + public static throwConflictRequestError (message : string) { + throw RequestError.createConflictRequestError(message); + } + + /** + * + * @param message + * @throws + */ + public static throwInternalErrorRequestError (message : string) { + throw RequestError.createInternalErrorRequestError(message); + } + +} + +export function createRequestError ( + status : RequestStatus, + message : string | undefined = undefined +) : RequestError { + return RequestError.create(status, message); +} + +export function isRequestError (value : any) : value is RequestError { + return ( + !!value + && value instanceof RequestError + ); +} + + diff --git a/request/types/RequestHeaderListOptions.ts b/request/types/RequestHeaderListOptions.ts new file mode 100644 index 0000000..59a04d6 --- /dev/null +++ b/request/types/RequestHeaderListOptions.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { DefaultHeaderMapValuesType, isDefaultHeaderMapValuesType} from "./DefaultHeaderMapValuesType"; + +export interface RequestHeaderListOptions { + + defaultValues?: DefaultHeaderMapValuesType; + +} + +export function isRequestHeaderListOptions (value : any) : value is RequestHeaderListOptions { + + return ( + !!value + && ( + value?.defaultValues === undefined + || isDefaultHeaderMapValuesType(value?.defaultValues) + ) + ); + +} + + diff --git a/request/types/RequestHeaderMapParamObject.ts b/request/types/RequestHeaderMapParamObject.ts new file mode 100644 index 0000000..b944fe9 --- /dev/null +++ b/request/types/RequestHeaderMapParamObject.ts @@ -0,0 +1,19 @@ +import { RequestParamObjectType } from "./RequestParamObjectType"; +import {DefaultHeaderMapValuesType, isDefaultHeaderMapValuesType} from "./DefaultHeaderMapValuesType"; + +export interface RequestHeaderMapParamObject { + + objectType : RequestParamObjectType.REQUEST_HEADER_MAP; + defaultValues ?: DefaultHeaderMapValuesType; + +} + +export function isRequestHeaderMapParamObject (value: any): value is RequestHeaderMapParamObject { + return ( + !!value + && value?.objectType === RequestParamObjectType.REQUEST_HEADER_MAP + && ( value?.defaultValues === undefined || isDefaultHeaderMapValuesType(value?.defaultValues) ) + ); +} + + diff --git a/request/types/RequestHeaderOptions.ts b/request/types/RequestHeaderOptions.ts new file mode 100644 index 0000000..51a1f12 --- /dev/null +++ b/request/types/RequestHeaderOptions.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import {isBoolean} from "../../types/Boolean"; +import { isString } from "../../types/String"; + +export interface RequestHeaderOptions { + + required?: boolean; + defaultValue?: string | undefined; + +} + +export function isRequestHeaderOptions(value: any): value is RequestHeaderOptions { + + return ( + !!value + && (value?.required === undefined || isBoolean(value?.required)) + && (value?.defaultValue === undefined || isString(value?.defaultValue)) + ); + +} + +export function isRequestHeaderOptionsOrUndefined (value: any): value is (RequestHeaderOptions|undefined) { + return value === undefined || isRequestHeaderOptions(value); +} + + diff --git a/request/types/RequestHeaderParamObject.ts b/request/types/RequestHeaderParamObject.ts new file mode 100644 index 0000000..958f439 --- /dev/null +++ b/request/types/RequestHeaderParamObject.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestParamValueType, isRequestParamValueType} from "./RequestParamValueType"; +import { isBoolean } from "../../types/Boolean"; +import { RequestParamObjectType } from "./RequestParamObjectType"; +import { isString } from "../../types/String"; + +export interface RequestHeaderParamObject { + + objectType : RequestParamObjectType.REQUEST_HEADER; + valueType : RequestParamValueType; + headerName : string; + isRequired : boolean; + defaultValue : string | undefined; + +} + +export function isRequestHeaderParamObject (value: any): value is RequestHeaderParamObject { + return ( + !!value + && value?.objectType === RequestParamObjectType.REQUEST_HEADER + && isString(value?.headerName) + && isBoolean(value?.isRequired) + && isRequestParamValueType(value?.valueType) + && ( value?.defaultValue === undefined || isString(value?.defaultValue) ) + ); +} + + diff --git a/request/types/RequestMappingObject.ts b/request/types/RequestMappingObject.ts new file mode 100644 index 0000000..1530de0 --- /dev/null +++ b/request/types/RequestMappingObject.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestMethod, isRequestMethod} from "./RequestMethod"; +import { RequestInterfaceUtils } from "../utils/RequestInterfaceUtils"; +import { isArray } from "../../types/Array"; +import { isString } from "../../types/String"; +import { every } from "../../functions/every"; + +export interface RequestMappingObject { + readonly methods : readonly RequestMethod[]; + readonly paths : readonly string[]; +} + +export function isRequestMappingObject (value: any) : value is RequestMappingObject { + return ( + RequestInterfaceUtils.isObject(value) + && RequestInterfaceUtils.hasPropertyMethods(value) && isArray(value.methods) && every(value.methods, isRequestMethod) + && RequestInterfaceUtils.hasPropertyPaths(value) && isArray(value.paths) && every(value.paths, isString) + ); +} + + diff --git a/request/types/RequestMappingValue.ts b/request/types/RequestMappingValue.ts new file mode 100644 index 0000000..f885d0d --- /dev/null +++ b/request/types/RequestMappingValue.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestMethod, isRequestMethod} from "./RequestMethod"; +import { isString } from "../../types/String"; +import { isArrayOf } from "../../types/Array"; + +export type RequestMappingValue = RequestMethod|string; + +export function isRequestMappingValue (value : any) : value is RequestMappingValue { + return isString(value) || isRequestMethod(value); +} + +export function isRequestMappingValueArray (value : any) : value is RequestMappingValue[] { + return isArrayOf(value, isRequestMappingValue); +} diff --git a/request/types/RequestMethod.ts b/request/types/RequestMethod.ts new file mode 100644 index 0000000..a325c93 --- /dev/null +++ b/request/types/RequestMethod.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { OpenAPIV3 } from "../../types/openapi"; +import { isString } from "../../types/String"; +import { isNumber } from "../../types/Number"; +import { explainNot, explainOk } from "../../types/explain"; + +export enum RequestMethod { + OPTIONS, + GET, + POST, + PUT, + DELETE, + PATCH, + TRACE, + HEAD +} + +export const FIRST_REQUEST_METHOD_NUMBER = 0; +export const LAST_REQUEST_METHOD_NUMBER = 7; + +export function stringifyRequestMethod (value : RequestMethod) : string { + if (isNumber(value)) { + switch (value) { + case RequestMethod.OPTIONS : return 'options'; + case RequestMethod.GET : return 'get'; + case RequestMethod.POST : return 'post'; + case RequestMethod.PUT : return 'put'; + case RequestMethod.DELETE : return 'delete'; + case RequestMethod.PATCH : return 'patch'; + case RequestMethod.TRACE : return 'trace'; + case RequestMethod.HEAD : return 'head'; + } + } + throw new TypeError(`Unsupported value for stringifyRequestMethod(): ${value}`) +} + +export function isRequestMethod (value: unknown) : value is RequestMethod { + return isNumber(value) && value >= FIRST_REQUEST_METHOD_NUMBER && value <= LAST_REQUEST_METHOD_NUMBER; +} + +export function explainRequestMethod (value: any) : string { + return isRequestMethod(value) ? explainOk() : explainNot('RequestMethod') +} + +export function parseRequestMethod (value: any) : RequestMethod { + if (isRequestMethod(value)) return value; + if (isString(value)) { + value = value.toLowerCase(); + switch(value) { + case 'options' : return RequestMethod.OPTIONS; + case 'get' : return RequestMethod.GET; + case 'post' : return RequestMethod.POST; + case 'put' : return RequestMethod.PUT; + case 'delete' : return RequestMethod.DELETE; + case 'patch' : return RequestMethod.PATCH; + case 'trace' : return RequestMethod.TRACE; + case 'head' : return RequestMethod.HEAD; + default: break; + } + } + throw new TypeError(`Cannot parse value "${value}" as a valid RequestMethod`); +} + +export function getOpenApiMethodFromRequestMethod ( + method: RequestMethod +) : OpenAPIV3.HttpMethods { + switch (method) { + case RequestMethod.OPTIONS : return OpenAPIV3.HttpMethods.OPTIONS; + case RequestMethod.HEAD : return OpenAPIV3.HttpMethods.HEAD; + case RequestMethod.GET : return OpenAPIV3.HttpMethods.GET; + case RequestMethod.POST : return OpenAPIV3.HttpMethods.POST; + case RequestMethod.PUT : return OpenAPIV3.HttpMethods.PUT; + case RequestMethod.DELETE : return OpenAPIV3.HttpMethods.DELETE; + case RequestMethod.PATCH : return OpenAPIV3.HttpMethods.PATCH; + case RequestMethod.TRACE : return OpenAPIV3.HttpMethods.TRACE; + default: throw new TypeError('Unsupported method: '+method); + } +} + +export function getRequestMethodFromOpenApiMethod ( + method: OpenAPIV3.HttpMethods +) : RequestMethod { + switch (method) { + case OpenAPIV3.HttpMethods.OPTIONS : return RequestMethod.OPTIONS; + case OpenAPIV3.HttpMethods.HEAD : return RequestMethod.HEAD; + case OpenAPIV3.HttpMethods.GET : return RequestMethod.GET; + case OpenAPIV3.HttpMethods.POST : return RequestMethod.POST; + case OpenAPIV3.HttpMethods.PUT : return RequestMethod.PUT; + case OpenAPIV3.HttpMethods.DELETE : return RequestMethod.DELETE; + case OpenAPIV3.HttpMethods.PATCH : return RequestMethod.PATCH; + case OpenAPIV3.HttpMethods.TRACE : return RequestMethod.TRACE; + default: throw new TypeError('Unsupported method: '+method); + } +} diff --git a/request/types/RequestModelAttributeParamObject.ts b/request/types/RequestModelAttributeParamObject.ts new file mode 100644 index 0000000..83642ef --- /dev/null +++ b/request/types/RequestModelAttributeParamObject.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestParamValueType, isRequestParamValueType} from "./RequestParamValueType"; +import { RequestParamObjectType } from "./RequestParamObjectType"; +import { isString } from "../../types/String"; + +export interface RequestModelAttributeParamObject { + + objectType : RequestParamObjectType.MODEL_ATTRIBUTE; + valueType : RequestParamValueType; + attributeName : string; + +} + +export function isRequestModelAttributeParamObject (value: any): value is RequestModelAttributeParamObject { + return ( + !!value + && value?.objectType === RequestParamObjectType.MODEL_ATTRIBUTE + && isString(value?.attributeName) + && isRequestParamValueType(value?.valueType) + ); +} + + diff --git a/request/types/RequestParamObject.ts b/request/types/RequestParamObject.ts new file mode 100644 index 0000000..711463c --- /dev/null +++ b/request/types/RequestParamObject.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestQueryParamObject, isRequestQueryParamObject} from "./RequestQueryParamObject"; +import { RequestBodyParamObject, isRequestBodyParamObject} from "./RequestBodyParamObject"; +import { RequestHeaderParamObject, isRequestHeaderParamObject} from "./RequestHeaderParamObject"; +import { RequestHeaderMapParamObject, isRequestHeaderMapParamObject} from "./RequestHeaderMapParamObject"; +import { RequestPathVariableParamObject, isRequestPathVariableParamObject} from "./RequestPathVariableParamObject"; +import { RequestPathVariableMapParamObject, isRequestPathVariableMapParamObject} from "./RequestPathVariableMapParamObject"; +import { RequestModelAttributeParamObject, isRequestModelAttributeParamObject} from "./RequestModelAttributeParamObject"; + +export type RequestParamObject = ( + RequestQueryParamObject + | RequestBodyParamObject + | RequestHeaderParamObject + | RequestHeaderMapParamObject + | RequestPathVariableParamObject + | RequestPathVariableMapParamObject + | RequestModelAttributeParamObject +); + +export function isRequestParamObject(value: any): value is RequestParamObject { + return ( + isRequestQueryParamObject(value) + || isRequestBodyParamObject(value) + || isRequestHeaderParamObject(value) + || isRequestHeaderMapParamObject(value) + || isRequestPathVariableParamObject(value) + || isRequestPathVariableMapParamObject(value) + || isRequestModelAttributeParamObject(value) + ); +} + + diff --git a/request/types/RequestParamObjectType.ts b/request/types/RequestParamObjectType.ts new file mode 100644 index 0000000..25dbb65 --- /dev/null +++ b/request/types/RequestParamObjectType.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { isString } from "../../types/String"; +import { isNumber } from "../../types/Number"; + +export enum RequestParamObjectType { + + REQUEST_BODY, + QUERY_PARAM, + REQUEST_HEADER, + REQUEST_HEADER_MAP, + PATH_VARIABLE, + PATH_VARIABLE_MAP, + MODEL_ATTRIBUTE + +} + +export function isRequestParamObjectType (value : any) : value is RequestParamObjectType { + + if (!isNumber(value)) return false; + + switch(value) { + case RequestParamObjectType.REQUEST_BODY: + case RequestParamObjectType.QUERY_PARAM: + case RequestParamObjectType.REQUEST_HEADER: + case RequestParamObjectType.REQUEST_HEADER_MAP: + case RequestParamObjectType.PATH_VARIABLE: + case RequestParamObjectType.PATH_VARIABLE_MAP: + case RequestParamObjectType.MODEL_ATTRIBUTE: + return true; + } + + return false; + +} + +export function parseRequestParamObjectType (value: any) : RequestParamObjectType { + + if (isRequestParamObjectType(value)) return value; + + if (isString(value)) { + const lowerCaseValue = value.toLowerCase(); + switch (lowerCaseValue) { + case 'body' : return RequestParamObjectType.REQUEST_BODY; + case 'query_param' : return RequestParamObjectType.QUERY_PARAM; + case 'header' : return RequestParamObjectType.REQUEST_HEADER; + case 'header_map' : return RequestParamObjectType.REQUEST_HEADER_MAP; + case 'path_variable' : return RequestParamObjectType.PATH_VARIABLE; + case 'path_variable_map' : return RequestParamObjectType.PATH_VARIABLE_MAP; + case 'model_attribute' : return RequestParamObjectType.MODEL_ATTRIBUTE; + } + } + + throw new TypeError(`Value was not parsable to RequestParamObjectType: "${value}"`); + +} + +export function stringifyRequestParamObjectType (value : RequestParamObjectType) : string { + + switch (value) { + case RequestParamObjectType.REQUEST_BODY : return 'body'; + case RequestParamObjectType.QUERY_PARAM : return 'query_param'; + case RequestParamObjectType.REQUEST_HEADER : return 'header'; + case RequestParamObjectType.REQUEST_HEADER_MAP : return 'header_map'; + case RequestParamObjectType.PATH_VARIABLE : return 'path_variable'; + case RequestParamObjectType.PATH_VARIABLE_MAP : return 'path_variable_map'; + case RequestParamObjectType.MODEL_ATTRIBUTE : return 'model_attribute'; + } + + throw new TypeError(`Unsupported value: "${value}"`); + +} + + diff --git a/request/types/RequestParamValueType.ts b/request/types/RequestParamValueType.ts new file mode 100644 index 0000000..0a7e96e --- /dev/null +++ b/request/types/RequestParamValueType.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { isString } from "../../types/String"; +import { isNumber } from "../../types/Number"; + +export enum RequestParamValueType { + + JSON, + STRING, + INTEGER, + NUMBER, + REGULAR_OBJECT, + // BOOLEAN, + +} + +export function isRequestParamValueType (value : any) : value is RequestParamValueType { + + if (!isNumber(value)) return false; + + switch(value) { + case RequestParamValueType.JSON: + case RequestParamValueType.STRING: + case RequestParamValueType.INTEGER: + case RequestParamValueType.NUMBER: + case RequestParamValueType.REGULAR_OBJECT: + // case RequestParamValueType.BOOLEAN: + return true; + } + + return false; + +} + +export function isRequestParamValueTypeOrUndefined (value : any) : value is (undefined|RequestParamValueType) { + return value === undefined || isRequestParamValueType(value); +} + +export function parseRequestParamValueType (value: any) : RequestParamValueType { + + if (isRequestParamValueType(value)) return value; + + if (isString(value)) { + const lowerCaseValue = value.toLowerCase(); + switch (lowerCaseValue) { + case 'json' : return RequestParamValueType.JSON; + case 'string' : return RequestParamValueType.STRING; + case 'integer' : return RequestParamValueType.INTEGER; + case 'number' : return RequestParamValueType.NUMBER; + case 'regular_object' : return RequestParamValueType.REGULAR_OBJECT; + // case 'boolean' : return RequestParamValueType.BOOLEAN; + } + } + + throw new TypeError(`Value was not parsable to RequestParamType: "${value}"`); + +} + +export function stringifyRequestParamValueType (value : RequestParamValueType) : string { + + switch (value) { + case RequestParamValueType.JSON : return 'json'; + case RequestParamValueType.STRING : return 'string'; + case RequestParamValueType.INTEGER : return 'integer'; + case RequestParamValueType.NUMBER : return 'number'; + case RequestParamValueType.REGULAR_OBJECT : return 'regular_object'; + // case RequestParamValueType.BOOLEAN : return 'boolean'; + } + + throw new TypeError(`Unsupported value: "${value}"`); + +} + +export function getOpenApiTypeStringFromRequestParamValueType (value: RequestParamValueType) : "object" | "string" | "integer" | "number" { + switch (value) { + case RequestParamValueType.JSON : return 'object'; + case RequestParamValueType.STRING : return 'string'; + case RequestParamValueType.INTEGER : return 'integer'; + case RequestParamValueType.NUMBER : return 'number'; + case RequestParamValueType.REGULAR_OBJECT : return 'object'; + // case RequestParamValueType.BOOLEAN : return 'boolean'; + } + throw new TypeError(`Unknown RequestParamValueType supplied to getOpenApiTypeString(): ${value}`); +} \ No newline at end of file diff --git a/request/types/RequestPathVariableListOptions.ts b/request/types/RequestPathVariableListOptions.ts new file mode 100644 index 0000000..f9eecbb --- /dev/null +++ b/request/types/RequestPathVariableListOptions.ts @@ -0,0 +1,7 @@ +export interface RequestPathVariableListOptions { + + defaultValues?: { [key: string]: string }; + +} + + diff --git a/request/types/RequestPathVariableMapParamObject.ts b/request/types/RequestPathVariableMapParamObject.ts new file mode 100644 index 0000000..9b824e7 --- /dev/null +++ b/request/types/RequestPathVariableMapParamObject.ts @@ -0,0 +1,19 @@ +import { RequestParamObjectType } from "./RequestParamObjectType"; +import {DefaultPathVariableMapValuesType, isDefaultPathVariableMapValuesType} from "./DefaultPathVariableMapValuesType"; + +export interface RequestPathVariableMapParamObject { + + objectType : RequestParamObjectType.PATH_VARIABLE_MAP; + defaultValues ?: DefaultPathVariableMapValuesType; + +} + +export function isRequestPathVariableMapParamObject (value: any): value is RequestPathVariableMapParamObject { + return ( + !!value + && value?.objectType === RequestParamObjectType.PATH_VARIABLE_MAP + && ( value?.defaultValues === undefined || isDefaultPathVariableMapValuesType(value?.defaultValues) ) + ); +} + + diff --git a/request/types/RequestPathVariableOptions.ts b/request/types/RequestPathVariableOptions.ts new file mode 100644 index 0000000..5f6e907 --- /dev/null +++ b/request/types/RequestPathVariableOptions.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isBooleanOrUndefined } from "../../types/Boolean"; +import { isStringOrUndefined } from "../../types/String"; + +export interface RequestPathVariableOptions { + + readonly required ?: boolean; + readonly defaultValue ?: string | undefined; + readonly decodeValue ?: boolean; + +} + +export function isRequestPathVariableOptions(value: any): value is RequestPathVariableOptions { + + return ( + !!value + && isBooleanOrUndefined(value?.required) + && isStringOrUndefined(value?.defaultValue) + && isBooleanOrUndefined(value?.decodeValue) + ); + +} + +export function isRequestPathVariableOptionsOrUndefined (value: any): value is RequestPathVariableOptions { + return value === undefined || isRequestPathVariableOptions(value); +} + + diff --git a/request/types/RequestPathVariableParamObject.ts b/request/types/RequestPathVariableParamObject.ts new file mode 100644 index 0000000..cca4049 --- /dev/null +++ b/request/types/RequestPathVariableParamObject.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestParamValueType, isRequestParamValueType} from "./RequestParamValueType"; +import { isBoolean } from "../../types/Boolean"; +import { RequestParamObjectType } from "./RequestParamObjectType"; +import { isString } from "../../types/String"; + +export interface RequestPathVariableParamObject { + + readonly objectType : RequestParamObjectType.PATH_VARIABLE; + readonly valueType : RequestParamValueType; + readonly variableName : string; + readonly isRequired : boolean; + readonly defaultValue : string | undefined; + readonly decodeValue : boolean; + +} + +export function isRequestPathVariableParamObject (value: any): value is RequestPathVariableParamObject { + return ( + !!value + && value?.objectType === RequestParamObjectType.PATH_VARIABLE + && isString(value?.variableName) + && isBoolean(value?.isRequired) + && isRequestParamValueType(value?.valueType) + && ( value?.defaultValue === undefined || isString(value?.defaultValue) ) + ); +} + + diff --git a/request/types/RequestQueryParamObject.ts b/request/types/RequestQueryParamObject.ts new file mode 100644 index 0000000..4c81b7e --- /dev/null +++ b/request/types/RequestQueryParamObject.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestParamValueType, isRequestParamValueType} from "./RequestParamValueType"; +import { RequestParamObjectType } from "./RequestParamObjectType"; +import { isStringOrUndefined } from "../../types/String"; + +export interface RequestQueryParamObject { + objectType : RequestParamObjectType.QUERY_PARAM; + valueType : RequestParamValueType; + + /** + * If undefined, result will be an object of all query parameters provided + */ + queryParam : string | undefined; + +} + +export function isRequestQueryParamObject(value: any): value is RequestQueryParamObject { + return ( + !!value + && value?.objectType === RequestParamObjectType.QUERY_PARAM + && isStringOrUndefined(value?.queryParam) + && isRequestParamValueType(value?.valueType) + ); +} + + diff --git a/request/types/RequestStatus.ts b/request/types/RequestStatus.ts new file mode 100644 index 0000000..d02f26d --- /dev/null +++ b/request/types/RequestStatus.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { find } from "../../functions/find"; +import { trim } from "../../functions/trim"; +import { isString } from "../../types/String"; +import { isNumber } from "../../types/Number"; +import { keys } from "../../functions/keys"; + +export enum RequestStatus { + + Continue = 100, + SwitchingProtocols = 101, + Processing = 102, + CheckPoint = 103, + OK = 200, + Created = 201, + Accepted = 202, + NonAuthoritativeInformation = 203, + NoContent = 204, + ResetContent = 205, + PartialContent = 206, + MultiStatus = 207, + AlreadyReported = 208, + IMUsed = 226, + MultipleChoices = 300, + MovedPermanently = 301, + Found = 302, + SeeOther = 303, + NotModified = 304, + TemporaryRedirect = 307, + PermanentRedirect = 308, + BadRequest = 400, + Unauthorized = 401, + PaymentRequired = 402, + Forbidden = 403, + NotFound = 404, + MethodNotAllowed = 405, + NotAcceptable = 406, + ProxyAuthenticationRequired = 407, + RequestTimeout = 408, + Conflict = 409, + Gone = 410, + LengthRequired = 411, + PreconditionFailed = 412, + PayloadTooLarge = 413, + URITooLong = 414, + UnsupportedMediaType = 415, + RequestedRangeNotSatisfiable = 416, + ExpectationFailed = 417, + IAmATeapot = 418, + UnprocessableEntity = 422, + Locked = 423, + FailedDependency = 424, + TooEarly = 425, + UpgradeRequired = 426, + PreconditionRequired = 428, + TooManyRequests = 429, + RequestHeaderFieldsTooLarge = 431, + UnavailableForLegalReasons = 451, + + /** + * @deprecated Use RequestStatus.InternalServerError + */ + InternalError = 500, + + InternalServerError = 500, + NotImplemented = 501, + BadGateway = 502, + ServiceUnavailable = 503, + GatewayTimeout = 504, + HttpVersionNotSupported = 505, + VariantAlsoNegotiates = 506, + InsufficientStorage = 507, + LoopDetected = 508, + BandwidthLimitExceeded = 509, + NotExtended = 510, + NetworkAuthenticationRequired = 511, + +} + +export function isRequestStatus (value: any) : value is RequestStatus { + + if (!isNumber(value)) return false; + + switch (value) { + case RequestStatus.Continue: + case RequestStatus.SwitchingProtocols: + case RequestStatus.Processing: + case RequestStatus.CheckPoint: + case RequestStatus.OK: + case RequestStatus.Created: + case RequestStatus.Accepted: + case RequestStatus.NonAuthoritativeInformation: + case RequestStatus.NoContent: + case RequestStatus.ResetContent: + case RequestStatus.PartialContent: + case RequestStatus.MultiStatus: + case RequestStatus.AlreadyReported: + case RequestStatus.IMUsed: + case RequestStatus.MultipleChoices: + case RequestStatus.MovedPermanently: + case RequestStatus.Found: + case RequestStatus.SeeOther: + case RequestStatus.NotModified: + case RequestStatus.TemporaryRedirect: + case RequestStatus.PermanentRedirect: + case RequestStatus.BadRequest: + case RequestStatus.Unauthorized: + case RequestStatus.PaymentRequired: + case RequestStatus.Forbidden: + case RequestStatus.NotFound: + case RequestStatus.MethodNotAllowed: + case RequestStatus.NotAcceptable: + case RequestStatus.ProxyAuthenticationRequired: + case RequestStatus.RequestTimeout: + case RequestStatus.Conflict: + case RequestStatus.Gone: + case RequestStatus.LengthRequired: + case RequestStatus.PreconditionFailed: + case RequestStatus.PayloadTooLarge: + case RequestStatus.URITooLong: + case RequestStatus.UnsupportedMediaType: + case RequestStatus.RequestedRangeNotSatisfiable: + case RequestStatus.ExpectationFailed: + case RequestStatus.IAmATeapot: + case RequestStatus.UnprocessableEntity: + case RequestStatus.Locked: + case RequestStatus.FailedDependency: + case RequestStatus.TooEarly: + case RequestStatus.UpgradeRequired: + case RequestStatus.PreconditionRequired: + case RequestStatus.TooManyRequests: + case RequestStatus.RequestHeaderFieldsTooLarge: + case RequestStatus.UnavailableForLegalReasons: + case RequestStatus.InternalServerError: + case RequestStatus.NotImplemented: + case RequestStatus.BadGateway: + case RequestStatus.ServiceUnavailable: + case RequestStatus.GatewayTimeout: + case RequestStatus.HttpVersionNotSupported: + case RequestStatus.VariantAlsoNegotiates: + case RequestStatus.InsufficientStorage: + case RequestStatus.LoopDetected: + case RequestStatus.BandwidthLimitExceeded: + case RequestStatus.NotExtended: + case RequestStatus.NetworkAuthenticationRequired: + return true; + + } + + return false; + +} + +export function stringifyRequestStatus (value : RequestStatus) : string { + switch (value) { + + case RequestStatus.Continue : return 'Continue'; + case RequestStatus.SwitchingProtocols : return 'Switching Protocols'; + case RequestStatus.Processing : return 'Processing'; + case RequestStatus.CheckPoint : return 'Check Point'; + case RequestStatus.OK : return 'OK'; + case RequestStatus.Created : return 'Created'; + case RequestStatus.Accepted : return 'Accepted'; + case RequestStatus.NonAuthoritativeInformation : return 'Non-Authoritative Information'; + case RequestStatus.NoContent : return 'No Content'; + case RequestStatus.ResetContent : return 'Reset Content'; + case RequestStatus.PartialContent : return 'Partial Content'; + case RequestStatus.MultiStatus : return 'Multi Status'; + case RequestStatus.AlreadyReported : return 'Already Reported'; + case RequestStatus.IMUsed : return 'IM Used'; + case RequestStatus.MultipleChoices : return 'Multiple Choices'; + case RequestStatus.MovedPermanently : return 'Moved Permanently'; + case RequestStatus.Found : return 'Found'; + case RequestStatus.SeeOther : return 'See Other'; + case RequestStatus.NotModified : return 'Not Modified'; + case RequestStatus.TemporaryRedirect : return 'Temporary Redirect'; + case RequestStatus.PermanentRedirect : return 'Permanent Redirect'; + case RequestStatus.BadRequest : return 'Bad Request'; + case RequestStatus.Unauthorized : return 'Unauthorized'; + case RequestStatus.PaymentRequired : return 'Payment Required'; + case RequestStatus.Forbidden : return 'Forbidden'; + case RequestStatus.NotFound : return 'Not Found'; + case RequestStatus.MethodNotAllowed : return 'Method Not Allowed'; + case RequestStatus.NotAcceptable : return 'Not Acceptable'; + case RequestStatus.ProxyAuthenticationRequired : return 'Proxy Authentication Required'; + case RequestStatus.RequestTimeout : return 'Request Timeout'; + case RequestStatus.Conflict : return 'Conflict'; + case RequestStatus.Gone : return 'Gone'; + case RequestStatus.LengthRequired : return 'Length Required'; + case RequestStatus.PreconditionFailed : return 'Precondition Failed'; + case RequestStatus.PayloadTooLarge : return 'Payload Too Large'; + case RequestStatus.URITooLong : return 'URI Too Long'; + case RequestStatus.UnsupportedMediaType : return 'Unsupported Media Type'; + case RequestStatus.RequestedRangeNotSatisfiable : return 'Requested Range Not Satisfiable'; + case RequestStatus.ExpectationFailed : return 'Expectation Failed'; + case RequestStatus.IAmATeapot : return 'I Am a Teapot'; + case RequestStatus.UnprocessableEntity : return 'Unprocessable Entity'; + case RequestStatus.Locked : return 'Locked'; + case RequestStatus.FailedDependency : return 'Failed Dependency'; + case RequestStatus.TooEarly : return 'Too Early'; + case RequestStatus.UpgradeRequired : return 'Upgrade Required'; + case RequestStatus.PreconditionRequired : return 'Precondition Required'; + case RequestStatus.TooManyRequests : return 'Too Many Requests'; + case RequestStatus.RequestHeaderFieldsTooLarge : return 'Request Header Fields Too Large'; + case RequestStatus.UnavailableForLegalReasons : return 'Unavailable For Legal Reasons'; + case RequestStatus.InternalServerError : return 'Internal Server Error'; + case RequestStatus.NotImplemented : return 'Not Implemented'; + case RequestStatus.BadGateway : return 'Bad Gateway'; + case RequestStatus.ServiceUnavailable : return 'Service Unavailable'; + case RequestStatus.GatewayTimeout : return 'Gateway Timeout'; + case RequestStatus.HttpVersionNotSupported : return 'Http Version Not Supported'; + case RequestStatus.VariantAlsoNegotiates : return 'Variant Also Negotiates'; + case RequestStatus.InsufficientStorage : return 'Insufficient Storage'; + case RequestStatus.LoopDetected : return 'Loop Detected'; + case RequestStatus.BandwidthLimitExceeded : return 'Bandwidth Limit Exceeded'; + case RequestStatus.NotExtended : return 'Not Extended'; + case RequestStatus.NetworkAuthenticationRequired : return 'Network Authentication Required'; + + default : + + if (value < 400) return 'HTTP Status'; + + return 'HTTP Error'; + + } +} + +export function parseRequestStatus (value: any) : RequestStatus { + + if (isRequestStatus(value)) return value; + + if (isString(value)) { + + value = trim(value); + + const integerValue = parseInt(value, 10); + if (isRequestStatus(integerValue)) return integerValue; + + value = normaliseStatusString(value); + + const statusKey : string | undefined = find(keys(RequestStatus), (key : string) => { + // @ts-ignore + const item : RequestStatus = RequestStatus[key]; + return normaliseStatusString(stringifyRequestStatus(item)) === value; + }); + + if (statusKey) { + // @ts-ignore + return RequestStatus[statusKey]; + } + + } + + throw new TypeError(`Cannot parse value "${value}" as a valid RequestStatus`); + +} + +function normaliseStatusString (value: string) : string { + return value.toLowerCase().replace(/[ _-]+/g, "-"); +} + + diff --git a/request/types/RequestType.ts b/request/types/RequestType.ts new file mode 100644 index 0000000..69712c8 --- /dev/null +++ b/request/types/RequestType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { isString } from "../../types/String"; + +export enum RequestType { + + ERROR = 'error', + +} + +export function isRequestType (value: any) : value is RequestType { + + switch (value) { + + case RequestType.ERROR: + return true; + + default: break; + + } + + return false; + +} + +export function parseRequestType (value : any) : RequestType { + + if (isString(value)) { + + value = value.toLowerCase(); + + switch (value) { + + case 'error': + return RequestType.ERROR; + + default: break; + + } + + } + + throw new TypeError(`Unsupported value for RequestType: ${value}`) + +} + + diff --git a/request/types/ResponseEntity.ts b/request/types/ResponseEntity.ts new file mode 100644 index 0000000..6bda7ed --- /dev/null +++ b/request/types/ResponseEntity.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { RequestStatus, stringifyRequestStatus} from "./RequestStatus"; +import { map } from "../../functions/map"; +import { Headers, isHeaders} from "./Headers"; +import { HeadersObject } from "./HeadersObject"; +import { isReadonlyJsonAny} from "../../Json"; +import { StringUtils } from "../../StringUtils"; +import { parseRequestMethod, RequestMethod, stringifyRequestMethod} from "./RequestMethod"; +import { TestCallbackNonStandard } from "../../types/TestCallback"; +import { isNumber } from "../../types/Number"; + +export type EntityStatusTypes = RequestStatus | number; + +export function isEntityStatusType (value : any) : value is EntityStatusTypes { + return isNumber(value); +} + +export class ResponseEntity { + + private _status : EntityStatusTypes; + private _headers : Headers; + private _body : T | undefined; + + // arg1 arg2 arg3 + constructor ( status : EntityStatusTypes ); + constructor ( headers : Headers , status : EntityStatusTypes ); + constructor ( body : T , status : EntityStatusTypes ); + constructor ( body : T , headers : Headers , status : EntityStatusTypes ); + + constructor ( + arg1 : T | Headers | EntityStatusTypes, + arg2 ?: EntityStatusTypes | Headers, + arg3 ?: RequestStatus + ) { + + let status : EntityStatusTypes | undefined; + let headers : Headers | undefined; + let body : T | undefined; + + if ( isEntityStatusType(arg1) && !isEntityStatusType(arg2) && !isEntityStatusType(arg3) ) { + + status = arg1; + if (arg2 !== undefined) throw new TypeError('ResponseEntity signature is [body, ][headers, ]status'); + if (arg3 !== undefined) throw new TypeError('ResponseEntity signature is [body, ][headers, ]status'); + + } else if ( isEntityStatusType(arg2) && !isEntityStatusType(arg3) ) { + + if (arg3 !== undefined) throw new TypeError('ResponseEntity signature is [body, ][headers, ]status'); + + if (isHeaders(arg1)) { + headers = arg1; + } else { + // @ts-ignore + body = arg1; + } + + status = arg2; + + } else if ( isEntityStatusType(arg3) ) { + + if (!isHeaders(arg2)) throw new TypeError('ResponseEntity signature is [body, ][headers, ]status'); + + // @ts-ignore + body = arg1; + headers = arg2; + status = arg3; + + } else { + throw new TypeError('ResponseEntity signature is [body, ][headers, ]status'); + } + + this._status = status; + this._headers = headers ?? new Headers(); + this._body = body; + + } + + public getStatusCode () : RequestStatus { + return this._status; + } + + /** + * In JavaScript, this is essentially same as .getStatusCode() + */ + public getStatusCodeValue () : number { + return this._status; + } + + public status (value : RequestStatus) : ResponseEntity; + public status (value : number) : ResponseEntity; + public status (value : RequestStatus | number) : ResponseEntity { + + this._status = value; + + return this; + + } + + public headers (value: Headers) : ResponseEntity; + public headers (value: HeadersObject) : ResponseEntity; + public headers (value: Headers | HeadersObject) : ResponseEntity { + + if (isHeaders(value)) { + this._headers = value; + } else { + this._headers = new Headers(value); + } + + return this; + + } + + public body (value: T) : ResponseEntity { + this._body = value; + return this; + } + + public getHeaders () : Headers { + return this._headers; + } + + public allow (...methods: string[]) : ResponseEntity; + public allow (...methods: RequestMethod[]) : ResponseEntity; + + public allow (...methods: (RequestMethod|string)[]) : ResponseEntity { + + const parsedMethods : RequestMethod[] = map(methods, parseRequestMethod); + const stringMethods : string[] = map(parsedMethods, (item : RequestMethod) => stringifyRequestMethod(item).toUpperCase()); + + this._headers.set('Allow', stringMethods.join(', ')); + + return this; + + } + + public hasBody () : boolean { + return this._body !== undefined; + } + + public getBody () : T { + if (this._body === undefined) throw new TypeError('No body'); + return this._body; + } + + public valueOf () : string { + return this.toString(); + } + + public toString () : string { + + const status : string = stringifyRequestStatus(this._status); + + const headersObject : Headers = this._headers; + const headers : string = headersObject.isEmpty() ? '' : StringUtils.toString(headersObject); + + const body : any = this._body; + + if (body === undefined) { + + if (headers) { + return `ResponseEntity(${status}, ${headers})`; + } else { + return `ResponseEntity(${status})`; + } + + } + + let bodyDisplayValue : string = ''; + + if (isReadonlyJsonAny(body)) { + bodyDisplayValue = JSON.stringify(body, null, 2); + } else { + bodyDisplayValue = StringUtils.toString(body); + } + + if (headers) { + return `ResponseEntity(${status}, ${headers}, Body(${bodyDisplayValue}))`; + } else { + return `ResponseEntity(${status}, Body(${bodyDisplayValue}))`; + } + } + + public static accepted () : ResponseEntity { return new ResponseEntity(RequestStatus.Accepted); } + public static badRequest () : ResponseEntity { return new ResponseEntity(RequestStatus.BadRequest); } + public static created (location: string) : ResponseEntity { return new ResponseEntity(new Headers({"Location": location}), RequestStatus.Created); } + public static noContent () : ResponseEntity { return new ResponseEntity(RequestStatus.NoContent); } + public static notFound () : ResponseEntity { return new ResponseEntity(RequestStatus.NotFound); } + public static notImplemented () : ResponseEntity { return new ResponseEntity(RequestStatus.NotImplemented); } + public static internalServerError () : ResponseEntity { return new ResponseEntity(RequestStatus.InternalServerError); } + public static methodNotAllowed () : ResponseEntity { return new ResponseEntity(RequestStatus.MethodNotAllowed); } + public static unprocessableEntity () : ResponseEntity { return new ResponseEntity(RequestStatus.UnprocessableEntity); } + + public static ok (body?: T) : ResponseEntity { + return body !== undefined ? new ResponseEntity(body, RequestStatus.OK) : new ResponseEntity(RequestStatus.OK); + } + +} + +export function isResponseEntity (value : any) : value is ResponseEntity { + return !!value && value instanceof ResponseEntity; +} + +export function isResponseEntityOf ( + value : any, + isTest : TestCallbackNonStandard +) : value is ResponseEntity { + return !!value && value instanceof ResponseEntity && isTest( value.getBody() ); +} diff --git a/request/types/ServletRequest.ts b/request/types/ServletRequest.ts new file mode 100644 index 0000000..b4200fb --- /dev/null +++ b/request/types/ServletRequest.ts @@ -0,0 +1,14 @@ + +import { Headers } from "./Headers"; + +export interface ServletRequest { + + getHeaders() : Headers; + + setAttribute(key : string, value: T) : void; + + getAttribute(key : string) : any; + +} + + diff --git a/request/types/ServletResponse.ts b/request/types/ServletResponse.ts new file mode 100644 index 0000000..95cd851 --- /dev/null +++ b/request/types/ServletResponse.ts @@ -0,0 +1,6 @@ + +export interface ServletResponse { + +} + + diff --git a/request/utils/CookieUtils.ts b/request/utils/CookieUtils.ts new file mode 100644 index 0000000..821bfdf --- /dev/null +++ b/request/utils/CookieUtils.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { indexOf } from "../../functions/indexOf"; +import { map } from "../../functions/map"; +import { reduce } from "../../functions/reduce"; +import { split } from "../../functions/split"; +import { Cookie } from "../types/Cookie"; +import { CookieLike } from "../types/CookieLike"; +import { isArray } from "../../types/Array"; + +export class CookieUtils { + + public static parseCookies (value: string | readonly string[]) : CookieLike[] { + return map( + reduce( + isArray(value) ? value : [value], + (ret: string[], item: string) : string[] => { + return [ + ...ret, + ...split(item, /; */), + ]; + }, + [] + ), + (item: string) : CookieLike => { + const i : number = indexOf(item, '='); + if (i < 0) { + return Cookie.create( + item, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + } + const key : string = item.substring(0, i); + const value : string = item.substring(i+1); + return Cookie.create( + key, + value, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + } + ); + } + +} diff --git a/request/utils/RequestControllerUtils.test.ts b/request/utils/RequestControllerUtils.test.ts new file mode 100644 index 0000000..f743b6f --- /dev/null +++ b/request/utils/RequestControllerUtils.test.ts @@ -0,0 +1,936 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { RequestMappingObject } from "../types/RequestMappingObject"; +import { RequestControllerUtils } from "./RequestControllerUtils"; +import { RequestMethod } from "../types/RequestMethod"; +import { getInternalRequestMappingObject, RequestController } from "../types/RequestController"; +import { RequestParamValueType } from "../types/RequestParamValueType"; +import { RequestParamObjectType } from "../types/RequestParamObjectType"; +import { LogLevel } from "../../types/LogLevel"; + +RequestControllerUtils.setLogLevel(LogLevel.NONE); + +describe('RequestControllerUtils', () => { + + describe('#parseRequestMappings', () => { + + test('can parse single path', () => { + const config: (RequestMethod | string)[] = [ + '/path/to' + ]; + const parsedObject: RequestMappingObject = RequestControllerUtils.parseRequestMappings(config); + expect(parsedObject).toBeDefined(); + expect(parsedObject).toStrictEqual( + { + methods: [], + paths: [ '/path/to' ] + } + ); + }); + + test('can parse multiple paths', () => { + const config: (RequestMethod | string)[] = [ + '/path', + '/path/to' + ]; + const parsedObject: RequestMappingObject = RequestControllerUtils.parseRequestMappings(config); + expect(parsedObject).toBeDefined(); + expect(parsedObject).toStrictEqual( + { + methods: [], + paths: [ + '/path', + '/path/to' + ] + } + ); + }); + + test('can parse GET method', () => { + const config: (RequestMethod | string)[] = [ + RequestMethod.GET + ]; + const parsedObject: RequestMappingObject = RequestControllerUtils.parseRequestMappings(config); + expect(parsedObject).toBeDefined(); + expect(parsedObject).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [] + } + ); + }); + + test('can parse POST method', () => { + const config: (RequestMethod | string)[] = [ + RequestMethod.POST + ]; + const parsedObject: RequestMappingObject = RequestControllerUtils.parseRequestMappings(config); + expect(parsedObject).toBeDefined(); + expect(parsedObject).toStrictEqual( + { + methods: [ + RequestMethod.POST + ], + paths: [] + } + ); + }); + + test('can parse multiple methods', () => { + const config: (RequestMethod | string)[] = [ + RequestMethod.GET, + RequestMethod.POST + ]; + const parsedObject: RequestMappingObject = RequestControllerUtils.parseRequestMappings(config); + expect(parsedObject).toBeDefined(); + expect(parsedObject).toStrictEqual( + { + methods: [ + RequestMethod.GET, + RequestMethod.POST + ], + paths: [] + } + ); + }); + + test('can parse multiple paths with method', () => { + const config: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/path', + '/path/to' + ]; + const parsedObject: RequestMappingObject = RequestControllerUtils.parseRequestMappings(config); + expect(parsedObject).toBeDefined(); + expect(parsedObject).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/path', + '/path/to' + ] + } + ); + }); + + test('can parse multiple paths with multiple methods', () => { + const config: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/path', + RequestMethod.POST, + '/path/to' + ]; + const parsedObject: RequestMappingObject = RequestControllerUtils.parseRequestMappings(config); + expect(parsedObject).toBeDefined(); + expect(parsedObject).toStrictEqual( + { + methods: [ + RequestMethod.GET, + RequestMethod.POST + ], + paths: [ + '/path', + '/path/to' + ] + } + ); + }); + + }); + + describe('#attachControllerMapping', () => { + + test('can attach request mapping to the controller', () => { + class TestController { + + } + const config: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/path/to' + ]; + RequestControllerUtils.attachControllerMapping(TestController as RequestController, config); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties).toStrictEqual({}); + expect(internalMapping?.mappings?.length).toBe(1); + expect(internalMapping?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/path/to' + ] + } + ); + }); + + test('can attach multiple mappings to the controller', () => { + class TestController { + + } + const config1: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/path/to' + ]; + const config2: (RequestMethod | string)[] = [ + RequestMethod.POST, + '/path/to' + ]; + RequestControllerUtils.attachControllerMapping(TestController as RequestController, config1); + RequestControllerUtils.attachControllerMapping(TestController as RequestController, config2); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties).toStrictEqual({}); + expect(internalMapping?.mappings?.length).toBe(2); + expect(internalMapping?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/path/to' + ] + } + ); + expect(internalMapping?.mappings[1]).toStrictEqual( + { + methods: [ + RequestMethod.POST + ], + paths: [ + '/path/to' + ] + } + ); + }); + + }); + + describe('#attachControllerMethodMapping', () => { + + test("can attach mapping for the controller's method", () => { + class TestController { + getList () : readonly string[] { + return []; + } + } + const config: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/path/to' + ]; + RequestControllerUtils.attachControllerMethodMapping(TestController as RequestController, config, 'getList'); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getList?.mappings?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getList?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/path/to' + ] + } + ); + expect(internalMapping?.controllerProperties?.getList?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getList?.params).toStrictEqual([]); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + test("can attach mappings for the same controller's method", () => { + class TestController { + getList () : readonly string[] { + return []; + } + } + const config1: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/path/to' + ]; + const config2: (RequestMethod | string)[] = [ + RequestMethod.POST, + '/different/path' + ]; + RequestControllerUtils.attachControllerMethodMapping(TestController as RequestController, config1, 'getList'); + RequestControllerUtils.attachControllerMethodMapping(TestController as RequestController, config2, 'getList'); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getList?.mappings?.length).toBe(2); + expect(internalMapping?.controllerProperties?.getList?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.POST + ], + paths: [ + '/different/path' + ] + } + ); + expect(internalMapping?.controllerProperties?.getList?.mappings[1]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/path/to' + ] + } + ); + expect(internalMapping?.controllerProperties?.getList?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getList?.params).toStrictEqual([]); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + test("can attach multiple mappings to the same controller", () => { + class TestController { + getList () : readonly string[] { + return []; + } + getItem () : string | undefined { + return 'Hello world'; + } + } + const config1: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/path/to' + ]; + const config2: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/different' + ]; + RequestControllerUtils.attachControllerMethodMapping(TestController as RequestController, config1, 'getList'); + RequestControllerUtils.attachControllerMethodMapping(TestController as RequestController, config2, 'getItem'); + + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.mappings).toStrictEqual([]); + + expect(internalMapping?.controllerProperties?.getList?.mappings?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getList?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/path/to' + ] + } + ); + expect(internalMapping?.controllerProperties?.getList?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getList?.params).toStrictEqual([]); + + expect(internalMapping?.controllerProperties?.getItem?.mappings?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getItem?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/different' + ] + } + ); + expect(internalMapping?.controllerProperties?.getItem?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getItem?.params).toStrictEqual([]); + + }); + + }); + + describe('#findController', () => { + + test("can find controller from class", () => { + class TestController { + getList () : readonly string[] { + return []; + } + } + const result = RequestControllerUtils.findController(TestController); + expect(result).toStrictEqual(TestController); + }); + + test("can find controller from class instance", () => { + class TestController { + getList () : readonly string[] { + return []; + } + } + const instance = new TestController(); + const result = RequestControllerUtils.findController(instance); + expect(result).toStrictEqual(TestController); + }); + + test("cannot find instance from undefined", () => { + expect( RequestControllerUtils.findController(undefined) ).toStrictEqual(undefined); + }); + + test("cannot find instance from string", () => { + expect( RequestControllerUtils.findController('hello world') ).toStrictEqual(undefined); + }); + + test("cannot find instance from null", () => { + expect( RequestControllerUtils.findController(null) ).toStrictEqual(undefined); + }); + + test("cannot find instance from number", () => { + expect( RequestControllerUtils.findController(123) ).toStrictEqual(undefined); + }); + + }); + + describe('#setControllerMethodModelAttributeParam', () => { + + test("can attach string attribute mapping for the controller's method", () => { + class TestController { + getEcho (test: string) : string { + return test; + } + } + RequestControllerUtils.setControllerMethodModelAttributeParam( + TestController as RequestController, + 'getEcho', + 0, + 'test', + RequestParamValueType.STRING + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.params).toStrictEqual( + [ + { + attributeName: 'test', + objectType: RequestParamObjectType.MODEL_ATTRIBUTE, + valueType: RequestParamValueType.STRING + } + ] + ); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + }); + + describe('#attachControllerMethodModelAttributeBuilder', () => { + + test("can attach attribute mapping for the controller's method", () => { + class TestController { + static getEcho: number = 0; + } + RequestControllerUtils.attachControllerMethodModelAttributeBuilder( + TestController as RequestController, + 'getEcho', + { + value: 0, + writable: true, + enumerable: true, + configurable: false + }, + 'test' + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes).toStrictEqual(['test']); + expect(internalMapping?.controllerProperties?.getEcho?.params).toStrictEqual([]); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + test("can attach second attribute mapping for the controller's method", () => { + class TestController { + static getEcho: number = 0; + } + RequestControllerUtils.attachControllerMethodModelAttributeBuilder( + TestController as RequestController, + 'getEcho', + { + value: 0, + writable: true, + enumerable: true, + configurable: false + }, + 'test' + ); + RequestControllerUtils.attachControllerMethodModelAttributeBuilder( + TestController as RequestController, + 'getEcho', + { + value: 1, + writable: true, + enumerable: true, + configurable: false + }, + 'hello' + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes).toStrictEqual(['hello', 'test']); + expect(internalMapping?.controllerProperties?.getEcho?.params).toStrictEqual([]); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + test("can attach attribute mapping for the controller's method when there was another mapping for other property", () => { + class TestController { + static getBar: number = 0; + static getEcho: number = 0; + } + RequestControllerUtils.attachControllerMethodModelAttributeBuilder( + TestController as RequestController, + 'getBar', + { + value: 0, + writable: true, + enumerable: true, + configurable: false + }, + 'test' + ); + RequestControllerUtils.attachControllerMethodModelAttributeBuilder( + TestController as RequestController, + 'getEcho', + { + value: 1, + writable: true, + enumerable: true, + configurable: false + }, + 'hello' + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes).toStrictEqual(['hello']); + expect(internalMapping?.controllerProperties?.getEcho?.params).toStrictEqual([]); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + }); + + describe('#setControllerMethodQueryParam', () => { + + test("can attach attribute mapping for the controller's method", () => { + class TestController { + static getEcho ( + // @ts-ignore + test: string) { + + }; + } + RequestControllerUtils.setControllerMethodQueryParam( + TestController as RequestController, + 'getEcho', + 0, + 'test', + RequestParamValueType.STRING + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes?.length).toBe(0); + //expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes[0]).toStrictEqual({}); + expect(internalMapping?.controllerProperties?.getEcho?.params?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getEcho?.params[0]).toStrictEqual( + { + objectType: RequestParamObjectType.QUERY_PARAM, + queryParam: "test", + valueType: RequestParamValueType.STRING + } + ); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + }); + + describe('#setControllerMethodHeader', () => { + + test("can attach header configuration for the controller's method", () => { + class TestController { + static getEcho ( + // @ts-ignore + test: string) { + + }; + } + RequestControllerUtils.setControllerMethodHeader( + TestController as RequestController, + 'getEcho', + 0, + 'test', + RequestParamValueType.STRING, + true, + "123" + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes?.length).toBe(0); + //expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes[0]).toStrictEqual({}); + expect(internalMapping?.controllerProperties?.getEcho?.params?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getEcho?.params[0]).toStrictEqual( + { + objectType: RequestParamObjectType.REQUEST_HEADER, + headerName: "test", + valueType: RequestParamValueType.STRING, + defaultValue: "123", + isRequired: true + } + ); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + }); + + describe('#setControllerMethodPathVariable', () => { + + test("can attach path variable configuration for the controller's method", () => { + class TestController { + static getEcho ( + // @ts-ignore + test: string) { + + }; + } + RequestControllerUtils.setControllerMethodPathVariable( + TestController as RequestController, + 'getEcho', + 0, + 'test', + RequestParamValueType.STRING, + true, + true, + "123" + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes?.length).toBe(0); + //expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes[0]).toStrictEqual({}); + expect(internalMapping?.controllerProperties?.getEcho?.params?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getEcho?.params[0]).toStrictEqual( + { + objectType: RequestParamObjectType.PATH_VARIABLE, + valueType: RequestParamValueType.STRING, + defaultValue: "123", + variableName: 'test', + decodeValue: true, + isRequired: true + } + ); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + }); + + describe('#setControllerMethodPathVariableMap', () => { + + test("can attach path variable map configuration for the controller's method", () => { + class TestController { + static getEcho ( + // @ts-ignore + test: string) { + + }; + } + RequestControllerUtils.setControllerMethodPathVariableMap( + TestController as RequestController, + 'getEcho', + 0, + { + foo: 'bar' + } + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes?.length).toBe(0); + //expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes[0]).toStrictEqual({}); + expect(internalMapping?.controllerProperties?.getEcho?.params?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getEcho?.params[0]).toStrictEqual( + { + objectType: RequestParamObjectType.PATH_VARIABLE_MAP, + defaultValues: { + foo: 'bar' + } + } + ); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + }); + + describe('#setControllerMethodHeaderMap', () => { + + test("can attach header map configuration for the controller's method", () => { + class TestController { + static getEcho ( + // @ts-ignore + test: string) { + + }; + } + RequestControllerUtils.setControllerMethodHeaderMap( + TestController as RequestController, + 'getEcho', + 0, + { + foo: 'bar' + } + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes?.length).toBe(0); + //expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes[0]).toStrictEqual({}); + expect(internalMapping?.controllerProperties?.getEcho?.params?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getEcho?.params[0]).toStrictEqual( + { + objectType: RequestParamObjectType.REQUEST_HEADER_MAP, + defaultValues: { + foo: 'bar' + } + } + ); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + }); + + describe('#setControllerMethodBodyParam', () => { + + test("can attach body argument configuration for the controller's method", () => { + class TestController { + static getEcho ( + // @ts-ignore + test: string) { + + }; + } + RequestControllerUtils.setControllerMethodBodyParam( + TestController as RequestController, + 'getEcho', + 0, + RequestParamValueType.STRING + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.controllerProperties?.getEcho?.requestBodyRequired).toBe(true); + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toBe(0); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes?.length).toBe(0); + //expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes[0]).toStrictEqual({}); + expect(internalMapping?.controllerProperties?.getEcho?.params?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getEcho?.params[0]).toStrictEqual( + { + objectType: RequestParamObjectType.REQUEST_BODY, + valueType: RequestParamValueType.STRING + } + ); + expect(internalMapping?.mappings?.length).toBe(0); + }); + + }); + + describe('#attachControllerOpenApiDocument', () => { + + test("can attach openAPI configuration for the controller", () => { + class TestController { + static getEcho ( + // @ts-ignore + test: string) { + + }; + } + RequestControllerUtils.attachControllerOpenApiDocument( + TestController as RequestController, + { + info: { + title: 'Hello world API', + version: '0.1.0' + } + } + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.mappings).toStrictEqual([]); + expect(internalMapping?.openApiPartials?.length).toStrictEqual(1); + expect((internalMapping?.openApiPartials?? [])[0]).toStrictEqual( + { + info: { + title: 'Hello world API', + version: '0.1.0' + } + } + ); + }); + + }); + + describe('#attachControllerOperation', () => { + + test("can attach openAPI operation info for the controller's method", () => { + class TestController { + static getEcho ( + // @ts-ignore + test: string) { + + }; + } + RequestControllerUtils.attachControllerOperation( + TestController as RequestController, + 'getEcho', + { + "operationId": "getEcho", + summary: 'Returns echo of the parameter provided' + } + ); + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + expect(internalMapping?.controller).toStrictEqual(TestController); + expect(internalMapping?.mappings).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.mappings).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.params).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.operations?.length).toBe(1); + expect((internalMapping?.controllerProperties?.getEcho?.operations??[])[0]).toStrictEqual( + { + "operationId": "getEcho", + summary: 'Returns echo of the parameter provided' + } + ); + }); + + test("can attach openAPI operation info for the controller's method with request mapping", () => { + class TestController { + static getEcho (test: string) { + return test; + }; + } + + const config: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/path/to' + ]; + + RequestControllerUtils.attachControllerMapping(TestController as RequestController, config); + + RequestControllerUtils.attachControllerOperation( + TestController as RequestController, + 'getEcho', + { + "operationId": "getEcho", + summary: 'Returns echo of the parameter provided' + } + ); + + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + + expect(internalMapping?.controller).toStrictEqual(TestController); + + expect(internalMapping?.mappings?.length).toStrictEqual(1); + expect(internalMapping?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/path/to' + ] + } + ); + + expect(internalMapping?.controllerProperties?.getEcho?.mappings).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.params).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.operations?.length).toBe(1); + expect((internalMapping?.controllerProperties?.getEcho?.operations??[])[0]).toStrictEqual( + { + "operationId": "getEcho", + summary: 'Returns echo of the parameter provided' + } + ); + + }); + + test("can attach openAPI operation info for the controller's method with request mapping with method mappings1", () => { + class TestController { + static getFoo (test: string) { + return test; + }; + static getEcho (test: string) { + return test; + }; + } + + const config1: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/echo' + ]; + RequestControllerUtils.attachControllerMethodMapping(TestController as RequestController, config1, 'getEcho'); + + const config2: (RequestMethod | string)[] = [ + RequestMethod.GET, + '/foo' + ]; + RequestControllerUtils.attachControllerMethodMapping(TestController as RequestController, config2, 'getFoo'); + + RequestControllerUtils.attachControllerOperation( + TestController as RequestController, + 'getEcho', + { + "operationId": "getEcho", + summary: 'Returns echo of the parameter provided' + } + ); + + const internalMapping = getInternalRequestMappingObject(TestController as RequestController, TestController); + + expect(internalMapping?.controller).toStrictEqual(TestController); + + expect(internalMapping?.mappings).toStrictEqual([]); + + expect(internalMapping?.controllerProperties?.getFoo?.mappings?.length).toBe(1); + expect(internalMapping?.controllerProperties?.getFoo?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/foo' + ] + } + ); + expect(internalMapping?.controllerProperties?.getFoo?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getFoo?.params).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getFoo?.operations).not.toBeDefined(); + + expect(internalMapping?.controllerProperties?.getEcho?.mappings?.length).toStrictEqual(1); + expect(internalMapping?.controllerProperties?.getEcho?.mappings[0]).toStrictEqual( + { + methods: [ + RequestMethod.GET + ], + paths: [ + '/echo' + ] + } + ); + + expect(internalMapping?.controllerProperties?.getEcho?.modelAttributes).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.params).toStrictEqual([]); + expect(internalMapping?.controllerProperties?.getEcho?.operations?.length).toBe(1); + expect((internalMapping?.controllerProperties?.getEcho?.operations??[])[0]).toStrictEqual( + { + "operationId": "getEcho", + summary: 'Returns echo of the parameter provided' + } + ); + + }); + + }); + +}); diff --git a/request/utils/RequestControllerUtils.ts b/request/utils/RequestControllerUtils.ts new file mode 100644 index 0000000..3d99f16 --- /dev/null +++ b/request/utils/RequestControllerUtils.ts @@ -0,0 +1,863 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { + RequestController, + getInternalRequestMappingObject, + isRequestController, + setInternalRequestMappingObject +} from "../types/RequestController"; +import { concat } from "../../functions/concat"; +import { filter } from "../../functions/filter"; +import { has } from "../../functions/has"; +import { find } from "../../functions/find"; +import { merge } from "../../functions/merge"; +import { uniq } from "../../functions/uniq"; +import { reduce } from "../../functions/reduce"; +import { map } from "../../functions/map"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { OpenAPIV3 } from "../../types/openapi"; +import { isString } from "../../types/String"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { RequestMappingObject } from "../types/RequestMappingObject"; +import { isRequestMethod} from "../types/RequestMethod"; +import { RequestMappingValue } from "../types/RequestMappingValue"; +import { RequestControllerMappingObject } from "../types/RequestControllerMappingObject"; +import { RequestParamValueType } from "../types/RequestParamValueType"; +import { RequestParamObject } from "../types/RequestParamObject"; +import { RequestBodyParamObject } from "../types/RequestBodyParamObject"; +import { RequestHeaderParamObject } from "../types/RequestHeaderParamObject"; +import { RequestQueryParamObject } from "../types/RequestQueryParamObject"; +import { RequestHeaderMapParamObject } from "../types/RequestHeaderMapParamObject"; +import { RequestParamObjectType } from "../types/RequestParamObjectType"; +import { RequestPathVariableParamObject } from "../types/RequestPathVariableParamObject"; +import { RequestPathVariableMapParamObject } from "../types/RequestPathVariableMapParamObject"; +import { DefaultHeaderMapValuesType } from "../types/DefaultHeaderMapValuesType"; +import { DefaultPathVariableMapValuesType } from "../types/DefaultPathVariableMapValuesType"; +import { RequestModelAttributeParamObject } from "../types/RequestModelAttributeParamObject"; + +const LOG = LogService.createLogger('RequestControllerUtils'); + +export class RequestControllerUtils { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + /** + * Filters items by type to separate properties in the return object + * + * @param value Array of paths (string) or methods (RequestMethod e.g. numbers) + * @returns RequestMappingObject with methods and paths separated + */ + public static parseRequestMappings (value : readonly RequestMappingValue[]) : RequestMappingObject { + return { + methods : filter(value, isRequestMethod), + paths : filter(value, isString) + }; + } + + /** + * Attach request mapping configuration into a controller directly. + * + * @param controller A controller. Usually an instance of class or the class itself (when static) + * @param config The request mapping configuration + */ + public static attachControllerMapping ( + controller : RequestController, + config : readonly RequestMappingValue[] + ) { + + const parsedObject = RequestControllerUtils.parseRequestMappings(config); + + // LOG.debug('attachControllerMapping: controller = ', controller); + // LOG.debug('attachControllerMapping: parsedObject = ', parsedObject); + + const origMapping : RequestControllerMappingObject | undefined = getInternalRequestMappingObject(controller, controller); + + // LOG.debug('attachControllerMapping: origMapping = ', origMapping); + + if (origMapping === undefined) { + + setInternalRequestMappingObject(controller, { + mappings: [parsedObject], + controllerProperties: {} + }); + + } else { + + setInternalRequestMappingObject(controller, { + ...origMapping, + mappings: concat([], origMapping.mappings, [parsedObject]) + }); + + } + + } + + /** + * Attach request mapping configuration for a method into a controller directly. + * + * @param controller A controller. Usually an instance of class or the class itself (when static) + * @param config The request mapping configuration + * @param propertyKey The name of the method + */ + public static attachControllerMethodMapping ( + controller : RequestController, + config : readonly RequestMappingValue[], + propertyKey : string + ) { + + const origMapping : RequestControllerMappingObject | undefined = getInternalRequestMappingObject(controller, controller); + + const parsedObject : RequestMappingObject = RequestControllerUtils.parseRequestMappings(config); + + if (origMapping === undefined) { + + setInternalRequestMappingObject(controller, { + mappings: [], + controllerProperties: { + [propertyKey] : { + mappings : [parsedObject], + params : [], + modelAttributes : [], + synchronized : false + } + } + }); + + } else if (!has(origMapping.controllerProperties, propertyKey)) { + + setInternalRequestMappingObject(controller, { + ...origMapping, + controllerProperties: { + ...origMapping.controllerProperties, + [propertyKey] : { + mappings : [parsedObject], + params : [], + modelAttributes : [], + synchronized : false + } + } + }); + + } else { + + setInternalRequestMappingObject(controller, { + ...origMapping, + controllerProperties: { + ...origMapping.controllerProperties, + [propertyKey] : { + ...origMapping.controllerProperties[propertyKey], + mappings: concat([parsedObject], origMapping.controllerProperties[propertyKey].mappings) + } + } + }); + + } + + } + + /** + * Find the controller from arbitrary variable. + * + * If provided with a class directly, will return the class itself. + * + * If provided with an instance of a class, will return the class instead. + * + * Otherwise, will return `undefined`. + * + * @param target + */ + public static findController (target : any) : RequestController | undefined { + if ( isFunction(target) && isRequestController(target) ) { + return target; + } + if ( isObject(target) && isFunction(target?.constructor) && isRequestController(target.constructor) ) { + return target.constructor; + } + return undefined; + } + + /** + * This method is used to configure how ModelAttribute is mapped to the + * method's parameter + * + * @param controller The controller to attach the configuration to + * @param propertyKey The method name + * @param paramIndex The index of the parameter + * @param attributeName The parameter name + * @param paramType The parameter type + */ + public static setControllerMethodModelAttributeParam ( + controller : RequestController, + propertyKey : string, + paramIndex : number, + attributeName : string, + paramType : RequestParamValueType + ) { + // LOG.debug('setControllerMethodModelAttributeParam: attributeName =', attributeName, paramType); + const newParam : RequestModelAttributeParamObject = { + objectType : RequestParamObjectType.MODEL_ATTRIBUTE, + attributeName : attributeName, + valueType : paramType + }; + RequestControllerUtils._setControllerMethodParam(controller, propertyKey, paramIndex, newParam); + } + + /** + * This function is used when the ModelAttribute decorator provides a PropertyDescriptor instead of a parameter + * number. (FIXME: Add better explanation. What is the actual use case from annotations?) + * + * @param controller + * @param propertyKey + * @param propertyDescriptor + * @param attributeName + * @see https://www.typescriptlang.org/docs/handbook/decorators.html#accessor-decorators + */ + public static attachControllerMethodModelAttributeBuilder ( + controller : RequestController, + propertyKey : string, + // @ts-ignore @TODO: Why not used? + propertyDescriptor : PropertyDescriptor, + attributeName : string + ) { + // LOG.debug('attachControllerMethodModelAttributeBuilder: attributeName =', attributeName, propertyKey); + const origMapping : RequestControllerMappingObject | undefined = getInternalRequestMappingObject(controller, controller); + if (origMapping === undefined) { + setInternalRequestMappingObject(controller, { + mappings: [], + controllerProperties: { + [propertyKey] : { + mappings : [], + params : [], + modelAttributes : [attributeName], + synchronized : false + } + } + }); + } else if (!has(origMapping.controllerProperties, propertyKey)) { + setInternalRequestMappingObject(controller, { + ...origMapping, + controllerProperties: { + ...origMapping.controllerProperties, + [propertyKey] : { + mappings : [], + params : [], + modelAttributes : [attributeName], + synchronized : false + } + } + }); + } else { + setInternalRequestMappingObject(controller, { + ...origMapping, + controllerProperties: { + ...origMapping.controllerProperties, + [propertyKey] : { + ...origMapping.controllerProperties[propertyKey], + modelAttributes: [ + attributeName, + ...origMapping.controllerProperties[propertyKey].modelAttributes + ] + } + } + }); + } + } + + /** + * Set query param configuration into the controller + * + * @param controller + * @param propertyKey + * @param paramIndex + * @param queryParam + * @param paramType + */ + public static setControllerMethodQueryParam ( + controller : RequestController, + propertyKey : string, + paramIndex : number, + queryParam : string | undefined, + paramType : RequestParamValueType + ) { + // LOG.debug('setControllerMethodQueryParam: queryParam =', queryParam, paramType); + const newParam : RequestQueryParamObject = { + objectType : RequestParamObjectType.QUERY_PARAM, + queryParam : queryParam, + valueType : paramType + }; + RequestControllerUtils._setControllerMethodParam(controller, propertyKey, paramIndex, newParam); + } + + /** + * This method is used to set the configuration for the HTTP header ( Request.header() ) annotation in to the controller. + * + * @param controller + * @param propertyKey + * @param paramIndex + * @param headerName + * @param paramType + * @param isRequired + * @param defaultValue + */ + public static setControllerMethodHeader ( + controller : RequestController, + propertyKey : string, + paramIndex : number, + headerName : string, + paramType : RequestParamValueType, + isRequired : boolean | undefined, + defaultValue : string | undefined + ) { + const newParam : RequestHeaderParamObject = { + objectType : RequestParamObjectType.REQUEST_HEADER, + headerName : headerName, + valueType : paramType, + isRequired : isRequired ?? false, + defaultValue : defaultValue + }; + RequestControllerUtils._setControllerMethodParam(controller, propertyKey, paramIndex, newParam); + } + + /** + * Set configuration for path variable into the controller. + * + * @param controller The controller + * @param propertyKey The method name + * @param paramIndex The index of the parameter + * @param variableName The variable name in the path + * @param paramType The variable type in the path + * @param isRequired True if variable is required + * @param decodeValue True if variable must be decoded + * @param defaultValue The default value if missing + */ + public static setControllerMethodPathVariable ( + controller : RequestController, + propertyKey : string, + paramIndex : number, + variableName : string, + paramType : RequestParamValueType, + isRequired : boolean | undefined, + decodeValue : boolean | undefined, + defaultValue : string | undefined + ) { + const newParam : RequestPathVariableParamObject = { + objectType : RequestParamObjectType.PATH_VARIABLE, + variableName : variableName, + valueType : paramType, + isRequired : isRequired ?? true, + decodeValue : decodeValue ?? true, + defaultValue : defaultValue + }; + RequestControllerUtils._setControllerMethodParam(controller, propertyKey, paramIndex, newParam); + } + + /** + * Sets configuration to pass on a full map of parameters into the controller's + * method argument at specified index. + * + * @param controller The controller + * @param propertyKey The method name + * @param paramIndex The index of the method's parameter + * @param defaultValues The default values if some parameters missing + */ + public static setControllerMethodPathVariableMap ( + controller : RequestController, + propertyKey : string, + paramIndex : number, + defaultValues : DefaultPathVariableMapValuesType | undefined + ) { + const newParam : RequestPathVariableMapParamObject = { + objectType : RequestParamObjectType.PATH_VARIABLE_MAP, + defaultValues : defaultValues + }; + RequestControllerUtils._setControllerMethodParam(controller, propertyKey, paramIndex, newParam); + } + + /** + * Sets configuration to pass on a full map of headers into the controller's + * method argument at specified index. + * + * Used at `Request.header()` + * + * @param controller The controller + * @param propertyKey The method name + * @param paramIndex The index of the method's parameter + * @param defaultValues The default values if some parameters missing + */ + public static setControllerMethodHeaderMap ( + controller : RequestController, + propertyKey : string, + paramIndex : number, + defaultValues : DefaultHeaderMapValuesType | undefined + ) { + const newParam : RequestHeaderMapParamObject = { + objectType : RequestParamObjectType.REQUEST_HEADER_MAP, + defaultValues : defaultValues + }; + RequestControllerUtils._setControllerMethodParam(controller, propertyKey, paramIndex, newParam); + } + + /** + * Sets configuration to pass on the request body into the controller's + * method argument at specified index. + * + * Used at `Request.body()` + * + * @param controller The controller + * @param propertyKey The method name + * @param paramIndex The index of the method's parameter + * @param paramType The type of the parameter + */ + public static setControllerMethodBodyParam ( + controller : RequestController, + propertyKey : string, + paramIndex : number, + paramType : RequestParamValueType + ) { + const newParam : RequestBodyParamObject = { + objectType : RequestParamObjectType.REQUEST_BODY, + valueType : paramType + }; + RequestControllerUtils._setControllerMethodParam(controller, propertyKey, paramIndex, newParam, true); + } + + /** + * Set OpenAPI document configuration into the controller directly + * + * @param controller + * @param config + */ + public static attachControllerOpenApiDocument ( + controller : RequestController, + config : Partial + ) : void { + + let mappingObject : RequestControllerMappingObject | undefined = getInternalRequestMappingObject(controller, controller); + if (mappingObject === undefined) { + // ...when no previous mapping found at all, we'll create from stretch + setInternalRequestMappingObject( + controller, + { + mappings: [], + controllerProperties: {}, + openApiPartials: [config] + } + ); + return; + } + + // LOG.debug('attachControllerOperation: mappingObject = ', mappingObject); + // LOG.debug('attachControllerOperation: config = ', config); + const openApiPartials = mappingObject?.openApiPartials ?? []; + setInternalRequestMappingObject( + controller, + { + ...mappingObject, + openApiPartials: [...openApiPartials, config] + } + ); + return; + } + + /** + * + * @param controller + * @param propertyKey + * @param config + */ + public static attachControllerOperation ( + controller : RequestController, + propertyKey : string | undefined, + config : Partial + ) : void { + + const operationId = config?.operationId ?? propertyKey; + + if ( !config?.operationId && operationId ) { + config = { + ...config, + operationId + }; + } + + let mappingObject : RequestControllerMappingObject | undefined = getInternalRequestMappingObject(controller, controller); + if (mappingObject === undefined) { + // ...when no previous mapping found at all, we'll create from stretch + if (propertyKey === undefined) { + setInternalRequestMappingObject( + controller, + { + mappings: [], + controllerProperties: {}, + operations: [config] + } + ); + } else { + setInternalRequestMappingObject( + controller, + { + mappings: [], + controllerProperties: { + [propertyKey]: { + modelAttributes: [], + mappings: [], + params: [], + operations: [config], + synchronized : false + } + } + } + ); + } + return; + } + + // LOG.debug('attachControllerOperation: propertyKey = ', propertyKey); + // LOG.debug('attachControllerOperation: mappingObject = ', mappingObject); + // LOG.debug('attachControllerOperation: config = ', config); + + if (propertyKey === undefined) { + // When property does not exist, append to root mapping + const operations = mappingObject?.operations ?? []; + setInternalRequestMappingObject( + controller, + { + ...mappingObject, + operations: [...operations, config] + } + ); + return; + } + + if (!has(mappingObject.controllerProperties, propertyKey)) { + // When mapping exists, but property does not, we'll create new property from stretch + setInternalRequestMappingObject(controller, { + ...mappingObject, + controllerProperties: { + ...mappingObject.controllerProperties, + [propertyKey] : { + mappings : [], + params : [], + modelAttributes : [], + operations : [config], + synchronized : false + } + } + }); + return; + } + + let operations : readonly Partial[] = mappingObject?.controllerProperties[propertyKey]?.operations ?? []; + + if (operationId) { + let operation : any = find(operations, (op: Partial) => op.operationId === operationId); + if (operation) { + + operations = filter( + operations, + (op: Partial) : boolean => op.operationId !== operationId + ); + + // tags?: string[]; + // summary?: string; + // description?: string; + // externalDocs?: ExternalDocumentationObject; + // operationId?: string; + // parameters?: (ReferenceObject | ParameterObject)[]; + // requestBody?: ReferenceObject | RequestBodyObject; + // responses: ResponsesObject; + // callbacks?: { [callback: string]: ReferenceObject | CallbackObject }; + // deprecated?: boolean; + // security?: SecurityRequirementObject[]; + // servers?: ServerObject[]; + + operation = { + ...(operation?.tags || config?.tags ? { tags : uniq(concat([], config?.tags ?? [], operation?.tags ?? [])) } : {}), + ...(config?.summary ? { summary : config?.summary } : {}), + ...(config?.description ? { description : config?.description } : {}), + ...(operation?.externalDocs || config?.externalDocs ? { externalDocs : merge({}, config?.externalDocs ?? {}, operation?.externalDocs ?? {}) } : {}), + ...(config?.operationId ? { operationId : config?.operationId } : {}), + ...(operation?.parameters || config?.parameters ? { parameters : mergeConcatByProperty('name', config?.parameters ?? [], operation?.parameters ?? []) } : {}), + ...(operation?.requestBody || config?.requestBody ? { requestBody : merge({}, config?.requestBody, operation?.requestBody) } : {}), + ...(operation?.responses || config?.responses ? { responses : merge({}, config?.responses ?? {}, operation?.responses ?? {} ) } : {}), + ...(operation?.callbacks || config?.callbacks ? { callbacks : merge({}, config?.callbacks ?? {}, operation?.callbacks ?? {} ) } : {}), + ...(operation?.deprecated || config?.deprecated ? { deprecated : config?.deprecated ?? operation?.deprecated } : {}), + ...(operation?.security || config?.security ? { security : concat([], config?.security ?? [], operation?.security ?? [] ) } : {}), + ...(operation?.servers || config?.servers ? { servers : mergeConcatByProperty("url", config?.servers ?? [], operation?.servers ?? [] ) } : {}), + }; + + setInternalRequestMappingObject( + controller, + { + ...mappingObject, + controllerProperties: { + ...mappingObject.controllerProperties, + [propertyKey] : { + ...mappingObject.controllerProperties[propertyKey], + operations: [ + operation, + ...operations + ] + } + } + } + ); + return; + } + } + + setInternalRequestMappingObject( + controller, + { + ...mappingObject, + controllerProperties: { + ...mappingObject.controllerProperties, + [propertyKey] : { + ...mappingObject.controllerProperties[propertyKey], + operations: [...operations, config] + } + } + } + ); + + } + + /** + * + * @param controller + * @param propertyKey + */ + public static attachControllerSynchronizedRequest ( + controller : RequestController, + propertyKey : string | undefined + ) : void { + + if (propertyKey === undefined) { + throw new TypeError(`Synchronizing all controller methods is not supported yet`); + } + + let mappingObject : RequestControllerMappingObject | undefined = getInternalRequestMappingObject(controller, controller); + if (mappingObject === undefined) { + // ...when no previous mapping found at all, we'll create from stretch + setInternalRequestMappingObject( + controller, + { + mappings: [], + controllerProperties: { + [propertyKey]: { + modelAttributes: [], + mappings: [], + params: [], + synchronized: true + } + } + } + ); + return; + } + + // LOG.debug('attachControllerOperation: propertyKey = ', propertyKey); + // LOG.debug('attachControllerOperation: mappingObject = ', mappingObject); + + if (!has(mappingObject.controllerProperties, propertyKey)) { + // When mapping exists, but property does not, we'll create new property from stretch + setInternalRequestMappingObject(controller, { + ...mappingObject, + controllerProperties: { + ...mappingObject.controllerProperties, + [propertyKey] : { + mappings : [], + params : [], + modelAttributes : [], + synchronized : true + } + } + }); + } else { + setInternalRequestMappingObject( + controller, + { + ...mappingObject, + controllerProperties: { + ...mappingObject.controllerProperties, + [propertyKey] : { + ...mappingObject.controllerProperties[propertyKey], + synchronized : true + } + } + } + ); + } + + } + + + private static _setControllerMethodParam ( + controller : RequestController, + propertyKey : string, + paramIndex : number, + newParam : RequestParamObject, + requestBodyRequired : boolean = false + ) { + const origMapping : RequestControllerMappingObject | undefined = getInternalRequestMappingObject(controller, controller); + if (origMapping === undefined) { + const params : readonly (RequestParamObject|null)[] = RequestControllerUtils._initializeParams(paramIndex, newParam); + if (requestBodyRequired) { + setInternalRequestMappingObject( + controller, + { + mappings: [], + controllerProperties: { + [propertyKey] : { + requestBodyRequired : true, + mappings : [], + modelAttributes : [], + params : params, + synchronized : false + } + } + } + ); + } else { + setInternalRequestMappingObject( + controller, + { + mappings: [], + controllerProperties: { + [propertyKey] : { + mappings : [], + modelAttributes : [], + params : params, + synchronized : false + } + } + } + ); + } + } else if (!has(origMapping.controllerProperties, propertyKey)) { + const params : readonly (RequestParamObject|null)[] = RequestControllerUtils._initializeParams(paramIndex, newParam); + if (requestBodyRequired) { + setInternalRequestMappingObject( + controller, + { + ...origMapping, + controllerProperties: { + ...origMapping.controllerProperties, + [propertyKey] : { + requestBodyRequired: true, + modelAttributes : [], + mappings : [], + params : params, + synchronized : false + } + } + } + ); + } else { + setInternalRequestMappingObject( + controller, + { + ...origMapping, + controllerProperties: { + ...origMapping.controllerProperties, + [propertyKey]: { + mappings: [], + modelAttributes : [], + params: params, + synchronized : false + } + } + } + ); + } + } else { + const params : (RequestParamObject|null)[] = RequestControllerUtils._reinitializeParams(origMapping, propertyKey, paramIndex, newParam); + if (requestBodyRequired) { + setInternalRequestMappingObject( + controller, + { + ...origMapping, + controllerProperties: { + ...origMapping.controllerProperties, + [propertyKey]: { + ...origMapping.controllerProperties[propertyKey], + requestBodyRequired: true, + params: params + } + } + } + ); + } else { + setInternalRequestMappingObject( + controller, + { + ...origMapping, + controllerProperties: { + ...origMapping.controllerProperties, + [propertyKey] : { + ...origMapping.controllerProperties[propertyKey], + params: params + } + } + } + ); + } + } + } + + private static _initializeParams ( + paramIndex : number, + newParam : RequestParamObject + ) : (RequestParamObject|null)[] { + let params : (RequestParamObject|null)[] = []; + while (paramIndex >= params.length) { + params.push(null); + } + params[paramIndex] = newParam; + return params; + } + + private static _reinitializeParams ( + origMapping : RequestControllerMappingObject, + propertyKey : string, + paramIndex : number, + newParam : RequestParamObject + ) : (RequestParamObject|null)[] { + let params : (RequestParamObject|null)[] = [ ...origMapping.controllerProperties[propertyKey].params ]; + while (paramIndex >= params.length) { + params.push(null); + } + params[paramIndex] = newParam; + return params; + } + +} + +function mergeConcatByProperty (propertyName : string, newValues: T[], prevValues: T[]) : T[] { + const allPropertyValues : string[] = uniq([ + ...map(newValues , (item: T) => has(item, propertyName) ? (item as any)[propertyName] : undefined ), + ...map(prevValues , (item: T) => has(item, propertyName) ? (item as any)[propertyName] : undefined ), + ]); + return reduce( + allPropertyValues, + (list: T[], propertyValue: string) : T[] => { + const newValue : T | undefined = find(newValues , (item: T) : boolean => has(item, propertyName) ? (item as any)[propertyName] === propertyValue : false); + const prevValue : T | undefined = find(prevValues, (item: T) : boolean => has(item, propertyName) ? (item as any)[propertyName] === propertyValue : false); + if (newValue === undefined) { + if (prevValue === undefined) return list; + list.push( prevValue ); + } else if (prevValue === undefined) { + list.push( newValue ); + } else { + list.push( merge(prevValue, newValue) ); + } + return list; + }, + [] + ) +} \ No newline at end of file diff --git a/request/utils/RequestInterfaceUtils.ts b/request/utils/RequestInterfaceUtils.ts new file mode 100644 index 0000000..fe43361 --- /dev/null +++ b/request/utils/RequestInterfaceUtils.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { has } from "../../functions/has"; +import { map } from "../../functions/map"; +import { filter } from "../../functions/filter"; +import { TestCallback } from "../../types/TestCallback"; +import { isFunction } from "../../types/Function"; +import { isObject } from "../../types/Object"; +import { keys } from "../../functions/keys"; +import { every } from "../../functions/every"; +import { some } from "../../functions/some"; + +export class RequestInterfaceUtils { + + static isObject (value : any) : value is {} { + return isObject(value); + } + + static hasPropertyMethods (value : any) : value is {methods: any} { + return has(value, 'methods'); + } + + static hasPropertyControllerProperties (value : any) : value is {controllerProperties: any} { + return has(value, 'controllerProperties'); + } + + static hasPropertyPaths (value : any) : value is {paths: any} { + return has(value, 'paths'); + } + + static hasPropertyParams (value : any) : value is {params: any} { + return has(value, 'params'); + } + + static hasPropertyModelAttributes (value : any) : value is {modelAttributes: any} { + return has(value, 'modelAttributes'); + } + + static hasPropertySynchronized (value : any) : value is {synchronized: any} { + return has(value, 'synchronized'); + } + + static hasPropertyMappings (value : any) : value is {mappings: any} { + return has(value, 'mappings'); + } + + static hasPropertyController (value : any) : value is {controller: any} { + return has(value, 'controller'); + } + + static hasPropertyQueryParam (value : any) : value is {queryParam: any} { + return has(value, 'queryParam'); + } + + static hasPropertyType (value : any) : value is {type: any} { + return has(value, 'type'); + } + + static hasProperty__requestMappings (value : any) : value is {__requestMappings: any} { + return has(value, '__requestMappings'); + } + + static hasPropertyStatus (value : any) : value is {status: any} { + return has(value, 'status'); + } + + static hasPropertyMessage (value : any) : value is {message: any} { + return has(value, 'message'); + } + + static createOrFunction ( + ...values: Array + ) : (item : any) => boolean { + + return (item : any) : boolean => { + + return some(values, (item2 : Function|any) : boolean => { + + if (isFunction(item2)) return item2(item); + + return item === item2; + + }); + + }; + + } + + static everyPropertyIs ( + value: {[key: string] : any}, + test : TestCallback + ) : value is {[key: string] : T} { + + return every(map(keys(value), (key : string) : any => value[key]), test); + + } + + static explainEveryPropertyIs ( + value: {[key: string] : any}, + test : Function, + explain : Function + ) : Array { + return filter( + map( + map(keys(value), (key : string) : any => value[key]), + (item: any, index: number) => { + if (!test(item)) { + return `#${index}: ${explain(item)}`; + } else { + return ""; + } + }), + (item: any) => !!item + ); + } + +} diff --git a/request/utils/RequestQueryParameters.ts b/request/utils/RequestQueryParameters.ts new file mode 100644 index 0000000..bd59d0d --- /dev/null +++ b/request/utils/RequestQueryParameters.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export interface RequestQueryParameters { + readonly [key: string]: string; +} + +export interface WritableRequestQueryParameters { + [key: string]: string; +} diff --git a/request/utils/RequestUtils.ts b/request/utils/RequestUtils.ts new file mode 100644 index 0000000..a90740c --- /dev/null +++ b/request/utils/RequestUtils.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestMethod } from "../types/RequestMethod"; +import { RequestMappingObject } from "../types/RequestMappingObject"; +import { some } from "../../functions/some"; + +export class RequestUtils { + + static someMethodsMatch ( + value: RequestMethod, + target: readonly RequestMethod[] + ) : boolean { + return some(target, (item : RequestMethod) : boolean => item === value); + } + + static somePathsMatch ( + path: string, + target: readonly string[] + ) : boolean { + return some(target, (item : string) : boolean => path.startsWith(item)); + } + + static requestMappingMatch ( + method: RequestMethod, + path: string, + mapping: RequestMappingObject + ) : boolean { + return ( + ( mapping.methods.length === 0 ? true : this.someMethodsMatch(method, mapping.methods) ) + && ( mapping.paths.length === 0 ? true : this.somePathsMatch(path, mapping.paths) ) + ); + } + +} diff --git a/requestClient/RequestClientAdapter.ts b/requestClient/RequestClientAdapter.ts new file mode 100644 index 0000000..638b5ef --- /dev/null +++ b/requestClient/RequestClientAdapter.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { RequestMethod } from "../request/types/RequestMethod"; +import { JsonAny } from "../Json"; +import { ResponseEntity } from "../request/types/ResponseEntity"; + +export interface RequestClientAdapter { + + jsonEntityRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise>; + + textEntityRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise>; + + jsonRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise; + + textRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: string + ) : Promise; + +} diff --git a/requestClient/fetch/FetchRequestClient.test.ts b/requestClient/fetch/FetchRequestClient.test.ts new file mode 100644 index 0000000..8ece5bc --- /dev/null +++ b/requestClient/fetch/FetchRequestClient.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from "@jest/globals"; +import { FetchInterface, FetchRequestClient } from "./FetchRequestClient"; +import { RequestMethod } from "../../request/types/RequestMethod"; +import { ContentType } from "../../request/types/ContentType"; + +describe("FetchRequestClient", () => { + + let mockFetch : FetchInterface; + let fetchRequestClient : FetchRequestClient; + + beforeEach(() => { + + const mockHeaders = { + get: jest.fn().mockReturnValue("value"), + set: jest.fn(), + forEach: jest.fn((cb: (value: string, key: string) => void) => { + cb(ContentType.JSON, 'Content-Type'); + cb('1234', 'Length'); + }) + }; + + mockFetch = jest.fn().mockResolvedValue( + { + ok: true, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + status: 200, + headers: mockHeaders, + statusText: "OK" + } + ); + fetchRequestClient = new FetchRequestClient(mockFetch); + }); + + describe("#jsonRequest", () => { + + it("makes a GET JSON request with the given url and headers", async () => { + const response = await fetchRequestClient.jsonRequest(RequestMethod.GET, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "GET", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.JSON + }, + credentials: "same-origin" + }); + expect(response).toStrictEqual({}); + }); + + it("makes a POST JSON request with the given url, headers, and data", async () => { + const response = await fetchRequestClient.jsonRequest(RequestMethod.POST, "http://example.com", {}, { key: "value" }); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "POST", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.JSON + }, + credentials: "same-origin", + body: '{"key":"value"}' + }); + expect(response).toStrictEqual({}); + }); + + it("makes a PUT JSON request with the given url, headers, and data", async () => { + const response = await fetchRequestClient.jsonRequest(RequestMethod.PUT, "http://example.com", {}, { key: "value" }); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "PUT", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.JSON + }, + credentials: "same-origin", + body: '{"key":"value"}' + }); + expect(response).toStrictEqual({}); + }); + + it("makes a DELETE JSON request with the given url, headers, and data", async () => { + const response = await fetchRequestClient.jsonRequest(RequestMethod.DELETE, "http://example.com", {}, { key: "value" }); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "DELETE", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.JSON + }, + credentials: "same-origin", + body: '{"key":"value"}' + }); + expect(response).toStrictEqual({}); + }); + + }); + + describe("#textRequest", () => { + + it("makes a GET text request with the given url and headers", async () => { + const response = await fetchRequestClient.textRequest(RequestMethod.GET, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "GET", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.TEXT + }, + credentials: "same-origin" + }); + expect(response).toBe(""); + }); + + it("makes a POST text request with the given url and headers", async () => { + const response = await fetchRequestClient.textRequest(RequestMethod.POST, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "POST", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.TEXT + }, + credentials: "same-origin" + }); + expect(response).toBe(""); + }); + + it("makes a PUT text request with the given url and headers", async () => { + const response = await fetchRequestClient.textRequest(RequestMethod.PUT, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "PUT", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.TEXT + }, + credentials: "same-origin" + }); + expect(response).toBe(""); + }); + + it("makes a DELETE text request with the given url and headers", async () => { + const response = await fetchRequestClient.textRequest(RequestMethod.DELETE, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "DELETE", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.TEXT + }, + credentials: "same-origin" + }); + expect(response).toBe(""); + }); + + }); + + describe("#jsonEntityRequest", () => { + + it("makes a GET JSON request with the given url and headers", async () => { + const response = await fetchRequestClient.jsonEntityRequest(RequestMethod.GET, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "GET", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.JSON + }, + credentials: "same-origin" + }); + expect(response).toBeDefined(); + expect( response.getHeaders().getFirst('Content-Type') ).toStrictEqual(ContentType.JSON); + expect(response.getStatusCode()).toStrictEqual(200); + expect(response.getBody()).toStrictEqual({}); + }); + + it("makes a POST JSON request with the given url, headers, and data", async () => { + const response = await fetchRequestClient.jsonEntityRequest(RequestMethod.POST, "http://example.com", {}, { key: "value" }); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "POST", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.JSON + }, + credentials: "same-origin", + body: '{"key":"value"}' + }); + expect(response).toBeDefined(); + expect( response.getHeaders().getFirst('Content-Type') ).toStrictEqual(ContentType.JSON); + expect(response.getStatusCode()).toStrictEqual(200); + expect(response.getBody()).toStrictEqual({}); + }); + + it("makes a PUT JSON request with the given url, headers, and data", async () => { + const response = await fetchRequestClient.jsonEntityRequest(RequestMethod.PUT, "http://example.com", {}, { key: "value" }); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "PUT", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.JSON + }, + credentials: "same-origin", + body: '{"key":"value"}' + }); + expect(response).toBeDefined(); + expect( response.getHeaders().getFirst('Content-Type') ).toStrictEqual(ContentType.JSON); + expect(response.getStatusCode()).toStrictEqual(200); + expect(response.getBody()).toStrictEqual({}); + }); + + it("makes a DELETE JSON request with the given url, headers, and data", async () => { + const response = await fetchRequestClient.jsonEntityRequest(RequestMethod.DELETE, "http://example.com", {}, { key: "value" }); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "DELETE", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.JSON + }, + credentials: "same-origin", + body: '{"key":"value"}' + }); + expect(response).toBeDefined(); + expect( response.getHeaders().getFirst('Content-Type') ).toStrictEqual(ContentType.JSON); + expect(response.getStatusCode()).toStrictEqual(200); + expect(response.getBody()).toStrictEqual({}); + }); + + }); + + describe("#textEntityRequest", () => { + + it("makes a GET text request with the given url and headers", async () => { + const response = await fetchRequestClient.textEntityRequest(RequestMethod.GET, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "GET", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.TEXT + }, + credentials: "same-origin" + }); + expect(response).toBeDefined(); + expect( response.getHeaders().getFirst('Content-Type') ).toStrictEqual(ContentType.JSON); + expect(response.getStatusCode()).toStrictEqual(200); + expect(response.getBody()).toBe(""); + }); + + it("makes a POST text request with the given url and headers", async () => { + const response = await fetchRequestClient.textEntityRequest(RequestMethod.POST, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "POST", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.TEXT + }, + credentials: "same-origin" + }); + expect(response).toBeDefined(); + expect( response.getHeaders().getFirst('Content-Type') ).toStrictEqual(ContentType.JSON); + expect(response.getStatusCode()).toStrictEqual(200); + expect(response.getBody()).toBe(""); + }); + + it("makes a PUT text request with the given url and headers", async () => { + const response = await fetchRequestClient.textEntityRequest(RequestMethod.PUT, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "PUT", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.TEXT + }, + credentials: "same-origin" + }); + expect(response).toBeDefined(); + expect( response.getHeaders().getFirst('Content-Type') ).toStrictEqual(ContentType.JSON); + expect(response.getStatusCode()).toStrictEqual(200); + expect(response.getBody()).toBe(""); + }); + + it("makes a DELETE text request with the given url and headers", async () => { + const response = await fetchRequestClient.textEntityRequest(RequestMethod.DELETE, "http://example.com", {}); + expect(mockFetch).toHaveBeenCalledWith("http://example.com", { + method: "DELETE", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": ContentType.TEXT + }, + credentials: "same-origin" + }); + expect(response).toBeDefined(); + expect( response.getHeaders().getFirst('Content-Type') ).toStrictEqual(ContentType.JSON); + expect(response.getStatusCode()).toStrictEqual(200); + expect(response.getBody()).toBe(""); + }); + + }); + +}); diff --git a/requestClient/fetch/FetchRequestClient.ts b/requestClient/fetch/FetchRequestClient.ts new file mode 100644 index 0000000..cc61d67 --- /dev/null +++ b/requestClient/fetch/FetchRequestClient.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { RequestMethod, stringifyRequestMethod } from "../../request/types/RequestMethod"; +import { JsonAny } from "../../Json"; +import { RequestClientAdapter } from "../RequestClientAdapter"; +import { RequestError } from "../../request/types/RequestError"; +import { ContentType } from "../../request/types/ContentType"; +import { ResponseEntity } from "../../request/types/ResponseEntity"; +import { Headers } from "../../request/types/Headers"; + +export interface FetchInterface { + (input: string, init?: RequestInit): Promise; +} + +export class FetchRequestClient implements RequestClientAdapter { + + private readonly _fetch : FetchInterface; + + constructor (fetch : FetchInterface) { + this._fetch = fetch; + } + + public async jsonRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise { + switch (method) { + case RequestMethod.GET: return await this._jsonRequest(RequestMethod.GET, url, headers, data).then((response : Response) => FetchRequestClient._successJsonResponse(response, RequestMethod.GET)); + case RequestMethod.POST: return await this._jsonRequest(RequestMethod.POST, url, headers, data).then((response : Response) => FetchRequestClient._successJsonResponse(response, RequestMethod.GET)); + case RequestMethod.PUT: return await this._jsonRequest(RequestMethod.PUT, url, headers, data).then((response : Response) => FetchRequestClient._successJsonResponse(response, RequestMethod.GET)); + case RequestMethod.DELETE: return await this._jsonRequest(RequestMethod.DELETE, url, headers, data).then((response : Response) => FetchRequestClient._successJsonResponse(response, RequestMethod.GET)); + default: throw new TypeError(`FetchRequestClient: Unsupported method: ${method}`); + } + } + + public async textRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: string + ) : Promise { + switch (method) { + case RequestMethod.GET: return await this._textRequest(RequestMethod.GET, url, headers, data).then((response : Response) => FetchRequestClient._successTextResponse(response, RequestMethod.GET)); + case RequestMethod.POST: return await this._textRequest(RequestMethod.POST, url, headers, data).then((response : Response) => FetchRequestClient._successTextResponse(response, RequestMethod.GET)); + case RequestMethod.PUT: return await this._textRequest(RequestMethod.PUT, url, headers, data).then((response : Response) => FetchRequestClient._successTextResponse(response, RequestMethod.GET)); + case RequestMethod.DELETE: return await this._textRequest(RequestMethod.DELETE, url, headers, data).then((response : Response) => FetchRequestClient._successTextResponse(response, RequestMethod.GET)); + default: throw new TypeError(`FetchRequestClient: Unsupported method: ${method}`); + } + } + + + public async jsonEntityRequest ( + method: RequestMethod, + url: string, + headers?: {[p: string]: string}, + data?: JsonAny + ): Promise> { + switch (method) { + case RequestMethod.GET: return await this._jsonRequest(RequestMethod.GET, url, headers, data).then((response : Response) => FetchRequestClient._successJsonEntityResponse(response, RequestMethod.GET)); + case RequestMethod.POST: return await this._jsonRequest(RequestMethod.POST, url, headers, data).then((response : Response) => FetchRequestClient._successJsonEntityResponse(response, RequestMethod.POST)); + case RequestMethod.PUT: return await this._jsonRequest(RequestMethod.PUT, url, headers, data).then((response : Response) => FetchRequestClient._successJsonEntityResponse(response, RequestMethod.PUT)); + case RequestMethod.DELETE: return await this._jsonRequest(RequestMethod.DELETE, url, headers, data).then((response : Response) => FetchRequestClient._successJsonEntityResponse(response, RequestMethod.DELETE)); + default: throw new TypeError(`FetchRequestClient: Unsupported method: ${method}`); + } + } + + public async textEntityRequest ( + method: RequestMethod, + url: string, + headers?: {[p: string]: string}, + data?: string + ): Promise> { + switch (method) { + case RequestMethod.GET: return await this._textRequest(RequestMethod.GET, url, headers, data).then((response: Response) => FetchRequestClient._successTextEntityResponse(response, RequestMethod.GET)); + case RequestMethod.POST: return await this._textRequest(RequestMethod.POST, url, headers, data).then((response: Response) => FetchRequestClient._successTextEntityResponse(response, RequestMethod.POST)); + case RequestMethod.PUT: return await this._textRequest(RequestMethod.PUT, url, headers, data).then((response: Response) => FetchRequestClient._successTextEntityResponse(response, RequestMethod.PUT)); + case RequestMethod.DELETE: return await this._textRequest(RequestMethod.DELETE, url, headers, data).then((response: Response) => FetchRequestClient._successTextEntityResponse(response, RequestMethod.DELETE)); + default: throw new TypeError(`FetchRequestClient: Unsupported method: ${method}`); + } + } + + private async _jsonRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: JsonAny + ) : Promise { + const options : RequestInit = { + method: FetchRequestClient._getMethod(method), + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': ContentType.JSON, + }, + credentials: 'same-origin' + }; + if (headers) { + options.headers = { + ...options.headers, + ...headers + }; + } + if (data) { + options.body = JSON.stringify(data); + } + return await this._fetch(url, options); + } + + private async _textRequest ( + method : RequestMethod, + url : string, + headers ?: {[key: string]: string}, + data ?: string + ) : Promise { + const options : RequestInit = { + method: FetchRequestClient._getMethod(method), + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': ContentType.TEXT, + }, + credentials: 'same-origin' + }; + if (headers) { + options.headers = { + ...options.headers, + ...headers + }; + } + if (data) { + options.body = data; + } + return await this._fetch(url, options); + } + + private static async _successJsonResponse ( + response: Response, + method: RequestMethod + ) : Promise { + const statusCode = response.status; + if ( !response.ok || (statusCode < 200 || statusCode >= 400) ) { + const url = response.url; + const message = `${statusCode}${ response.statusText ? ` "${response.statusText}"` : '' } for ${stringifyRequestMethod(method)} ${url}`; + //LOG.error(`Unsuccessful response with status ${statusCode}: `, response); + return response.json().then(body => { + throw new RequestError( + statusCode, + message, + method, + url, + body + ); + }); + } + return response.json(); + } + + private static async _successJsonEntityResponse ( + response: Response, + method: RequestMethod + ) : Promise> { + const statusCode = response.status; + if ( !response.ok || (statusCode < 200 || statusCode >= 400) ) { + const url = response.url; + const message = `${statusCode}${ response.statusText ? ` "${response.statusText}"` : '' } for ${stringifyRequestMethod(method)} ${url}`; + //LOG.error(`Unsuccessful response with status ${statusCode}: `, response); + const body = await response.json(); + throw new RequestError( + statusCode, + message, + method, + url, + body + ); + } + const body = await response.json(); + return ResponseEntity.ok(body).status(statusCode).headers(FetchRequestClient._parseResponseHeaders(response)); + } + + private static async _successTextResponse ( + response: Response, + method: RequestMethod + ) : Promise { + const statusCode = response.status; + if ( !response.ok || (statusCode < 200 || statusCode >= 400) ) { + const url = response.url; + const message = `${statusCode}${ response.statusText ? ` "${response.statusText}"` : '' } for ${stringifyRequestMethod(method)} ${url}`; + return response.text().then(body => { + throw new RequestError( + statusCode, + message, + method, + url, + body + ); + }); + } + return await response.text(); + } + + private static async _successTextEntityResponse ( + response: Response, + method: RequestMethod + ) : Promise> { + const statusCode = response.status; + if ( !response.ok || (statusCode < 200 || statusCode >= 400) ) { + const url = response.url; + const message = `${statusCode}${ response.statusText ? ` "${response.statusText}"` : '' } for ${stringifyRequestMethod(method)} ${url}`; + const body : string = await response.text(); + throw new RequestError( + statusCode, + message, + method, + url, + body + ); + } + const body : string = await response.text(); + return ResponseEntity.ok(body).status(statusCode).headers(FetchRequestClient._parseResponseHeaders(response)); + } + + private static _parseResponseHeaders (response : Response) : Headers { + const responseHeaders = response?.headers; + const headers : Headers = new Headers(); + if (responseHeaders) { + responseHeaders.forEach( + (value: string, key: string) => { + headers.set(key, value); + } + ); + } + return headers; + } + + private static _getMethod (method: RequestMethod) : string { + switch (method) { + case RequestMethod.OPTIONS : return 'OPTIONS'; + case RequestMethod.GET : return 'GET'; + case RequestMethod.POST : return 'POST'; + case RequestMethod.PUT : return 'PUT'; + case RequestMethod.DELETE : return 'DELETE'; + case RequestMethod.PATCH : return 'PATCH'; + case RequestMethod.TRACE : return 'TRACE'; + case RequestMethod.HEAD : return 'HEAD'; + } + throw new TypeError(`Unknown method: ${method}`); + } + +} diff --git a/requestClient/mock/MockRequestClient.ts b/requestClient/mock/MockRequestClient.ts new file mode 100644 index 0000000..a4ea452 --- /dev/null +++ b/requestClient/mock/MockRequestClient.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestClient } from "../../RequestClient"; +import { JsonAny } from "../../Json"; +import { ResponseEntity } from "../../request/types/ResponseEntity"; +import { RequestClientAdapter } from "../RequestClientAdapter"; +import { RequestMethod } from "../../request/types/RequestMethod"; +import { MockRequestClientAdapter } from "./MockRequestClientAdapter"; + +export class MockRequestClient implements RequestClient { + + public async deleteJson ( + // @ts-ignore + url: string, headers?: {[p: string]: string}, data?: JsonAny): Promise { + return undefined; + } + + public async deleteJsonEntity ( + // @ts-ignore + url: string, headers?: {[p: string]: string}, data?: JsonAny): Promise> { + return ResponseEntity.ok(); + } + + public async deleteText ( + // @ts-ignore + url: string, headers?: {[p: string]: string}, data?: string): Promise { + return undefined; + } + + public async deleteTextEntity ( + // @ts-ignore + url: string, headers?: {[p: string]: string}, data?: string): Promise> { + return ResponseEntity.ok(); + } + + public getClient (): RequestClientAdapter { + return new MockRequestClientAdapter(); + } + + public async getJson ( + // @ts-ignore + url: string, headers?: {[p: string]: string}): Promise { + return undefined; + } + + public async getJsonEntity ( + // @ts-ignore + url: string, headers?: {[p: string]: string}): Promise> { + return ResponseEntity.ok(); + } + + public async getText ( + // @ts-ignore + url: string, headers?: {[p: string]: string}): Promise { + return undefined; + } + + public async getTextEntity ( + // @ts-ignore + url: string, headers?: {[p: string]: string}): Promise> { + return ResponseEntity.ok(); + } + + public async jsonEntityRequest ( + // @ts-ignore + method: RequestMethod, url: string, headers?: {[p: string]: string}, data?: JsonAny): Promise> { + return ResponseEntity.ok(); + } + + public async jsonRequest ( + // @ts-ignore + method: RequestMethod, url: string, headers?: {[p: string]: string}, data?: JsonAny): Promise { + return undefined; + } + + public async patchJson ( + // @ts-ignore + url: string, data?: JsonAny, headers?: {[p: string]: string}): Promise { + return undefined; + } + + public async patchJsonEntity ( + // @ts-ignore + url: string, data?: JsonAny, headers?: {[p: string]: string}): Promise> { + return ResponseEntity.ok(); + } + + public async patchText ( + // @ts-ignore + url: string, data?: string, headers?: {[p: string]: string}): Promise { + return undefined; + } + + public async patchTextEntity ( + // @ts-ignore + url: string, data?: string, headers?: {[p: string]: string}): Promise> { + return ResponseEntity.ok(); + } + + public async postJson ( + // @ts-ignore + url: string, data?: JsonAny, headers?: {[p: string]: string}): Promise { + return undefined; + } + + public async postJsonEntity ( + // @ts-ignore + url: string, data?: JsonAny, headers?: {[p: string]: string}): Promise> { + return ResponseEntity.ok(); + } + + public async postText ( + // @ts-ignore + url: string, data?: string, headers?: {[p: string]: string}): Promise { + return undefined; + } + + public async postTextEntity ( + // @ts-ignore + url: string, data?: string, headers?: {[p: string]: string}): Promise> { + return ResponseEntity.ok(); + } + + public async putJson ( + // @ts-ignore + url: string, data?: JsonAny, headers?: {[p: string]: string}): Promise { + return undefined; + } + + public async putJsonEntity ( + // @ts-ignore + url: string, data?: JsonAny, headers?: {[p: string]: string}): Promise> { + return ResponseEntity.ok(); + } + + public async putText ( + // @ts-ignore + url: string, data?: string, headers?: {[p: string]: string}): Promise { + return undefined; + } + + public async putTextEntity ( + // @ts-ignore + url: string, data?: string, headers?: {[p: string]: string}): Promise> { + return ResponseEntity.ok(); + } + + public async textEntityRequest ( + // @ts-ignore + method: RequestMethod, url: string, headers?: {[p: string]: string}, data?: string): Promise> { + return ResponseEntity.ok(); + } + + public async textRequest ( + // @ts-ignore + method: RequestMethod, url: string, headers?: {[p: string]: string}, data?: string): Promise { + return undefined; + } + +} diff --git a/requestClient/mock/MockRequestClientAdapter.ts b/requestClient/mock/MockRequestClientAdapter.ts new file mode 100644 index 0000000..45f7de4 --- /dev/null +++ b/requestClient/mock/MockRequestClientAdapter.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestClientAdapter } from "../RequestClientAdapter"; +import { RequestMethod } from "../../request/types/RequestMethod"; +import { JsonAny } from "../../Json"; +import { ResponseEntity } from "../../request/types/ResponseEntity"; + +export class MockRequestClientAdapter implements RequestClientAdapter { + + public async jsonEntityRequest ( + // @ts-ignore + method: RequestMethod, + // @ts-ignore + url: string, + // @ts-ignore + headers?: {[p: string]: string}, + // @ts-ignore + data?: JsonAny + ): Promise> { + return ResponseEntity.ok(undefined); + } + + public async jsonRequest ( + // @ts-ignore + method: RequestMethod, + // @ts-ignore + url: string, + // @ts-ignore + headers?: {[p: string]: string}, + // @ts-ignore + data?: JsonAny + ): Promise { + return undefined; + } + + public async textEntityRequest ( + // @ts-ignore + method: RequestMethod, + // @ts-ignore + url: string, + // @ts-ignore + headers?: {[p: string]: string}, + // @ts-ignore + data?: JsonAny + ): Promise> { + return ResponseEntity.ok(undefined); + } + + public async textRequest ( + // @ts-ignore + method: RequestMethod, + // @ts-ignore + url: string, + // @ts-ignore + headers?: {[p: string]: string}, + // @ts-ignore + data?: string + ): Promise { + return undefined; + } + +} diff --git a/requestClient/request-client-constants.ts b/requestClient/request-client-constants.ts new file mode 100644 index 0000000..4f2dc43 --- /dev/null +++ b/requestClient/request-client-constants.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +export const NOR_REQUEST_CLIENT_MODE : string = process?.env?.NOR_REQUEST_CLIENT_MODE ?? process?.env?.REACT_APP_REQUEST_CLIENT_MODE ?? ''; + +export const REQUEST_CLIENT_FETCH_ENABLED : boolean = NOR_REQUEST_CLIENT_MODE === 'WINDOW' ? true : !!(typeof window !== 'undefined' && window.fetch); + +export const REQUEST_CLIENT_NODE_ENABLED : boolean = NOR_REQUEST_CLIENT_MODE === 'NODE' ? true : !REQUEST_CLIENT_FETCH_ENABLED; diff --git a/requestServer/RequestRouter.ts b/requestServer/RequestRouter.ts new file mode 100644 index 0000000..3cce627 --- /dev/null +++ b/requestServer/RequestRouter.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { RequestController } from "../request/types/RequestController"; +import { RequestMethod } from "../request/types/RequestMethod"; +import { Headers } from "../request/types/Headers"; +import { ResponseEntity } from "../request/types/ResponseEntity"; +import { ParseRequestBodyCallback } from "./types/ParseRequestBodyCallback"; + +export interface RequestRouter { + + /** + * Attach new controller to the router. + * + * @param controller + */ + attachController (controller : RequestController) : void; + + /** + * Handle a request with the router. + * + * @param methodString + * @param urlString + * @param parseRequestBody + * @param requestHeaders + */ + handleRequest ( + methodString : RequestMethod, + urlString : string | undefined, + parseRequestBody : ParseRequestBodyCallback | undefined, + requestHeaders : Headers + ) : Promise>; + +} diff --git a/requestServer/RequestRouterImpl.ts b/requestServer/RequestRouterImpl.ts new file mode 100644 index 0000000..827baeb --- /dev/null +++ b/requestServer/RequestRouterImpl.ts @@ -0,0 +1,902 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { getRequestControllerMappingObject, isRequestController, RequestController } from "../request/types/RequestController"; +import { parseRequestMethod, RequestMethod } from "../request/types/RequestMethod"; +import { filter } from "../functions/filter"; +import { forEach } from "../functions/forEach"; +import { has } from "../functions/has"; +import { isNull } from "../types/Null"; +import { map } from "../functions/map"; +import { trim } from "../functions/trim"; +import { reduce } from "../functions/reduce"; +import { concat } from "../functions/concat"; +import { find } from "../functions/find"; +import { keys } from "../functions/keys"; +import { some } from "../functions/some"; +import { RequestControllerMappingObject } from "../request/types/RequestControllerMappingObject"; +import { RequestMappingObject } from "../request/types/RequestMappingObject"; +import { isRequestStatus } from "../request/types/RequestStatus"; +import { RequestParamValueType } from "../request/types/RequestParamValueType"; +import { LogService } from "../LogService"; +import { RequestRouterMappingPropertyObject } from "./types/RequestRouterMappingPropertyObject"; +import { RequestRouterMappingObject } from "./types/RequestRouterMappingObject"; +import { RequestParamObject } from "../request/types/RequestParamObject"; +import { RequestControllerMethodObject } from "../request/types/RequestControllerMethodObject"; +import { RequestQueryParamObject } from "../request/types/RequestQueryParamObject"; +import { isReadonlyJsonAny, isReadonlyJsonArray, isReadonlyJsonObject, JsonAny, JsonObject, ReadonlyJsonObject } from "../Json"; +import { isResponseEntity, ResponseEntity } from "../request/types/ResponseEntity"; +import { isRequestError, RequestError } from "../request/types/RequestError"; +import { RequestParamObjectType } from "../request/types/RequestParamObjectType"; +import { RequestHeaderParamObject } from "../request/types/RequestHeaderParamObject"; +import { Headers } from "../request/types/Headers"; +import { DefaultHeaderMapValuesType } from "../request/types/DefaultHeaderMapValuesType"; +import { RequestHeaderMapParamObject } from "../request/types/RequestHeaderMapParamObject"; +import { RouteUtils } from "./utils/RouteUtils"; +import { BaseRoutes, RouteParamValuesObject } from "./types/BaseRoutes"; +import { RequestPathVariableParamObject } from "../request/types/RequestPathVariableParamObject"; +import { isRequestModelAttributeParamObject, RequestModelAttributeParamObject } from "../request/types/RequestModelAttributeParamObject"; +import { LogLevel } from "../types/LogLevel"; +import { RequestRouter } from "./RequestRouter"; +import { ParseRequestBodyCallback } from "./types/ParseRequestBodyCallback"; +import { RequestQueryParameters } from "../request/utils/RequestQueryParameters"; +import { parseRequestContextFromPath, RequestContext } from "./types/RequestContext"; +import { AsyncSynchronizer } from "../AsyncSynchronizer"; +import { AsyncSynchronizerImpl } from "../AsyncSynchronizerImpl"; + +const LOG = LogService.createLogger('RequestRouterImpl'); + +/** + * Three item array with following items: + * + * 1. Attribute name : string + * 2. Property name : string + * 3. Property arguments : (RequestParamObject | null)[] + * + */ +type ModelAttributeProperty = [string, string, (RequestParamObject | null)[]]; + +export class RequestRouterImpl implements RequestRouter { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _controllers : RequestController[]; + private _routes : BaseRoutes | undefined; + private _modelAttributeNames : Map | undefined; + private _requestMappings : readonly RequestControllerMappingObject[] | undefined; + private _initialized : boolean; + + /** + * The `AsyncSynchronizer` + * @private + */ + private readonly _asyncSynchronizer : Map; + + protected constructor () { + this._controllers = []; + this._asyncSynchronizer = new Map(); + this._routes = undefined; + this._requestMappings = undefined; + this._modelAttributeNames = undefined; + this._initialized = false; + } + + public static create ( + ...controllers: readonly any[] + ) : RequestRouterImpl { + const router = new RequestRouterImpl(); + forEach( + controllers, + (controller: any) => { + if (!isRequestController(controller)) { + throw new TypeError(`RequestRouter: The provided controller was not RequestController: ${controller}`); + } + router.attachController(controller); + } + ); + return router; + } + + public attachController (controller : RequestController) : void { + this._controllers.push(controller); + this._routes = undefined; + } + + public async handleRequest ( + methodString : RequestMethod, + urlString : string | undefined = undefined, + parseRequestBody : ParseRequestBodyCallback | undefined = undefined, + requestHeaders : Headers + ) : Promise> { + + try { + + const method : RequestMethod = parseRequestMethod(methodString); + + const { + pathName, + queryParams + } : RequestContext = parseRequestContextFromPath(urlString); + LOG.debug(`handleRequest: method="${method}", pathName="${pathName}", queryParams=`, queryParams); + + const requestPathName : string | undefined = pathName; + + const requestQueryParams : RequestQueryParameters = queryParams ?? {}; + // LOG.debug('requestQueryParams: ', requestQueryParams); + + if (requestPathName === undefined) { + LOG.error('handleRequest: requestPathName was not initialized'); + return ResponseEntity.internalServerError().body({ + error: 'Internal Server Error' + }); + } + + if ( !this._initialized ) { + this._initializeRouter(); + } + + const { + routes, + bodyRequired, + pathVariables + } : RequestContext = this._getRequestRoutesContext(method, requestPathName); + + if ( !parseRequestBody && bodyRequired ) { + LOG.error('handleRequest: parseRequestBody was not provided and body is required'); + return ResponseEntity.internalServerError().body({ + error: 'Internal Server Error' + }); + } + + if (routes === undefined) { + LOG.debug('handleRequest: No routes defined'); + return ResponseEntity.methodNotAllowed().body({ + error: 'Method Not Allowed' + }); + } + + if (routes.length === 0) { + LOG.debug('handleRequest: No routes found'); + return ResponseEntity.notFound().body({ + error: 'Not Found' + }); + } + + LOG.debug('routes: ', routes); + + let responseEntity : ResponseEntity | undefined = undefined; + + const requestBody = parseRequestBody && bodyRequired ? await parseRequestBody(requestHeaders) : undefined; + LOG.debug('handleRequest: requestBody: ', requestBody); + + const requestModelAttributes = new Map>(); + + // Handle requests using controllers + await reduce(routes, async (previousPromise, route: RequestRouterMappingPropertyObject) => { + + const routeController : any = route.controller; + const routePropertyName : string = route.propertyName; + const routePropertyParams : readonly (RequestParamObject | null)[] = route.propertyParams; + + const routeIndex : number = this._controllers.indexOf(routeController); + + await previousPromise; + + if ( this._modelAttributeNames && this._modelAttributeNames.has(routeController) ) { + + LOG.debug(`Populating attributes for property "${routePropertyName}"`); + + const modelAttributeValues : Map = RequestRouterImpl._getOrCreateRequestModelAttributesForController(requestModelAttributes, routeController); + + const routeAttributeNames : string[] = map( + filter(routePropertyParams, (item : any) : item is RequestModelAttributeParamObject => isRequestModelAttributeParamObject(item)), + (item : RequestModelAttributeParamObject) : string => item.attributeName + ); + LOG.debug('route attributeNames: ', routeAttributeNames); + + const allModelAttributeNamesForRouteController = this._modelAttributeNames.get(routeController); + LOG.debug('all attributeNamePairs: ', allModelAttributeNamesForRouteController); + + const attributeNamePairs : ModelAttributeProperty[] = filter( + allModelAttributeNamesForRouteController ?? [], + (item : ModelAttributeProperty) : boolean => routeAttributeNames.includes(item[0]) + ); + LOG.debug('attributeNamePairs: ', attributeNamePairs); + + await reduce(attributeNamePairs, async (p : Promise, pair : ModelAttributeProperty) : Promise => { + + const [attributeName, propertyName, propertyParams] = pair; + + await p; + + LOG.debug('attributeName: ', attributeName); + LOG.debug('propertyName: ', propertyName); + LOG.debug('propertyParams: ', propertyParams); + + const stepParams = RequestRouterImpl._bindRequestActionParams( + requestQueryParams, + requestBody, + propertyParams, + requestHeaders, + pathVariables, + modelAttributeValues + ); + + const stepResult : any = await routeController[propertyName](...stepParams); + + modelAttributeValues.set(attributeName, stepResult); + + }, Promise.resolve()); + + } + + const stepParams = RequestRouterImpl._bindRequestActionParams( + requestQueryParams, + requestBody, + routePropertyParams, + requestHeaders, + pathVariables, + requestModelAttributes.get(routeController) ?? new Map() + ); + LOG.debug('handleRequest: stepParams 1: ', stepParams); + + if (!has(routeController, routePropertyName)) { + LOG.warn(`Warning! No property by name "${routePropertyName}" found in the controller`); + responseEntity = ResponseEntity.notFound().body({error:"404 - Not Found", code: 404}); + return; + } + + let stepResult : any; + + // Lock processing if synchronized enabled + const isSynchronized : boolean = route.synchronized; + let synchronizerKey : string = ''; + let synchronizer : AsyncSynchronizer | undefined; + + if (isSynchronized) { + + // Initialize the queue + synchronizerKey = `controller-${routeIndex}-method-${routePropertyName}`; + LOG.debug(`handleRequest: synchronizer: `, synchronizerKey); + synchronizer = this._asyncSynchronizer.get(synchronizerKey); + if (!synchronizer) { + synchronizer = AsyncSynchronizerImpl.create(); + this._asyncSynchronizer.set(synchronizerKey, synchronizer); + } + LOG.debug(`handleRequest: Initialized request synchronizer [${synchronizerKey}]`); + + stepResult = await synchronizer.run(async () : Promise => { + LOG.debug(`handleRequest: Calling route property by name "${routePropertyName}"`); + return await routeController[routePropertyName](...stepParams); + }); + + } else { + LOG.debug(`handleRequest: Calling route property by name "${routePropertyName}"`); + stepResult = await routeController[routePropertyName](...stepParams); + } + + if (isRequestStatus(stepResult)) { + responseEntity = new ResponseEntity(stepResult); + } else if (isRequestError(stepResult)) { + responseEntity = new ResponseEntity(stepResult.toJSON(), stepResult.getStatusCode()); + } else if (isResponseEntity(stepResult)) { + // FIXME: What if we already have stepResult?? + if (responseEntity !== undefined) { + LOG.warn('Warning! ResponseEntity from previous call ignored.'); + } + responseEntity = stepResult; + } else if (isReadonlyJsonArray(stepResult)) { + + if (responseEntity === undefined) { + responseEntity = ResponseEntity.ok(stepResult); + } else { + responseEntity = new ResponseEntity( + concat(responseEntity.getBody(), stepResult), + responseEntity.getHeaders(), + responseEntity.getStatusCode() + ); + } + + } else if (isReadonlyJsonObject(stepResult)) { + + if (responseEntity === undefined) { + responseEntity = ResponseEntity.ok(stepResult); + } else { + responseEntity = new ResponseEntity( + { + ...responseEntity.getBody(), + ...stepResult + }, + responseEntity.getHeaders(), + responseEntity.getStatusCode() + ); + } + + } else if (isReadonlyJsonAny(stepResult)) { + + if (responseEntity === undefined) { + responseEntity = ResponseEntity.ok(stepResult); + } else { + LOG.warn('Warning! ResponseEntity from previous call ignored.'); + responseEntity = new ResponseEntity( + stepResult, + responseEntity.getHeaders(), + responseEntity.getStatusCode() + ); + } + + } else { + + if (responseEntity === undefined) { + responseEntity = ResponseEntity.ok(stepResult); + } else { + LOG.warn('Warning! ResponseEntity from previous call ignored.'); + responseEntity = new ResponseEntity( + stepResult, + responseEntity.getHeaders(), + responseEntity.getStatusCode() + ); + } + + } + + // LOG.debug('handleRequest: result changed: ', responseEntity); + + }, Promise.resolve()); + + LOG.debug('handleRequest: result finished: ' + responseEntity); + + // This never happens really, since 'routes' will always have more than one item at this point. + if (responseEntity === undefined) { + return ResponseEntity.noContent(); + } + + return responseEntity; + + } catch (err) { + + if (isRequestError(err)) { + const status = err?.status ?? 0; + if (status === 404) { + return ResponseEntity.notFound().body(err.toJSON()); + } + if (status >= 400 && status < 500) { + return ResponseEntity.badRequest().status(status).body(err.toJSON()); + } + return ResponseEntity.internalServerError().status(status).body(err.toJSON()); + } + + LOG.error('Exception: ', err); + + return ResponseEntity.internalServerError().body({ + error: 'Internal Server Error', + code: 500 + }); + + } + + } + + private _initializeRequestMappings () { + + LOG.debug('Initializing request mappings.'); + + if (!this._requestMappings) { + this._requestMappings = this._getRequestMappings(); + } + + } + + private _initializeRouter () { + + if (!this._initialized) { + + this._initialized = true; + + LOG.debug('Initializing...'); + + this._initializeRequestMappings(); + this._initializeRoutes(); + this._initializeRequiredModelAttributeNames(); + + } + + } + + private _initializeRoutes () { + + LOG.debug('Initializing routes.'); + + if ( this._requestMappings?.length ) { + this._routes = RouteUtils.createRoutes( RequestRouterImpl._parseMappingObject(this._requestMappings) ); + } else { + this._routes = RouteUtils.createRoutes( {} ); + } + + } + + private _initializeRequiredModelAttributeNames () { + + LOG.debug('Initializing model attributes.'); + + let values : [RequestController, ModelAttributeProperty[]][] = []; + + if ( this._requestMappings?.length ) { + values = reduce( + this._requestMappings, + (arr: [RequestController, ModelAttributeProperty[]][], item: RequestControllerMappingObject) => { + + const controller = item.controller; + + const controllerUniqueAttributeNames : ModelAttributeProperty[] = reduce( + keys(item.controllerProperties), + (arr2: ModelAttributeProperty[], propertyKey : string) : ModelAttributeProperty[] => { + + LOG.debug('_initializeRequiredModelAttributeNames: propertyKey: ', propertyKey); + + const propertyValue : RequestControllerMethodObject = item.controllerProperties[propertyKey]; + + const propertyAttributeNames : readonly string[] = propertyValue.modelAttributes; + + LOG.debug('_initializeRequiredModelAttributeNames: propertyAttributeNames: ', propertyAttributeNames); + + const params : (RequestParamObject|null)[] = [...propertyValue.params]; + + forEach(propertyAttributeNames, (attributeName : string) => { + LOG.debug('_initializeRequiredModelAttributeNames: attributeName: ', attributeName); + if ( find(arr2, (i : ModelAttributeProperty) => i[0] === attributeName) === undefined ) { + arr2.push([attributeName, propertyKey, params]); + } + }); + + return arr2; + + }, []); + + LOG.debug('controllerUniqueAttributeNames: ', controllerUniqueAttributeNames); + + values.push([controller, controllerUniqueAttributeNames]); + + return arr; + + }, values + ); + } + + this._modelAttributeNames = new Map(values); + + } + + private _getRequestRoutesContext ( + method : RequestMethod, + requestPathName : string + ) : RequestContext { + + if ( !this._routes || !this._routes.hasRoute(requestPathName) ) { + if (!this._routes) { + LOG.debug(`_getRequestRoutesContext: No routes defined`); + } else { + LOG.debug(`_getRequestRoutesContext: Routes did not match: `, requestPathName); + } + return { + routes: [], + bodyRequired: false + }; + } + + // LOG.debug('_getRequestRoutesContext: requestPathName: ', requestPathName); + // LOG.debug('_getRequestRoutesContext: method: ', method); + + let [routes, pathVariables] = this._routes.getRoute(requestPathName); + + routes = filter( + routes, + (item : RequestRouterMappingPropertyObject) : boolean => { + return item.methods.indexOf(method) >= 0; + } + ); + + // LOG.debug('_getRequestRoutesContext: routes: ', routes); + + if (!routes.length) { + // There were matching routes, but not for this method; Method not allowed. + LOG.debug(`_getRequestRoutesContext: There were matching routes, but not for this method; Method not allowed.`); + return { + routes: undefined, + bodyRequired: false + }; + } + + const requestBodyRequired = some(routes, item => item?.requestBodyRequired === true); + LOG.debug(`_getRequestRoutesContext: requestBodyRequired=`, requestBodyRequired); + return { + routes, + pathVariables, + bodyRequired: requestBodyRequired + }; + + } + + private _getRequestMappings () : RequestControllerMappingObject[] { + if (this._controllers.length === 0) { + return []; + } + return filter( + map( + this._controllers, + (controller : RequestController) => getRequestControllerMappingObject(controller) + ), + (item : RequestControllerMappingObject | undefined) : boolean => !!item + ) as RequestControllerMappingObject[]; + } + + private static _parseMappingObject ( + requestMappings : readonly RequestControllerMappingObject[] + ) : RequestRouterMappingObject { + + const routeMappingResult : RequestRouterMappingObject = {}; + + function setRouteMappingResult ( + path : string, + mapping : RequestRouterMappingPropertyObject + ) { + + if (!has(routeMappingResult, path)) { + routeMappingResult[path] = [mapping]; + return; + } + + routeMappingResult[path].push(mapping); + + } + + forEach(requestMappings, (rootItem : RequestControllerMappingObject) => { + + const controller = rootItem.controller; + const controllerProperties = rootItem.controllerProperties; + const controllerPropertyNames = keys(controllerProperties); + + if (rootItem.mappings.length > 0) { + + // Controller has root mappings + + forEach(rootItem.mappings, (rootMappingItem : RequestMappingObject) => { + + const rootMethods = rootMappingItem.methods; + + forEach(rootMappingItem.paths, (rootPath: string) => { + + forEach(controllerPropertyNames, (propertyKey: string) => { + + const propertyValue : RequestControllerMethodObject = controllerProperties[propertyKey]; + const propertyParams : readonly (RequestParamObject|null)[] = propertyValue.params; + + forEach(propertyValue.mappings, (propertyMappingItem : RequestMappingObject) => { + + // Filters away any property routes which do not have common methods + const propertyMethods : readonly RequestMethod[] = propertyMappingItem.methods; + + if (!RequestRouterImpl._matchMethods(rootMethods, propertyMethods)) { + return; + } + + const propertyMethodsCommonWithRoot : readonly RequestMethod[] = RequestRouterImpl._parseCommonMethods(rootMethods, propertyMethods); + + const propertyPaths : readonly string[] = propertyMappingItem.paths; + + forEach(propertyPaths, (propertyPath : string) => { + + const fullPropertyPath = RequestRouterImpl._joinRoutePaths(rootPath, propertyPath); + + setRouteMappingResult( + fullPropertyPath, + { + requestBodyRequired : propertyValue?.requestBodyRequired ?? false, + synchronized : propertyValue?.synchronized ?? false, + methods : propertyMethodsCommonWithRoot, + controller : controller, + propertyName : propertyKey, + propertyParams : propertyParams + } + ); + + }); + + }); + + }); + + }); + + }); + + } else { + + // We don't have parent controller mappings, so expect method mappings to be global. + + forEach(controllerPropertyNames, (propertyKey: string) => { + + const propertyValue : RequestControllerMethodObject = controllerProperties[propertyKey]; + const propertyParams : readonly (RequestParamObject|null)[] = propertyValue.params; + + forEach(propertyValue.mappings, (propertyMappingItem : RequestMappingObject) => { + + const propertyMethods : readonly RequestMethod[] = propertyMappingItem.methods; + const propertyPaths : readonly string[] = propertyMappingItem.paths; + + forEach(propertyPaths, (propertyPath : string) => { + + setRouteMappingResult( + propertyPath, + { + requestBodyRequired : propertyValue?.requestBodyRequired ?? false, + synchronized : propertyValue?.synchronized ?? false, + methods : propertyMethods, + controller : controller, + propertyName : propertyKey, + propertyParams : propertyParams + } + ); + + }); + + }); + + }); + + } + + }); + + return routeMappingResult; + + } + + private static _matchMethods ( + rootMethods : readonly RequestMethod[], + propertyMethods : readonly RequestMethod[] + ) : boolean { + + if (rootMethods.length === 0) return true; + + if (propertyMethods.length == 0) return true; + + return some(rootMethods, (rootMethod : RequestMethod) : boolean => { + return some(propertyMethods, (propertyMethod : RequestMethod) : boolean => { + return rootMethod === propertyMethod; + }); + }); + + } + + private static _parseCommonMethods ( + rootMethods : readonly RequestMethod[], + propertyMethods : readonly RequestMethod[] + ) : readonly RequestMethod[] { + return ( + rootMethods.length !== 0 + ? filter( + propertyMethods, + (propertyMethod: RequestMethod) : boolean => { + return some(rootMethods, (rootMethod : RequestMethod) : boolean => { + return rootMethod === propertyMethod; + }); + } + ) + : propertyMethods + ); + } + + private static _joinRoutePaths (a : string, b : string) : string { + + a = trim(a); + b = trim(trim(b), "/"); + + if (b.length === 0) return a; + if (a.length === 0) return b; + + if ( a[a.length - 1] === '/' || b[0] === '/' ) { + return a + b; + } + + return a + '/' + b; + + } + + private static _bindRequestActionParams ( + searchParams : RequestQueryParameters, + requestBody : JsonAny | undefined, + params : readonly (RequestParamObject|null)[], + requestHeaders : Headers, + pathVariables : RouteParamValuesObject | undefined, + modelAttributes : Map + ) : any[] { + return map(params, (item : RequestParamObject|null) : any => { + + if ( item === null ) { + return undefined; + } + + const objectType : RequestParamObjectType | undefined = item?.objectType; + + switch (objectType) { + + case RequestParamObjectType.REQUEST_BODY: + return requestBody; + + case RequestParamObjectType.QUERY_PARAM: { + const queryParamItem : RequestQueryParamObject = item as RequestQueryParamObject; + const key = queryParamItem.queryParam; + if (key === undefined) { + return RequestRouterImpl._castObjectParam(searchParams, queryParamItem.valueType); + } + if (!has(searchParams, key)) return undefined; + const value : string | null = searchParams[key]; + if (isNull(value)) return undefined; + return RequestRouterImpl._castStringParam(value, queryParamItem.valueType); + } + + case RequestParamObjectType.REQUEST_HEADER: { + + const headerItem : RequestHeaderParamObject = item as RequestHeaderParamObject; + + const headerName = headerItem.headerName; + + if (!requestHeaders.containsKey(headerName)) { + + if (headerItem.isRequired) { + throw new RequestError(400, `Bad Request: Header missing: ${headerName}`); + } + + return headerItem?.defaultValue ?? undefined; + + } + + const headerValue : string | undefined = requestHeaders.getFirst(headerName); + + if ( headerValue === undefined ) return undefined; + + return RequestRouterImpl._castStringParam(headerValue, headerItem.valueType); + + } + + case RequestParamObjectType.REQUEST_HEADER_MAP: { + + const headerItem : RequestHeaderMapParamObject = item as RequestHeaderMapParamObject; + + const defaultHeaders : DefaultHeaderMapValuesType | undefined = headerItem?.defaultValues; + + if (requestHeaders.isEmpty()) { + + if (defaultHeaders) { + return new Headers(defaultHeaders); + } else { + return new Headers(); + } + + } else { + + if (defaultHeaders) { + return new Headers( { + ...defaultHeaders, + ...requestHeaders.valueOf() + } ); + } else { + return requestHeaders.clone(); + } + + } + + } + + case RequestParamObjectType.PATH_VARIABLE: { + + const pathParamItem : RequestPathVariableParamObject = item as RequestPathVariableParamObject; + + const variableName = pathParamItem.variableName; + + const variableValue = pathVariables && has(pathVariables, variableName) ? pathVariables[variableName] : undefined; + + if ( variableValue !== undefined && variableValue !== '' ) { + + if (pathParamItem.decodeValue) { + return decodeURIComponent(variableValue); + } + + return variableValue; + + } else { + + if (pathParamItem.isRequired) { + throw new RequestError(404, `Not Found`); + } + + return pathParamItem.defaultValue ?? undefined; + } + + } + + case RequestParamObjectType.MODEL_ATTRIBUTE: { + + const modelAttributeItem : RequestModelAttributeParamObject = item as RequestModelAttributeParamObject; + + const key = modelAttributeItem.attributeName; + + return modelAttributes.has(key) ? modelAttributes.get(key) : undefined; + + } + + + } + + throw new TypeError(`Unsupported item type: ${item}`); + + }); + } + + private static _castStringParam ( + value : string, + type : RequestParamValueType + ) : any { + + switch (type) { + + case RequestParamValueType.REGULAR_OBJECT: + throw new TypeError(`Incorrect value type for string value: ${type}`); + + case RequestParamValueType.JSON: + return JSON.parse(value); + + case RequestParamValueType.STRING: + return value; + + case RequestParamValueType.INTEGER: + return parseInt(value, 10); + + case RequestParamValueType.NUMBER: + return parseFloat(value); + + } + + throw new TypeError(`Unsupported type: ${type}`) + } + + private static _castObjectParam ( + value : {readonly [key:string]: string}, + type : RequestParamValueType + ) : any { + switch (type) { + case RequestParamValueType.REGULAR_OBJECT: + return JSON.parse(JSON.stringify(value)); + + case RequestParamValueType.JSON: + case RequestParamValueType.STRING: + case RequestParamValueType.INTEGER: + case RequestParamValueType.NUMBER: + throw new TypeError(`Incorrect value type for non-string value: ${type}`); + + } + throw new TypeError(`Unsupported type: ${type}`) + } + + private static _getOrCreateRequestModelAttributesForController ( + requestModelAttributes : Map>, + routeController: any + ) : Map { + + let modelAttributeValues : Map | undefined = requestModelAttributes.get(routeController); + + if ( modelAttributeValues != undefined ) { + return modelAttributeValues; + } + + modelAttributeValues = new Map(); + requestModelAttributes.set(routeController, modelAttributeValues); + return modelAttributeValues; + + } + +} diff --git a/requestServer/types/BaseRoutes.ts b/requestServer/types/BaseRoutes.ts new file mode 100644 index 0000000..1a01f16 --- /dev/null +++ b/requestServer/types/BaseRoutes.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { RequestRouterMappingPropertyObject } from "./RequestRouterMappingPropertyObject"; + +export type RouteParamValuesObject = {[key : string]: string}; + +/** + * This result includes: + * - Routes mapping objects + * - Optional path parameters + */ +export type GetRouteResultType = [RequestRouterMappingPropertyObject[] | undefined, RouteParamValuesObject | undefined]; + +export interface BaseRoutes { + + hasRoute (pathName: string): boolean; + + getRoute (pathName: string): GetRouteResultType; + +} diff --git a/requestServer/types/ParamRoutes.ts b/requestServer/types/ParamRoutes.ts new file mode 100644 index 0000000..d4903b9 --- /dev/null +++ b/requestServer/types/ParamRoutes.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { BaseRoutes, GetRouteResultType, RouteParamValuesObject } from "./BaseRoutes"; +import { RequestRouterMappingObject } from "./RequestRouterMappingObject"; +import { find } from "../../functions/find"; +import { map } from "../../functions/map"; +import { trim } from "../../functions/trim"; +import { RequestRouterMappingPropertyObject } from "./RequestRouterMappingPropertyObject"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { isString } from "../../types/String"; +import { keys } from "../../functions/keys"; +import { every } from "../../functions/every"; +import { some } from "../../functions/some"; + +const LOG = LogService.createLogger('ParamRoutes'); + +interface CompiledRequestPathCallback { + (path: string) : GetRouteResultType; +} + +export class ParamRoutes implements BaseRoutes { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _routes: CompiledRequestPathCallback[]; + + public constructor (routes: RequestRouterMappingObject) { + + const routePaths : string[] = keys(routes); + + const routeFunctions : CompiledRequestPathCallback[] = map(routePaths, (itemKey : string) : CompiledRequestPathCallback => { + + const itemValue : RequestRouterMappingPropertyObject[] = routes[itemKey]; + + return ParamRoutes.createCallbackFunction(itemKey, itemValue); + + }); + + this._routes = routeFunctions; + + } + + public hasRoute (pathName: string): boolean { + // LOG.debug(`hasRoute: Looking up "${pathName}" from `, this._routes); + const found = some(this._routes, (f : CompiledRequestPathCallback) => { + const [r] = f(pathName); + return r !== undefined && r?.length >= 1; + }); + if (found) { + LOG.debug(`hasRoute: Looking up "${pathName}": Found`); + } else { + LOG.debug(`hasRoute: Looking up "${pathName}": Not Found`); + } + return found; + } + + public getRoute (pathName: string): GetRouteResultType { + + let lastResult : RequestRouterMappingPropertyObject[] | undefined = undefined; + let lastParams : RouteParamValuesObject | undefined = undefined; + + find( + this._routes, + (f : CompiledRequestPathCallback) => { + + let [result, params] = f(pathName); + + if (result !== undefined) { + lastResult = result; + lastParams = params; + return true; + } + + return false; + + } + ); + + if (lastResult !== undefined) { + return [lastResult, lastParams]; + } else { + return [undefined, undefined] + } + + } + + public static createCallbackFunction ( + itemKey : string, + itemValue : RequestRouterMappingPropertyObject[] + ) : CompiledRequestPathCallback { + + if (itemKey === '') { + itemKey = '/'; + } + + const formatParts = itemKey.split('/'); + const formatPartsLen = formatParts.length; + + if (formatPartsLen === 0) throw new Error('Path format had no items. This should not happen.'); + + const paramKeys : (string|null)[] = map(formatParts, (pathValue : string, pathIndex : number) => { + if ( pathValue.length >= 3 && pathValue[0] === '{' && pathValue[pathValue.length -1 ] === '}' ) { + return trim(pathValue.substr(1, pathValue.length - 2)); + } else { + formatParts[pathIndex] = pathValue.toLowerCase(); + return null; + } + }); + + const hasParamKeys : boolean = some(paramKeys, isString); + + if (!hasParamKeys) { + const staticPath = itemKey.toLowerCase(); + return (requestPath: string) : GetRouteResultType => { + if ( requestPath.toLowerCase() !== staticPath ) { + return [undefined, undefined]; + } else { + return [itemValue, undefined]; + } + }; + } + + return (requestPath: string) : GetRouteResultType => { + + const requestParts = requestPath.split('/'); + + const requestPartsLen = requestParts.length; + + // There should be at least one item always, since there should be at least one "/" + if ( requestPartsLen === 0 ) { + return [undefined, undefined]; + } + + if ( formatPartsLen !== requestPartsLen ) { + return [undefined, undefined]; + } + + let paramValues : RouteParamValuesObject = {}; + + if ( every(paramKeys, (paramKey: string | null, paramIndex: number): boolean => { + + if ( isString(paramKey) ) { + + paramValues[paramKey] = requestParts[paramIndex]; + + return true; + + } else { + + return formatParts[paramIndex] === requestParts[paramIndex].toLowerCase(); + + } + + }) ) { + return [ itemValue, paramValues ]; + } else { + return [ undefined, undefined ]; + } + + }; + + } + +} diff --git a/requestServer/types/ParseRequestBodyCallback.ts b/requestServer/types/ParseRequestBodyCallback.ts new file mode 100644 index 0000000..3d1583b --- /dev/null +++ b/requestServer/types/ParseRequestBodyCallback.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { Headers } from "../../request/types/Headers"; +import { JsonAny } from "../../Json"; + +export interface ParseRequestBodyCallback { + (headers: Headers): JsonAny | undefined | Promise; +} diff --git a/requestServer/types/RequestContext.test.ts b/requestServer/types/RequestContext.test.ts new file mode 100644 index 0000000..49c5165 --- /dev/null +++ b/requestServer/types/RequestContext.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseRequestContextFromPath } from "./RequestContext"; + +describe('RequestContext', () => { + describe('#parseRequestContextFromPath', () => { + + it('can parse URL path without query parameters', () => { + expect( parseRequestContextFromPath('/path/to') ).toStrictEqual({ + pathName: '/path/to' + }); + }); + + it('can parse URL path without query parameters with question mark but without parameters', () => { + expect( parseRequestContextFromPath('/path/to?') ).toStrictEqual({ + pathName: '/path/to' + }); + }); + + it('can parse URL path with query parameter', () => { + expect( parseRequestContextFromPath('/path/to?world=hello') ).toStrictEqual({ + pathName: '/path/to', + queryParams: { + world: 'hello' + } + }); + }); + + it('can parse URL path with multiple query parameters', () => { + expect( parseRequestContextFromPath('/path/to?world=hello&foo=bar') ).toStrictEqual({ + pathName: '/path/to', + queryParams: { + world: 'hello', + foo: 'bar' + } + }); + }); + + it('can parse URL path with multiple query parameters with escaping', () => { + expect( parseRequestContextFromPath('/path/to?message=hello+world&foo=bar') ).toStrictEqual({ + pathName: '/path/to', + queryParams: { + message: 'hello world', + foo: 'bar' + } + }); + }); + + it('can parse URL path with multiple query parameters with encoding', () => { + expect( parseRequestContextFromPath('/path/to?message=hello+world%20%28testing%29&foo=bar') ).toStrictEqual({ + pathName: '/path/to', + queryParams: { + message: 'hello world (testing)', + foo: 'bar' + } + }); + }); + + }); +}); diff --git a/requestServer/types/RequestContext.ts b/requestServer/types/RequestContext.ts new file mode 100644 index 0000000..d9201e8 --- /dev/null +++ b/requestServer/types/RequestContext.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { RequestMethod } from "../../request/types/RequestMethod"; +import { RequestQueryParameters, WritableRequestQueryParameters } from "../../request/utils/RequestQueryParameters"; +import { RequestRouterMappingPropertyObject } from "./RequestRouterMappingPropertyObject"; +import { RouteParamValuesObject } from "./BaseRoutes"; +import { values } from "../../functions/values"; +import { forEach } from "../../functions/forEach"; + +export interface RequestContext { + + readonly method?: RequestMethod; + readonly pathName?: string; + readonly queryParams?: RequestQueryParameters; + readonly routes?: readonly RequestRouterMappingPropertyObject[] | undefined; + readonly bodyRequired?: boolean; + readonly pathVariables?: RouteParamValuesObject; + +} + +function decodeQueryParam (p : string) { + return decodeURIComponent(p).replace(/\+/g, " "); +} + +export function parseRequestContextFromPath (urlString : string | undefined) : RequestContext { + + if (urlString === undefined) { + return { + pathName: '/' + }; + } + + const index = urlString.indexOf('?'); + if (index < 0) { + return { + pathName: urlString + }; + } + + const pathName = urlString.substring(0, index); + const queryData = urlString.substring(index+1); + if (queryData === '') { + return { pathName }; + } + + const params = queryData.split('&'); + + let queryParams : WritableRequestQueryParameters = {}; + forEach( + params, + (item: string) : void => { + const [ keyString, ...restOf ] = item.split('='); + const key = decodeQueryParam(keyString); + const value = decodeQueryParam(restOf.join('=')); + queryParams[key] = value; + } + ); + + return { + pathName, + ...(values(queryParams).length ? { queryParams } : {}) + }; +} diff --git a/requestServer/types/RequestHandler.ts b/requestServer/types/RequestHandler.ts new file mode 100644 index 0000000..62acf36 --- /dev/null +++ b/requestServer/types/RequestHandler.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +export interface RequestHandler { + (req: IncomingMessage, res: ServerResponse): void; +} diff --git a/requestServer/types/RequestRouterMappingObject.ts b/requestServer/types/RequestRouterMappingObject.ts new file mode 100644 index 0000000..9ec0da3 --- /dev/null +++ b/requestServer/types/RequestRouterMappingObject.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import {RequestRouterMappingPropertyObject} from "./RequestRouterMappingPropertyObject"; + +/** + * This object maps route paths to methods + */ +export interface RequestRouterMappingObject { + + /** + * The key is the route path + */ + [key: string]: Array; + +} diff --git a/requestServer/types/RequestRouterMappingPropertyObject.ts b/requestServer/types/RequestRouterMappingPropertyObject.ts new file mode 100644 index 0000000..5d4ade7 --- /dev/null +++ b/requestServer/types/RequestRouterMappingPropertyObject.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestMethod } from "../../request/types/RequestMethod"; +import { RequestParamObject } from "../../request/types/RequestParamObject"; + +export interface RequestRouterMappingPropertyObject { + readonly requestBodyRequired : boolean; + readonly synchronized : boolean; + readonly methods : readonly RequestMethod[]; + readonly controller : any; + readonly propertyName : string; + readonly propertyParams : readonly (RequestParamObject | null)[]; +} diff --git a/requestServer/types/ServerService.ts b/requestServer/types/ServerService.ts new file mode 100644 index 0000000..9d96173 --- /dev/null +++ b/requestServer/types/ServerService.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { RequestHandler } from "./RequestHandler"; + +export interface ServerService { + + start () : void; + + stop () : void; + + setHandler (newHandler : RequestHandler | undefined) : void; + +} diff --git a/requestServer/types/StaticRoutes.ts b/requestServer/types/StaticRoutes.ts new file mode 100644 index 0000000..86fc0ac --- /dev/null +++ b/requestServer/types/StaticRoutes.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { BaseRoutes, GetRouteResultType } from "./BaseRoutes"; +import { RequestRouterMappingObject } from "./RequestRouterMappingObject"; +import { map } from "../../functions/map"; +import { RequestRouterMappingPropertyObject} from "./RequestRouterMappingPropertyObject"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { keys } from "../../functions/keys"; + +const LOG = LogService.createLogger('StaticRoutes'); + +type MappingPropertyKeyValuePair = readonly [string, RequestRouterMappingPropertyObject[]]; + +export class StaticRoutes implements BaseRoutes { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private readonly _routes: Map; + + public constructor (routes: RequestRouterMappingObject) { + + const routePaths : string[] = keys(routes); + + const routePairs : MappingPropertyKeyValuePair[] = map(routePaths, (itemKey : string) : MappingPropertyKeyValuePair => { + + const itemValue : RequestRouterMappingPropertyObject[] = routes[itemKey]; + + return [itemKey.toLowerCase(), itemValue]; + + }); + + this._routes = new Map( routePairs ); + + } + + public hasRoute (pathName: string): boolean { + LOG.debug(`Looking up "${pathName}" from `, this._routes); + return this._routes.has(pathName.toLowerCase()); + } + + public getRoute (pathName: string): GetRouteResultType { + + const result = this._routes.get(pathName.toLowerCase()); + + if (result !== undefined) { + return [result, undefined]; + } + + return [undefined, undefined]; + + } + +} diff --git a/requestServer/utils/RouteUtils.ts b/requestServer/utils/RouteUtils.ts new file mode 100644 index 0000000..d0adae1 --- /dev/null +++ b/requestServer/utils/RouteUtils.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2020-2021. Sendanor . All rights reserved. + +import { RequestRouterMappingObject } from "../types/RequestRouterMappingObject"; +import { BaseRoutes } from "../types/BaseRoutes"; +import { ParamRoutes } from "../types/ParamRoutes"; +import { StaticRoutes } from "../types/StaticRoutes"; +import { keys } from "../../functions/keys"; +import { some } from "../../functions/some"; + +export class RouteUtils { + + public static createRoutes (routes : RequestRouterMappingObject) : BaseRoutes { + + if (RouteUtils.routesHasParameter(routes)) { + return new ParamRoutes(routes); + } + + return new StaticRoutes(routes); + + } + + public static pathHasParameter (value : string) : boolean { + return value.split('/').some((item : string) => { + return item.length >= 3 && item[0] === '{' && item[item.length - 1 ] === '}'; + }); + } + + public static routesHasParameter (routes : RequestRouterMappingObject) : boolean { + return some(keys(routes), RouteUtils.pathHasParameter); + } + +} diff --git a/samples/loading/LoadingAppDefinition.ts b/samples/loading/LoadingAppDefinition.ts new file mode 100644 index 0000000..f5b66e9 --- /dev/null +++ b/samples/loading/LoadingAppDefinition.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { AppDTO } from "../../entities/app/AppDTO"; +import { AppEntity } from "../../entities/app/AppEntity"; +import { createAnyRoute } from "./routes/AnyRoute"; +import { createLoadingRoute } from "./routes/LoadingRoute"; +import { createTextComponent } from "./components/TextComponent"; +import { createDefaultView } from "./views/DefaultView"; +import { createLoadingView } from "./views/LoadingView"; + +export const LOADING_ROUTE_NAME : string = 'LoadingRoute'; + +export type LoadingAppDefinition = AppDTO; + +export function createLoadingAppDefinition ( + myAppName: string, + publicUrl: string, + language: string +) : LoadingAppDefinition { + return ( + AppEntity.create(myAppName) + .setRoutes( + [ + createLoadingRoute(LOADING_ROUTE_NAME), + createAnyRoute(LOADING_ROUTE_NAME), + ] + ) + .setPublicUrl(publicUrl) + .setLanguage(language) + .setComponents([ + createTextComponent() + ]) + .setViews([ + createDefaultView(), + createLoadingView() + ]) + .getDTO() + ); +} diff --git a/samples/loading/components/Text.ts b/samples/loading/components/Text.ts new file mode 100644 index 0000000..f2b15b7 --- /dev/null +++ b/samples/loading/components/Text.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../../entities/component/ComponentEntity"; +import { TEXT_COMPONENT_NAME } from "./TextComponent"; + +export type Text = ComponentDTO; + +export function createText ( + name: string, + text: string, +) : Text { + return ( + ComponentEntity.create(name) + .extend(TEXT_COMPONENT_NAME) + .setContent([text]) + .getDTO() + ); +} diff --git a/samples/loading/components/TextComponent.ts b/samples/loading/components/TextComponent.ts new file mode 100644 index 0000000..6b3ea8b --- /dev/null +++ b/samples/loading/components/TextComponent.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../../entities/types/HyperComponent"; + +export const TEXT_COMPONENT_NAME: string = 'TextComponent'; + +export type TextComponent = ComponentDTO; + +export function createTextComponent () : TextComponent { + return ( + ComponentEntity.create(TEXT_COMPONENT_NAME) + .extend(HyperComponent.Article) + .setContent([]) + .getDTO() + ); +} diff --git a/samples/loading/constants/colors.ts b/samples/loading/constants/colors.ts new file mode 100644 index 0000000..d28f85f --- /dev/null +++ b/samples/loading/constants/colors.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export const DARK_TEXT_COLOR = '#ffffff'; +export const DARK_BACKGROUND_COLOR = '#222222'; diff --git a/samples/loading/routes/AnyRoute.ts b/samples/loading/routes/AnyRoute.ts new file mode 100644 index 0000000..95d0408 --- /dev/null +++ b/samples/loading/routes/AnyRoute.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { RouteDTO } from "../../../entities/route/RouteDTO"; +import { RouteEntity } from "../../../entities/route/RouteEntity"; + +export const ANY_ROUTE_NAME : string = 'AnyRoute'; + +export type AnyRoute = RouteDTO; + +export function createAnyRoute ( + redirect: string +) : AnyRoute { + return ( + RouteEntity.create(ANY_ROUTE_NAME, '*') + .setRedirect(redirect) + .getDTO() + ); +} diff --git a/samples/loading/routes/LoadingRoute.ts b/samples/loading/routes/LoadingRoute.ts new file mode 100644 index 0000000..2c170f4 --- /dev/null +++ b/samples/loading/routes/LoadingRoute.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { RouteDTO } from "../../../entities/route/RouteDTO"; +import { RouteEntity } from "../../../entities/route/RouteEntity"; +import { LOADING_VIEW_NAME } from "../views/LoadingView"; + +export type LoadingRoute = RouteDTO; + +export function createLoadingRoute ( + name: string, +) : LoadingRoute { + return ( + RouteEntity.create(name, '/') + .setView(LOADING_VIEW_NAME) + .getDTO() + ); +} diff --git a/samples/loading/views/DefaultView.ts b/samples/loading/views/DefaultView.ts new file mode 100644 index 0000000..461ec8e --- /dev/null +++ b/samples/loading/views/DefaultView.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ColorEntity } from "../../../entities/color/ColorEntity"; +import { ViewDTO } from "../../../entities/view/ViewDTO"; +import { StyleEntity } from "../../../entities/style/StyleEntity"; +import { ViewEntity } from "../../../entities/view/ViewEntity"; +import { DARK_BACKGROUND_COLOR, DARK_TEXT_COLOR } from "../constants/colors"; + +export const DEFAULT_VIEW_NAME: string = 'DefaultView'; + +export type DefaultView = ViewDTO; + +export function createDefaultView () : DefaultView { + return ( + ViewEntity.create(DEFAULT_VIEW_NAME) + .setStyle( + StyleEntity.create() + .setBackgroundColor( + ColorEntity.create(DARK_BACKGROUND_COLOR).getDTO() + ) + .setTextColor( + ColorEntity.create(DARK_TEXT_COLOR).getDTO() + ) + .getDTO() + ) + .getDTO() + ); +} diff --git a/samples/loading/views/LoadingView.ts b/samples/loading/views/LoadingView.ts new file mode 100644 index 0000000..1e8ea5b --- /dev/null +++ b/samples/loading/views/LoadingView.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { createViewDTO, ViewDTO } from "../../../entities/view/ViewDTO"; +import { createText } from "../components/Text"; +import { DEFAULT_VIEW_NAME } from "./DefaultView"; + +export const LOADING_VIEW_NAME: string = 'LoadingView'; + +export type LoadingView = ViewDTO; + +export function createLoadingView () : LoadingView { + return createViewDTO( + LOADING_VIEW_NAME, + DEFAULT_VIEW_NAME, + undefined, + undefined, + undefined, + [ + createText('loadingText', '...loading...'), + ], + undefined, + undefined, + ); +} diff --git a/samples/order/OrderAppDefinition.ts b/samples/order/OrderAppDefinition.ts new file mode 100644 index 0000000..adcd0c7 --- /dev/null +++ b/samples/order/OrderAppDefinition.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { AppDTO } from "../../entities/app/AppDTO"; +import { AppEntity } from "../../entities/app/AppEntity"; +import { createAnyRoute } from "./routes/AnyRoute"; +import { createLoginRoute } from "./routes/LoginRoute"; +import { createTextComponent } from "./components/TextComponent"; +import { createDefaultView } from "./views/DefaultView"; +import { createLoginView } from "./views/LoginView"; + +export const LOGIN_ROUTE_NAME : string = 'LoginRoute'; + +export type OrderAppDefinition = AppDTO; + +export function createOrderAppDefinitions ( + myAppName: string, + publicUrl: string, + language: string +) : OrderAppDefinition { + return ( + AppEntity.create(myAppName) + .setRoutes([ + createLoginRoute(LOGIN_ROUTE_NAME), + createAnyRoute(LOGIN_ROUTE_NAME), + ]) + .setPublicUrl(publicUrl) + .setLanguage(language) + .setComponents([ + createTextComponent(), + ]) + .setViews([ + createDefaultView(), + createLoginView(), + ]) + .getDTO() + ); +} diff --git a/samples/order/components/Text.ts b/samples/order/components/Text.ts new file mode 100644 index 0000000..30ccfa3 --- /dev/null +++ b/samples/order/components/Text.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../../entities/component/ComponentEntity"; +import { TEXT_COMPONENT_NAME } from "./TextComponent"; + +export type Text = ComponentDTO; + +export function createText ( + name: string, + text: string, +) : Text { + return ( + ComponentEntity.create(name) + .extend(TEXT_COMPONENT_NAME) + .setContent([ + text + ]) + .getDTO() + ); +} diff --git a/samples/order/components/TextComponent.ts b/samples/order/components/TextComponent.ts new file mode 100644 index 0000000..150b84d --- /dev/null +++ b/samples/order/components/TextComponent.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../../../entities/component/ComponentDTO"; +import { ComponentEntity } from "../../../entities/component/ComponentEntity"; +import { HyperComponent } from "../../../entities/types/HyperComponent"; + +export const TEXT_COMPONENT_NAME: string = 'TextComponent'; + +export type TextComponent = ComponentDTO; + +export function createTextComponent ( +) : TextComponent { + return ( + ComponentEntity.create(TEXT_COMPONENT_NAME) + .extend(HyperComponent.Article) + .setContent([]) + .getDTO() + ); +} diff --git a/samples/order/constants/colors.ts b/samples/order/constants/colors.ts new file mode 100644 index 0000000..d28f85f --- /dev/null +++ b/samples/order/constants/colors.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +export const DARK_TEXT_COLOR = '#ffffff'; +export const DARK_BACKGROUND_COLOR = '#222222'; diff --git a/samples/order/routes/AnyRoute.ts b/samples/order/routes/AnyRoute.ts new file mode 100644 index 0000000..95d0408 --- /dev/null +++ b/samples/order/routes/AnyRoute.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { RouteDTO } from "../../../entities/route/RouteDTO"; +import { RouteEntity } from "../../../entities/route/RouteEntity"; + +export const ANY_ROUTE_NAME : string = 'AnyRoute'; + +export type AnyRoute = RouteDTO; + +export function createAnyRoute ( + redirect: string +) : AnyRoute { + return ( + RouteEntity.create(ANY_ROUTE_NAME, '*') + .setRedirect(redirect) + .getDTO() + ); +} diff --git a/samples/order/routes/LoginRoute.ts b/samples/order/routes/LoginRoute.ts new file mode 100644 index 0000000..0797550 --- /dev/null +++ b/samples/order/routes/LoginRoute.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { RouteDTO } from "../../../entities/route/RouteDTO"; +import { RouteEntity } from "../../../entities/route/RouteEntity"; +import { LOGIN_VIEW_NAME } from "../views/LoginView"; + +export type LoginRoute = RouteDTO; + +export function createLoginRoute ( + name: string, +) : LoginRoute { + return ( + RouteEntity.create(name, '/') + .setView(LOGIN_VIEW_NAME) + .getDTO() + ); +} diff --git a/samples/order/views/DefaultView.ts b/samples/order/views/DefaultView.ts new file mode 100644 index 0000000..d2ca125 --- /dev/null +++ b/samples/order/views/DefaultView.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ViewDTO } from "../../../entities/view/ViewDTO"; +import { StyleEntity } from "../../../entities/style/StyleEntity"; +import { ViewEntity } from "../../../entities/view/ViewEntity"; +import { DARK_BACKGROUND_COLOR, DARK_TEXT_COLOR } from "../constants/colors"; + +export const DEFAULT_VIEW_NAME: string = 'DefaultView'; + +export type DefaultView = ViewDTO; + +export function createDefaultView () : DefaultView { + return ( + ViewEntity.create(DEFAULT_VIEW_NAME) + .setStyle( + StyleEntity.create() + .setTextColor( + DARK_TEXT_COLOR + ) + .setBackgroundColor( + DARK_BACKGROUND_COLOR + ) + .getDTO() + ) + .getDTO() + ); +} diff --git a/samples/order/views/LoginView.ts b/samples/order/views/LoginView.ts new file mode 100644 index 0000000..9a183c2 --- /dev/null +++ b/samples/order/views/LoginView.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { createViewDTO, ViewDTO } from "../../../entities/view/ViewDTO"; +import { createText } from "../components/Text"; +import { DEFAULT_VIEW_NAME } from "./DefaultView"; + +export const LOGIN_VIEW_NAME: string = 'LoginView'; + +export type LoginView = ViewDTO; + +export function createLoginView () : LoginView { + return createViewDTO( + LOGIN_VIEW_NAME, + DEFAULT_VIEW_NAME, + undefined, + undefined, + undefined, + [ + createText('project', 'Example'), + createText('appName', 'OrderApp'), + ], + undefined, + undefined, + ); +} diff --git a/services/ComponentFactory.ts b/services/ComponentFactory.ts new file mode 100644 index 0000000..8c39149 --- /dev/null +++ b/services/ComponentFactory.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ComponentDTO } from "../entities/component/ComponentDTO"; + +/** + * Type of Component DTO factory function. + */ +export interface ComponentFactoryFunction { + () : ComponentDTO; +} + +/** + * Interface for component DTO factories. + */ +export interface ComponentFactory { + + /** + * Check if a component has been registered. + * + * @param name The name of the component + */ + hasComponent ( + name : string, + ) : boolean; + + /** + * Register a component factory function. + * + * @param name The name of the component + * @param factoryFunction The factory function for HyperComponentDTO + */ + registerComponentConstructor ( + name : string, + factoryFunction : ComponentFactoryFunction, + ) : this; + + /** + * Unregister a component factory function. + * + * @param name The name of the component + */ + unregisterComponentConstructor ( + name : string, + ) : this; + + /** + * Creates a component DTO if name is registered. + * Otherwise, returns `undefined`. + * + * @param name The name of the component + */ + createComponentDTO (name : string) : ComponentDTO | undefined; + +} diff --git a/services/ComponentFactoryImpl.ts b/services/ComponentFactoryImpl.ts new file mode 100644 index 0000000..817ef4c --- /dev/null +++ b/services/ComponentFactoryImpl.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { has } from "../functions/has"; +import { ComponentDTO } from "../entities/component/ComponentDTO"; +import { ComponentFactoryService } from "./ComponentFactoryService"; +import { ComponentFactoryFunction, ComponentFactory } from "./ComponentFactory"; + +/** + * Implementation for a component DTO factory. + */ +export class ComponentFactoryImpl implements ComponentFactory { + + /** + * Internal object containing factory functions. + * + * @private + */ + private readonly _factories : { + [key: string]: ComponentFactoryFunction; + }; + + /** + * The protected constructor. + * + * Use `ComponentFactoryImpl.create()` to instantiate the class. + * + * @protected + */ + protected constructor () { + this._factories = {}; + } + + /** + * @inheritDoc + */ + hasComponent ( + name : string, + ) : boolean { + return has(this._factories, name); + } + + /** + * @inheritDoc + */ + public registerComponentConstructor ( + name : string, + factoryFunction : ComponentFactoryFunction, + ) : this { + this._factories[name] = factoryFunction; + return this; + } + + /** + * @inheritDoc + */ + public unregisterComponentConstructor ( + name : string, + ) : this { + if (has(this._factories, name)) { + delete this._factories[name]; + } + return this; + } + + /** + * @inheritDoc + */ + public createComponentDTO ( + name : string + ) : ComponentDTO | undefined { + if (has(this._factories, name)) { + return this._factories[name](); + } + return undefined; + } + + /** + * Creates a new (empty) instance of component factory. + */ + public static create () : ComponentFactory { + return new this(); + } + +} + +export function isComponentFactoryImpl (value: unknown): value is ComponentFactoryService { + return value instanceof ComponentFactoryImpl; +} diff --git a/services/ComponentFactoryService.ts b/services/ComponentFactoryService.ts new file mode 100644 index 0000000..7466720 --- /dev/null +++ b/services/ComponentFactoryService.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { has } from "../functions/has"; +import { ComponentDTO } from "../entities/component/ComponentDTO"; +import { ComponentFactoryImpl } from "./ComponentFactoryImpl"; +import { ComponentFactoryFunction, ComponentFactory } from "./ComponentFactory"; + +/** + * Global component factory service. + */ +export class ComponentFactoryService { + + /** + * + * @private + */ + private static _factory : ComponentFactory = ComponentFactoryImpl.create(); + + /** + * Check if a component has been registered. + * + * @param name The name of the component + */ + public static hasComponent ( + name : string, + ) : boolean { + return this._factory.hasComponent(name); + } + + /** + * Register a component factory function. + * + * @param name The name of the component + * @param factoryFunction The factory function for HyperComponentDTO + */ + public static registerComponentConstructor ( + name : string, + factoryFunction : ComponentFactoryFunction, + ) : ComponentFactory { + return this._factory.registerComponentConstructor(name, factoryFunction); + } + + /** + * Unregister a component factory function. + * + * @param name The name of the component + */ + public static unregisterComponentConstructor ( + name : string, + ) : ComponentFactory { + return this._factory.unregisterComponentConstructor(name); + } + + /** + * Creates a component DTO if name is registered. + * Otherwise, returns `undefined`. + * + * @param name The name of the component DTO + */ + public static createComponentDTO ( + name : string + ) : ComponentDTO | undefined { + return this._factory.createComponentDTO(name); + } + + /** + * Creates a new (empty) instance of component factory. + */ + public static create () : ComponentFactory { + return ComponentFactoryImpl.create(); + } + +} diff --git a/simpleRepository/SimpleHttpRepositoryClient.ts b/simpleRepository/SimpleHttpRepositoryClient.ts new file mode 100644 index 0000000..393274a --- /dev/null +++ b/simpleRepository/SimpleHttpRepositoryClient.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SimpleRepository } from "./types/SimpleRepository"; +import { SimpleRepositoryEntry } from "./types/SimpleRepositoryEntry"; +import { SimpleStoredRepositoryItem } from "./types/SimpleStoredRepositoryItem"; +import { LogLevel } from "../types/LogLevel"; +import { LogService } from "../LogService"; + +const LOG = LogService.createLogger('SimpleHttpRepositoryClient'); + +export class SimpleHttpRepositoryClient implements SimpleRepository { + + // private readonly _url : string; + private _loggedIn : boolean; + + public static setLogLevel (value : LogLevel | undefined) { + LOG.setLogLevel(value); + } + + public constructor ( + // @ts-ignore @TODO: Why not used? + url: string + ) { + // this._url = url; + this._loggedIn = false; + } + + public async createItem ( + data: T, + // @ts-ignore @TODO: Why not used? + members?: readonly string[] + ): Promise> { + return { + id: 'new', + data, + version: 0 + }; + } + + public async deleteById (id: string): Promise> { + return { + id, + data: { + id: '', + target: '' + } as T, + version: 0 + }; + } + + public async deleteByIdList ( + // @ts-ignore @TODO: Why not used? + list: readonly string[] + ): Promise[]> { + return []; + } + + public async deleteByList ( + // @ts-ignore @TODO: Why not used? + list: SimpleRepositoryEntry[] + ): Promise[]> { + return []; + } + + public async deleteAll (): Promise[]> { + return []; + } + + public async findById ( + // @ts-ignore @TODO: Why not used? + id: string, includeMembers?: boolean + ): Promise | undefined> { + return undefined; + } + + public async findByIdAndUpdate ( + id: string, item: T): Promise> { + return { + id, + data: item, + version: 0 + }; + } + + public async findByProperty ( + // @ts-ignore @TODO: Why not used? + propertyName: string, propertyValue: any + ): Promise | undefined> { + return undefined; + } + + public async getAll (): Promise[]> { + return []; + } + + public async getAllByProperty ( + // @ts-ignore @TODO: Why not used? + propertyName: string, propertyValue: any + ): Promise[]> { + return []; + } + + public async getSome ( + // @ts-ignore @TODO: Why not used? + idList: readonly string[] + ): Promise[]> { + return []; + } + + public async inviteToItem ( + // @ts-ignore @TODO: Why not used? + id: string, members: readonly string[] + ): Promise { + return undefined; + } + + public isRepositoryEntryList ( + // @ts-ignore @TODO: Why not used? + list: any + ): list is SimpleRepositoryEntry[] { + return false; + } + + public async subscribeToItem ( + // @ts-ignore @TODO: Why not used? + id: string + ): Promise { + return undefined; + } + + public async update (id: string, data: T): Promise> { + return { + id, + data, + version: 0 + }; + } + + public async updateOrCreateItem (item: T): Promise> { + return { + id: 'new', + data: item, + version: 0 + }; + } + + public async waitById ( + // @ts-ignore @TODO: Why not used? + id: string, includeMembers?: boolean, timeout?: number + ): Promise | undefined> { + return undefined; + } + + public async login ( + // @ts-ignore @TODO: Why not used? + username: string, + // @ts-ignore @TODO: Why not used? + password: string + ) { + this._loggedIn = true; + } + + public isLoggedIn() : boolean { + return this._loggedIn; + } + +} diff --git a/simpleRepository/SimpleMemoryRepository.test.ts b/simpleRepository/SimpleMemoryRepository.test.ts new file mode 100644 index 0000000..93a20cc --- /dev/null +++ b/simpleRepository/SimpleMemoryRepository.test.ts @@ -0,0 +1,240 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { isStoredRepositoryItem, SimpleStoredRepositoryItem } from "./types/SimpleStoredRepositoryItem"; +import { SimpleMemoryRepository } from "./SimpleMemoryRepository"; +import { SimpleRepositoryEntry } from "./types/SimpleRepositoryEntry"; +import { isJson } from "../Json"; +import { isArrayOf } from "../types/Array"; + +const test = { + id: "1", target: "test" +}; +const test2 = { + id: "2", target: "test2" +}; + +const uniqueTest = { + id: "22", target: "unique" +}; + + +interface StoredTestRepositoryItem extends SimpleStoredRepositoryItem { + + /** + * Unique ID + */ + readonly id: string; + + /** Current item data as JSON string */ + readonly target: string; + +} + + +describe('SimpleMemoryRepository', () => { + + describe('#construct', () => { + + it('can create memoryrepository', () => { + const memoryrepository = new SimpleMemoryRepository(isStoredRepositoryItem); + expect(memoryrepository).toBeDefined(); + }); + + }); + + describe('#getAll', () => { + + const memoryRepository: SimpleMemoryRepository = new SimpleMemoryRepository(isStoredRepositoryItem); + + + it('can list items when no items exists', async () => { + const list = await memoryRepository.getAll(); + expect(isArrayOf(list)).toBe(true); + expect(list?.length).toBe(0); + }); + + it('can create item', async () => { + + const item: SimpleRepositoryEntry = await memoryRepository.createItem(test); + + expect(item?.id).toBeDefined(); + expect(item?.id).not.toBe(''); + expect(item?.version).toBeDefined(); + expect(item?.version).not.toBe(''); + expect(item?.data).toBeDefined(); + expect(item?.data.id).toBe("1"); + expect(item?.data.target).toBe("test"); + + const list = await memoryRepository.getAll(); + + expect(isArrayOf(list)).toBe(true); + expect(list?.length).toBe(1); + expect(list[0]?.id).not.toBe(''); + expect(list[0]?.id).toBe(item?.id); + expect(list[0]?.data.id).toBe(item?.data.id); + expect(list[0]?.data.target).toBe(item?.data.target); + + // @ts-ignore @TODO: Why not used? + const item2: SimpleRepositoryEntry = await memoryRepository.createItem(test2); + + }); + + it('can find items: getSome, findById, getAllByProperty, findByProperty', async () => { + + const list = await memoryRepository.getAll(); + + expect(isArrayOf(list)).toBe(true); + expect(list?.length).toBe(2); + expect(list[1]?.id).not.toBe(''); + expect(list[1]?.data.id).toBe("2"); + expect(list[1]?.data.target).toBe("test2"); + + //getsome + const list1 = await memoryRepository.getSome([ list[0]?.id ]); + + expect(isArrayOf(list1)).toBe(true); + expect(list1[0]?.id).not.toBe(''); + expect(list1[0]?.data.id).toBe("1"); + expect(list1[0]?.data.target).toBe("test"); + + //findById + const item = await memoryRepository.findById(list[1]?.id); + + expect(isArrayOf(list1)).toBe(true); + expect(item?.id).not.toBe(''); + expect(item?.data.id).toBe("2"); + expect(item?.data.target).toBe("test2"); + + // @ts-ignore @TODO: Why not used? + const item3: SimpleRepositoryEntry = await memoryRepository.createItem(test2); + + //getAllByProperty + const propertyList = await memoryRepository.getAllByProperty('target', item?.data.target); + + expect(isArrayOf(propertyList)).toBe(true); + expect(propertyList?.length).toBe(2); + expect(propertyList[0].id).not.toBe(''); + expect(propertyList[0].data.target).toBe("test2"); + expect(propertyList[1].data.target).toBe("test2"); + + //findByProperty + const itemUnique: SimpleRepositoryEntry = await memoryRepository.createItem(uniqueTest); + const propertyItemUnique = await memoryRepository.findByProperty('target', itemUnique?.data.target); + + expect(isJson(propertyItemUnique)).toBe(true); + expect(isArrayOf(propertyItemUnique)).toBe(false); + expect(propertyItemUnique?.data.target).toBe("unique"); + + const propertyItemUniqueId = await memoryRepository.findByProperty('id', itemUnique?.data.id); + expect(propertyItemUnique?.data.id).toBe("22"); + expect(propertyItemUniqueId?.data.id).toBe("22"); + + }); + + + }); + + describe('modifications', () => { + + let id: string; + // let item4: SimpleRepositoryEntry; + // let modification: SimpleRepositoryEntry; + + let modificationData = {id: "11", target: "modification"}; + + const memoryrepository = new SimpleMemoryRepository(isStoredRepositoryItem); + + beforeEach(async () => { + + expect(memoryrepository).toBeDefined(); + + }); + + it('can update item by it id', async () => { + + const item4: SimpleRepositoryEntry = await memoryrepository.createItem(test); + const modification: SimpleRepositoryEntry = await memoryrepository.createItem(modificationData); + + id = item4?.id; + + expect(id).toBeDefined(); + expect(id).not.toBe(''); + expect(item4?.data.target).toBe("test"); + + //findByIdAndUpdate + let updatedItem = await memoryrepository.findByIdAndUpdate( + id, + modification.data + ); + expect(updatedItem?.id).toBe(id); + expect(updatedItem?.data.id).toBe("11"); + expect(updatedItem?.data.target).toBe("modification"); + + //update + updatedItem = await memoryrepository.update( + id, + test + ); + expect(updatedItem?.id).toBe(id); + expect(updatedItem?.data.id).toBe("1"); + expect(updatedItem?.data.target).toBe("test"); + + }); + + + }); + + describe('delete operations', () => { + let id: string; + // let item5: SimpleRepositoryEntry; + + const memoryrepository = new SimpleMemoryRepository(isStoredRepositoryItem); + + + it('can delete item by it id', async () => { + const item5: SimpleRepositoryEntry = await memoryrepository.createItem(test); + id = item5?.id; + + expect(id).toBeDefined(); + expect(id).not.toBe(''); + expect(item5?.data.target).toBe("test"); + + const deletedItem = await memoryrepository.deleteById(id); + + expect(deletedItem?.id).toBe(id); + expect(deletedItem?.data.id).toBe("1"); + expect(deletedItem?.data.target).toBe("test"); + expect(deletedItem?.deleted).toBe(true); + expect(deletedItem?.members).toBe(undefined); + + }); + + }); + + describe('wait by id', () => { + let id: string; + // let item5: SimpleRepositoryEntry; + + const memoryrepository = new SimpleMemoryRepository(isStoredRepositoryItem); + + + it('can wait and find item by it id', async () => { + const item5: SimpleRepositoryEntry = await memoryrepository.createItem(test); + id = item5?.id; + + expect(id).toBeDefined(); + expect(id).not.toBe(''); + expect(item5?.data.target).toBe("test"); + + const waitItem = await memoryrepository.waitById(id, false, undefined); + + expect(waitItem?.id).toBe(id); + + const waitItem2 = await memoryrepository.waitById(id, true, 100); + expect(waitItem2?.id).toBe(id); + + }); + + }); + +}); diff --git a/simpleRepository/SimpleMemoryRepository.ts b/simpleRepository/SimpleMemoryRepository.ts new file mode 100644 index 0000000..e379e6d --- /dev/null +++ b/simpleRepository/SimpleMemoryRepository.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { randomBytes } from "crypto"; +import { concat } from "../functions/concat"; +import { filter } from "../functions/filter"; +import { find } from "../functions/find"; +import { findIndex } from "../functions/findIndex"; +import { get } from "../functions/get"; +import { map } from "../functions/map"; +import { remove } from "../functions/remove"; +import { uniq } from "../functions/uniq"; +import { SimpleRepositoryEntry } from "./types/SimpleRepositoryEntry"; +import { SimpleRepository, REPOSITORY_NEW_IDENTIFIER } from "./types/SimpleRepository"; +import { SimpleStoredRepositoryItem, StoredRepositoryItemExplainCallback, StoredRepositoryItemTestCallback } from "./types/SimpleStoredRepositoryItem"; +import { SimpleMemoryItem } from "./types/SimpleMemoryItem"; +import { SimpleRepositoryUtils } from "./SimpleRepositoryUtils"; +import { explainNot, explainOk } from "../types/explain"; + +/** + * Saves objects of type T in memory. + * + * Intended to be used for development purposes. + * + * See also + * [MatrixCrudRepository](https://github.com/heusalagroup/fi.hg.matrix/blob/main/MatrixCrudRepository.ts) + */ +export class SimpleMemoryRepository implements SimpleRepository { + + private readonly _members : readonly string[]; + private readonly _isT : StoredRepositoryItemTestCallback; + private readonly _explainT : StoredRepositoryItemExplainCallback; + private readonly _tName : string; + + private _items : readonly SimpleMemoryItem[]; + + /** + * + * @param members Array of members to add in any item created + * @param isT Test function for T type + * @param explainT Function to explain if isT fails + * @param tName The name of the T type for debugging purposes. Defaults to "T". + */ + public constructor ( + isT : StoredRepositoryItemTestCallback, + members : readonly string[] = [], + tName : string | undefined = undefined, + explainT : StoredRepositoryItemExplainCallback | undefined = undefined + ) { + this._members = members; + this._items = []; + this._isT = isT; + this._tName = tName ?? 'T'; + this._explainT = explainT ?? ( (value: any) : string => isT(value) ? explainOk() : explainNot(this._tName) ); + } + + public async getAll () : Promise[]> { + return map(this._items, (item) : SimpleRepositoryEntry => ({ + id : item.id, + version : item.version, + data : item.data, + deleted : item.deleted, + members : undefined + })); + } + + public async getAllByProperty ( + propertyName : string, + propertyValue : any + ): Promise[]> { + + const items : readonly SimpleMemoryItem[] = this._items; + + const filteredItems : readonly SimpleMemoryItem[] = filter( + items, + (item: SimpleMemoryItem) : boolean => get(item?.data, propertyName) === propertyValue + ); + + return map( + filteredItems, + (item: SimpleMemoryItem) : SimpleRepositoryEntry => ({ + id : item.id, + version : item.version, + data : item.data, + deleted : item.deleted, + members : undefined + }) + ); + + } + + public async createItem ( + data: T, + members : string[] = [] + ) : Promise> { + + const id = SimpleMemoryRepository._createId(); + + const existingItem = find(this._items, item => item.id === id); + + if (existingItem) throw new Error(`MemoryRepository: ID "${id}" was not unique`); + + const item : SimpleMemoryItem = { + id : SimpleMemoryRepository._createId(), + version : 1, + data : data, + deleted : false, + members : uniq(concat([], members ? members : [], this._members)) + }; + + this._items = [...this._items, item]; + + return { + id : item.id, + version : item.version, + data : item.data, + deleted : item.deleted, + members : item.members ? map(item.members, id => ({id})) : undefined + }; + + } + + public async findById ( + id : string, + includeMembers ?: boolean + ) : Promise | undefined> { + const item = find(this._items, form => form.id === id); + if (item === undefined) return undefined; + return { + id : item.id, + version : item.version, + data : item.data, + deleted : item.deleted, + members : includeMembers && item.members?.length ? map(item.members, id => ({id})) : undefined + }; + } + + public async findByProperty ( + propertyName : string, + propertyValue : any + ) : Promise | undefined> { + const result = await this.getAllByProperty(propertyName, propertyValue); + const resultCount : number = result?.length ?? 0; + if (resultCount === 0) return undefined; + if (resultCount >= 2) throw new TypeError(`MemoryRepository.findByProperty: Multiple items found by property "${propertyName}" as: ${propertyValue}`); + return result[0]; + } + + public async findByIdAndUpdate (id: string, item: T): Promise> { + const rItem : SimpleRepositoryEntry | undefined = await this.findById(id); + if (rItem === undefined) throw new TypeError(`MemoryRepository.findByIdAndUpdate: Could not find item for "${id}"`); + return await this.update(rItem.id, item); + } + + public async waitById ( + id : string, + includeMembers ?: boolean, + timeout ?: number + ): Promise | undefined> { + // FIXME: Implement real long polling + return new Promise((resolve, reject) => { + try { + setTimeout( + () => { + try { + resolve(this.findById(id, includeMembers)); + } catch (err) { + reject(err); + } + }, + timeout ?? 4000 + ); + } catch (err) { + reject(err); + } + }); + } + + public async update (id: string, data: T) : Promise> { + + const itemIndex = findIndex(this._items, item => item.id === id); + if (itemIndex < 0) throw new TypeError(`No item found: ${id}`); + + const prevItem = this._items[itemIndex]; + + const nextItem = { + ...prevItem, + version: prevItem.version + 1, + data: data + }; + + this._setItemByIndex(itemIndex, nextItem); + + return { + id : nextItem.id, + version : nextItem.version, + data : nextItem.data, + deleted : nextItem.deleted, + members : undefined + }; + + } + + public async deleteById (id: string) : Promise> { + + const items = remove(this._items, item => item.id === id); + const item = items.shift(); + + if (item === undefined) { + throw new TypeError(`Could not find item: ${id}`); + } + + return { + id: item.id, + data: item.data, + version: item.version + 1, + deleted: true, + members: undefined + }; + + } + + public async inviteToItem ( + id : string, + members : readonly string[] + ): Promise { + + const itemIndex = findIndex(this._items, item => item.id === id); + if (itemIndex < 0) throw new TypeError(`No item found: ${id}`); + + const prevItem = this._items[itemIndex]; + + const prevMembers = prevItem?.members ?? []; + + const nextItem = { + ...prevItem, + invited: filter( + uniq( + concat( + [], + prevItem?.invited ?? [], + members + ) + ), + (item : string) => !prevMembers.includes(item) + ) + }; + + this._setItemByIndex(itemIndex, nextItem); + + } + + public async subscribeToItem (id: string): Promise { + + const itemIndex = findIndex(this._items, item => item.id === id); + if (itemIndex < 0) throw new TypeError(`No item found: ${id}`); + + const prevItem : SimpleMemoryItem = this._items[itemIndex]; + const prevMembers = prevItem?.members ?? []; + const prevInvited = prevItem?.invited ?? []; + + const nextItem : SimpleMemoryItem = { + ...prevItem, + members: concat(prevMembers, prevInvited), + invited: [] + }; + + this._setItemByIndex(itemIndex, nextItem); + + } + + private static _createId () : string { + return randomBytes(20).toString('hex'); + } + + private _setItemByIndex ( + itemIndex : number, + newItem : SimpleMemoryItem + ) { + this._items = map( + this._items, + (item : SimpleMemoryItem, i: number) : SimpleMemoryItem => i === itemIndex ? newItem : item + ); + } + + public async deleteByIdList (list: readonly string[]): Promise[]> { + const results = []; + let i = 0; + for (; i < list.length; i += 1) { + results.push( await this.deleteById(list[i]) ); + } + if (!this.isRepositoryEntryList(results)) { + throw new TypeError(`MemoryRepository.getSome: Illegal data from database: Not RepositoryEntryList: ${ + this.explainRepositoryEntryList(results) + }`); + } + return results; + } + + public async deleteByList (list: SimpleRepositoryEntry[]): Promise[]> { + return await this.deleteByIdList( map(list, item => item.id) ); + } + + public async deleteAll (): Promise[]> { + return await this.deleteByIdList( map(this._items, item => item.id) ); + } + + public async getSome (idList: readonly string[]): Promise[]> { + const allList : readonly SimpleRepositoryEntry[] = await this.getAll(); + const list = filter( + allList, + (item : SimpleRepositoryEntry) : boolean => !!item?.id && idList.includes(item?.id) + ); + if (!this.isRepositoryEntryList(list)) { + throw new TypeError(`MemoryRepository.getSome: Illegal data from database: Not RepositoryEntryList: ${ + this.explainRepositoryEntryList(list) + }`); + } + return list; + } + + public isRepositoryEntryList (list: any): list is SimpleRepositoryEntry[] { + return SimpleRepositoryUtils.isRepositoryEntryList(list, this._isT); + } + + public explainRepositoryEntryList (list: any): string { + return SimpleRepositoryUtils.explainRepositoryEntryList(list, this._isT, this._explainT, this._tName); + } + + public async updateOrCreateItem (item: T): Promise> { + const id = item.id; + const foundItem : SimpleRepositoryEntry | undefined = id !== REPOSITORY_NEW_IDENTIFIER ? await this.findById(id) : undefined; + if (foundItem) { + return await this.update(foundItem.id, item); + } else { + return await this.createItem(item); + } + } + +} diff --git a/simpleRepository/SimpleMemoryRepositoryClient.ts b/simpleRepository/SimpleMemoryRepositoryClient.ts new file mode 100644 index 0000000..2ba19a4 --- /dev/null +++ b/simpleRepository/SimpleMemoryRepositoryClient.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SimpleRepositoryClient } from "./types/SimpleRepositoryClient"; + +export class SimpleMemoryRepositoryClient implements SimpleRepositoryClient { + +} diff --git a/simpleRepository/SimpleMemoryRepositoryInitializer.ts b/simpleRepository/SimpleMemoryRepositoryInitializer.ts new file mode 100644 index 0000000..4fefc50 --- /dev/null +++ b/simpleRepository/SimpleMemoryRepositoryInitializer.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SimpleMemoryRepository } from "./SimpleMemoryRepository"; +import { SimpleStoredRepositoryItem, StoredRepositoryItemExplainCallback, StoredRepositoryItemTestCallback } from "./types/SimpleStoredRepositoryItem"; +import { SimpleRepositoryInitializer } from "./types/SimpleRepositoryInitializer"; +import { SimpleRepository } from "./types/SimpleRepository"; +import { explainNot, explainOk } from "../types/explain"; + +export class SimpleMemoryRepositoryInitializer implements SimpleRepositoryInitializer { + + private readonly _isT : StoredRepositoryItemTestCallback; + private readonly _explainT : StoredRepositoryItemExplainCallback; + private readonly _tName : string; + + public constructor ( + isT : StoredRepositoryItemTestCallback, + tName : string | undefined = undefined, + explainT : StoredRepositoryItemExplainCallback | undefined = undefined + ) { + this._isT = isT; + this._tName = tName ?? 'T'; + this._explainT = explainT ?? ( (value: any) : string => isT(value) ? explainOk() : explainNot(this._tName) ); + } + + public async initializeRepository () : Promise> { + return new SimpleMemoryRepository( + this._isT, + undefined, + this._tName, + this._explainT + ); + } + +} diff --git a/simpleRepository/SimpleMemorySharedClientService.ts b/simpleRepository/SimpleMemorySharedClientService.ts new file mode 100644 index 0000000..4e88c2a --- /dev/null +++ b/simpleRepository/SimpleMemorySharedClientService.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SimpleSharedClientService, SharedClientServiceDestructor } from "./types/SimpleSharedClientService"; +import { SimpleMemoryRepositoryClient } from "./SimpleMemoryRepositoryClient"; +import { Observer, ObserverCallback } from "../Observer"; +import { SimpleSharedClientServiceEvent } from "./types/SimpleSharedClientServiceEvent"; + +export class SimpleMemorySharedClientService implements SimpleSharedClientService { + + private _observer : Observer; + private readonly _client : SimpleMemoryRepositoryClient; + + public constructor () { + this._observer = new Observer("MemorySharedClientService"); + this._client = new SimpleMemoryRepositoryClient(); + } + + public destroy (): void { + this._observer.destroy(); + } + + public getClient (): SimpleMemoryRepositoryClient { + return this._client; + } + + public async initialize ( + // @ts-ignore @todo Why not used? + url: string + ): Promise { + if(this._observer.hasCallbacks(SimpleSharedClientServiceEvent.INITIALIZED)) { + this._observer.triggerEvent(SimpleSharedClientServiceEvent.INITIALIZED); + } + } + + public isInitializing (): boolean { + return false; + } + + public async login ( + // @ts-ignore @todo Why not used? + url: string + ): Promise { + } + + /** + * + * @param name + * @param callback + */ + public on ( + name: SimpleSharedClientServiceEvent, + callback: ObserverCallback + ): SharedClientServiceDestructor { + return this._observer.listenEvent(name, callback); + } + + public async waitForInitialization (): Promise { + } + +} + diff --git a/simpleRepository/SimpleRepositoryUtils.ts b/simpleRepository/SimpleRepositoryUtils.ts new file mode 100644 index 0000000..4b7ba43 --- /dev/null +++ b/simpleRepository/SimpleRepositoryUtils.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { explainRepositoryEntry, isRepositoryEntry, SimpleRepositoryEntry } from "./types/SimpleRepositoryEntry"; +import { has } from "../functions/has"; +import { reduce } from "../functions/reduce"; +import { values } from "../functions/values"; +import { SimpleStoredRepositoryItem, StoredRepositoryItemExplainCallback, StoredRepositoryItemTestCallback } from "./types/SimpleStoredRepositoryItem"; +import { explainArrayOf, isArrayOf } from "../types/Array"; + +export class SimpleRepositoryUtils { + + public static filterLatest (list : SimpleRepositoryEntry[]) : SimpleRepositoryEntry[] { + + return values(reduce( + list, + (cache: {[key: string]: SimpleRepositoryEntry}, item: SimpleRepositoryEntry) : {[key: string]: SimpleRepositoryEntry} => { + + if (!has(cache, item.id)) { + cache[item.id] = item; + } else if (item.version > cache[item.id].version) { + cache[item.id] = item; + } + + return cache; + + }, + {} as {[key: string]: SimpleRepositoryEntry} + )); + + } + + /** + * Returns true if the list is in correct format. + * + * @param list + * @param isT + * @private + */ + public static isRepositoryEntryList ( + list: any, + isT: StoredRepositoryItemTestCallback + ) : list is SimpleRepositoryEntry[] { + return isArrayOf( + list, + (item: SimpleRepositoryEntry): boolean => isRepositoryEntry( + item, + isT + ) + ); + } + + public static explainRepositoryEntryList ( + list: any, + isT: StoredRepositoryItemTestCallback, + explainT: StoredRepositoryItemExplainCallback, + tName : string + ) : string { + return explainArrayOf( + tName, + (item: SimpleRepositoryEntry): string => explainRepositoryEntry( + item, + explainT + ), + list, + (item: SimpleRepositoryEntry): boolean => isRepositoryEntry( + item, + isT + ) + ); + } + + +} diff --git a/simpleRepository/types/SimpleMemoryItem.ts b/simpleRepository/types/SimpleMemoryItem.ts new file mode 100644 index 0000000..56e6e55 --- /dev/null +++ b/simpleRepository/types/SimpleMemoryItem.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SimpleStoredRepositoryItem, StoredRepositoryItemTestCallback } from "./SimpleStoredRepositoryItem"; +import { isBoolean } from "../../types/Boolean"; +import { isString } from "../../types/String"; +import { isNumber } from "../../types/Number"; +import { isStringArrayOrUndefined } from "../../types/StringArray"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface SimpleMemoryItem { + readonly id : string; + readonly version : number; + readonly data : T; + readonly deleted : boolean; + readonly members ?: readonly string[]; + readonly invited ?: readonly string[]; +} + +export function createMemoryItem ( + id : string, + version : number, + data : T, + deleted : boolean, + members ?: readonly string[], + invited ?: readonly string[] +): SimpleMemoryItem { + return { + id, + version, + data, + deleted, + members, + invited + }; +} + +export function isMemoryItem ( + value: any, + isT: StoredRepositoryItemTestCallback +): value is SimpleMemoryItem { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'version', + 'data', + 'deleted', + 'members', + 'invited' + ]) + && isString(value?.id) + && isNumber(value?.version) + && isT(value?.data) + && isBoolean(value?.deleted) + && isStringArrayOrUndefined(value?.members) + && isStringArrayOrUndefined(value?.invited) + ); +} diff --git a/simpleRepository/types/SimplePublicRepository.ts b/simpleRepository/types/SimplePublicRepository.ts new file mode 100644 index 0000000..d907869 --- /dev/null +++ b/simpleRepository/types/SimplePublicRepository.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { SimpleRepositoryEntry } from "./SimpleRepositoryEntry"; + +/** + * @deprecated Use Repository directly -- or refactor this interface to have multiple interfaces + * for every type of access. + */ +export interface SimplePublicRepository { + + findById (id: string): Promise | undefined>; + +} diff --git a/simpleRepository/types/SimpleRepository.ts b/simpleRepository/types/SimpleRepository.ts new file mode 100644 index 0000000..4bae46e --- /dev/null +++ b/simpleRepository/types/SimpleRepository.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { SimpleRepositoryEntry } from "./SimpleRepositoryEntry"; +import { SimpleStoredRepositoryItem } from "./SimpleStoredRepositoryItem"; + +export const REPOSITORY_NEW_IDENTIFIER = 'new'; + +export interface SimpleRepository { + + findById ( + id : string, + includeMembers ?: boolean + ): Promise | undefined>; + + findByProperty ( + propertyName : string, + propertyValue : any + ): Promise | undefined>; + + findByIdAndUpdate ( + id: string, + item: T + ) : Promise>; + + waitById ( + id : string, + includeMembers ?: boolean, + timeout ?: number + ): Promise | undefined>; + + getAll (): Promise[]>; + + getSome (idList : readonly string[]): Promise[]>; + + getAllByProperty ( + propertyName : string, + propertyValue : any + ): Promise[]>; + + createItem ( + data : T, + members ?: readonly string[] + ): Promise>; + + update (id: string, data: T): Promise>; + updateOrCreateItem (item: T) : Promise>; + + deleteById (id: string): Promise>; + deleteByIdList (list: readonly string[]): Promise[]>; + deleteByList (list: readonly SimpleRepositoryEntry[]): Promise[]>; + deleteAll (): Promise[]>; + + inviteToItem (id: string, members : readonly string[]): Promise; + + subscribeToItem (id: string): Promise; + + isRepositoryEntryList (list: any) : list is SimpleRepositoryEntry[]; + +} + diff --git a/simpleRepository/types/SimpleRepositoryClient.ts b/simpleRepository/types/SimpleRepositoryClient.ts new file mode 100644 index 0000000..835719e --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryClient.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export interface SimpleRepositoryClient { + +} diff --git a/simpleRepository/types/SimpleRepositoryEntry.ts b/simpleRepository/types/SimpleRepositoryEntry.ts new file mode 100644 index 0000000..96654ce --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryEntry.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021-2023. Sendanor . All rights reserved. + +import { explainSimpleRepositoryMember, isSimpleRepositoryMember, SimpleRepositoryMember } from "./SimpleRepositoryMember"; +import { ExplainCallback } from "../../types/ExplainCallback"; +import { TestCallbackNonStandard } from "../../types/TestCallback"; +import { explain, explainProperty } from "../../types/explain"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../types/Boolean"; +import { explainString, isString } from "../../types/String"; +import { explainNumber, isNumber } from "../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainArrayOrUndefinedOf, isArrayOrUndefinedOf } from "../../types/Array"; + +export interface SimpleRepositoryEntry { + + readonly data : T; + readonly id : string; + readonly version : number; + readonly deleted ?: boolean; + + /** + * Users who have active access to the resource (eg. joined in the Matrix room) + */ + readonly members ?: readonly SimpleRepositoryMember[]; + +} + +export function createRepositoryEntry ( + data : T, + id : string, + version : number, + deleted ?: boolean, + members ?: readonly SimpleRepositoryMember[], +) : SimpleRepositoryEntry { + return { + data, + id, + version, + deleted, + members + }; +} + +export function isRepositoryEntry ( + value : any, + isT : TestCallbackNonStandard +): value is SimpleRepositoryEntry { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'data', + 'id', + 'version', + 'deleted', + 'members' + ]) + && isT(value?.data) + && isString(value?.id) + && isNumber(value?.version) + && isBooleanOrUndefined(value?.deleted) + && isArrayOrUndefinedOf(value?.members, isSimpleRepositoryMember) + ); +} + +export function explainRepositoryEntry ( + value : any, + explainT : ExplainCallback +) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'data', + 'id', + 'version', + 'deleted', + 'members' + ]), + explainProperty("data", explainT(value?.data)), + explainProperty("id", explainString(value?.id)), + explainProperty("version", explainNumber(value?.version)), + explainProperty("deleted", explainBooleanOrUndefined(value?.deleted)), + explainProperty("members", explainArrayOrUndefinedOf("RepositoryMember", explainSimpleRepositoryMember, value?.members, isSimpleRepositoryMember)) + ] + ); +} diff --git a/simpleRepository/types/SimpleRepositoryEntryList.ts b/simpleRepository/types/SimpleRepositoryEntryList.ts new file mode 100644 index 0000000..1b0d4c1 --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryEntryList.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { SimpleRepositoryEntry } from "./SimpleRepositoryEntry"; +import { TestCallbackNonStandard } from "../../types/TestCallback"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; +import { isArrayOf } from "../../types/Array"; + +export interface SimpleRepositoryEntryList { + readonly list : SimpleRepositoryEntry[]; +} + +export function isSimpleRepositoryEntryList ( + value : any, + isT : TestCallbackNonStandard +): value is SimpleRepositoryEntryList { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'list' + ]) + && isArrayOf(value?.list, item => isT(item)) + ); +} diff --git a/simpleRepository/types/SimpleRepositoryFactory.ts b/simpleRepository/types/SimpleRepositoryFactory.ts new file mode 100644 index 0000000..2b74a28 --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryFactory.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SimpleStoredRepositoryItem } from "./SimpleStoredRepositoryItem"; +import { SimpleRepository } from "./SimpleRepository"; + +export interface SimpleRepositoryFactory { + (rooms: readonly string[]): SimpleRepository; +} diff --git a/simpleRepository/types/SimpleRepositoryInitializer.ts b/simpleRepository/types/SimpleRepositoryInitializer.ts new file mode 100644 index 0000000..5ac0b9d --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryInitializer.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { SimpleStoredRepositoryItem } from "./SimpleStoredRepositoryItem"; +import { SimpleRepository } from "./SimpleRepository"; +import { SimpleRepositoryClient } from "./SimpleRepositoryClient"; + +export interface SimpleRepositoryInitializer { + initializeRepository ( client: SimpleRepositoryClient ) : Promise>; +} diff --git a/simpleRepository/types/SimpleRepositoryItem.ts b/simpleRepository/types/SimpleRepositoryItem.ts new file mode 100644 index 0000000..29a6070 --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryItem.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { TestCallbackNonStandard } from "../../types/TestCallback"; +import { isString } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; + +export interface SimpleRepositoryItem { + readonly id: string; + readonly target: T; +} + +export function isRepositoryItem ( + value: any, + isT: TestCallbackNonStandard +): value is SimpleRepositoryItem { + return ( + isRegularObject(value) + && isString(value?.id) + && isT(value?.target) + ); +} diff --git a/simpleRepository/types/SimpleRepositoryMember.ts b/simpleRepository/types/SimpleRepositoryMember.ts new file mode 100644 index 0000000..96c73f6 --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryMember.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021-2023. Sendanor . All rights reserved. + +import { explain, explainProperty } from "../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../types/String"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface SimpleRepositoryMember { + readonly id : string; + readonly displayName ?: string; + readonly avatarUrl ?: string; +} + +export function createSimpleRepositoryMember ( + id : string, + displayName ?: string, + avatarUrl ?: string, +) : SimpleRepositoryMember { + return { + id, + displayName, + avatarUrl + }; +} + +export function isSimpleRepositoryMember (value: any): value is SimpleRepositoryMember { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'displayName', + 'avatarUrl' + ]) + && isString(value?.id) + && isStringOrUndefined(value?.displayName) + && isStringOrUndefined(value?.avatarUrl) + ); +} + +export function explainSimpleRepositoryMember (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'id', + 'displayName', + 'avatarUrl' + ]) + , explainProperty("id", explainString(value?.id)) + , explainProperty("displayName", explainStringOrUndefined(value?.displayName)) + , explainProperty("avatarUrl", explainStringOrUndefined(value?.avatarUrl)) + ] + ); +} + +export function stringifySimpleRepositoryMember (value: SimpleRepositoryMember): string { + return `RepositoryMember(${value})`; +} + +export function parseSimpleRepositoryMember (value: any): SimpleRepositoryMember | undefined { + if ( isSimpleRepositoryMember(value) ) return value; + return undefined; +} diff --git a/simpleRepository/types/SimpleRepositoryService.ts b/simpleRepository/types/SimpleRepositoryService.ts new file mode 100644 index 0000000..6e977eb --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryService.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +/** + * @deprecated SimpleRepository framework should not be used anymore. Will be removed later. + */ +export interface SimpleRepositoryService { + initialize () : Promise; +} diff --git a/simpleRepository/types/SimpleRepositoryServiceEvent.ts b/simpleRepository/types/SimpleRepositoryServiceEvent.ts new file mode 100644 index 0000000..61c16e5 --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryServiceEvent.ts @@ -0,0 +1,3 @@ +export enum SimpleRepositoryServiceEvent { + INITIALIZED = "RepositoryServiceEvent:initialized" +} diff --git a/simpleRepository/types/SimpleRepositoryType.ts b/simpleRepository/types/SimpleRepositoryType.ts new file mode 100644 index 0000000..c608254 --- /dev/null +++ b/simpleRepository/types/SimpleRepositoryType.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { toUpper } from "../../functions/toUpper"; + +export enum SimpleRepositoryType { + + /** + * Memory only repository. + * + * @see `MemoryRepository` + */ + MEMORY = "MEMORY", + + /** + * Matrix state event repository. + * + * @See [MatrixCrudRepository](https://github.com/heusalagroup/fi.hg.matrix/blob/main/MatrixCrudRepository.ts) + */ + MATRIX = "MATRIX", + + /** + * PostgreSQL and MySQL supports through SimpleRepositoryAdapter + * + * @See [SimpleRepositoryAdapter](https://github.com/heusalagroup/fi.hg.repository/blob/main/adapters/simple/SimpleRepositoryAdapter.ts) + */ + REPOSITORY_ADAPTER = "REPOSITORY_ADAPTER", + +} + +export function isRepositoryType (value: any): value is SimpleRepositoryType { + switch (value) { + case SimpleRepositoryType.MEMORY: + case SimpleRepositoryType.MATRIX: + case SimpleRepositoryType.REPOSITORY_ADAPTER: + return true; + default: + return false; + } +} + +export function stringifyRepositoryType (value: SimpleRepositoryType): string { + switch (value) { + case SimpleRepositoryType.MEMORY : return 'MEMORY'; + case SimpleRepositoryType.MATRIX : return 'MATRIX'; + case SimpleRepositoryType.REPOSITORY_ADAPTER : return 'REPOSITORY_ADAPTER'; + } + throw new TypeError(`Unsupported RepositoryType value: ${value}`); +} + +export function parseRepositoryType (value: any): SimpleRepositoryType | undefined { + switch (toUpper(`${value}`)) { + case 'MEMORY' : return SimpleRepositoryType.MEMORY; + case 'MATRIX' : return SimpleRepositoryType.MATRIX; + case 'REPOSITORY_ADAPTER' : return SimpleRepositoryType.REPOSITORY_ADAPTER; + default : return undefined; + } +} diff --git a/simpleRepository/types/SimpleSharedClientService.ts b/simpleRepository/types/SimpleSharedClientService.ts new file mode 100644 index 0000000..1a15c96 --- /dev/null +++ b/simpleRepository/types/SimpleSharedClientService.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { ObserverCallback, ObserverDestructor } from "../../Observer"; +import { SimpleRepositoryClient } from "./SimpleRepositoryClient"; +import { SimpleSharedClientServiceEvent } from "./SimpleSharedClientServiceEvent"; +import { Disposable } from "../../types/Disposable"; + +export type SharedClientServiceDestructor = ObserverDestructor; + +export interface SimpleSharedClientService extends Disposable { + destroy (): void; + getClient () : SimpleRepositoryClient | undefined; + isInitializing () : boolean; + on ( + name: SimpleSharedClientServiceEvent, + callback: ObserverCallback + ): SharedClientServiceDestructor; + login (url: string) : Promise; + initialize (url : string) : Promise; + waitForInitialization () : Promise; +} diff --git a/simpleRepository/types/SimpleSharedClientServiceEvent.ts b/simpleRepository/types/SimpleSharedClientServiceEvent.ts new file mode 100644 index 0000000..582fc95 --- /dev/null +++ b/simpleRepository/types/SimpleSharedClientServiceEvent.ts @@ -0,0 +1,4 @@ +export enum SimpleSharedClientServiceEvent { + LOGGED_IN = "SharedClientService:loggedIn", + INITIALIZED = "SharedClientService:initialized" +} diff --git a/simpleRepository/types/SimpleStoredRepositoryItem.ts b/simpleRepository/types/SimpleStoredRepositoryItem.ts new file mode 100644 index 0000000..8eae326 --- /dev/null +++ b/simpleRepository/types/SimpleStoredRepositoryItem.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; + +/** + * This is the stored item in the repository. + * + * All properties should be simple scalar types. Strings are safe and good. + * + * Complex values can be serialized as JSON string inside the target property. + * + * Any inner value which you want to be able to search should be a property here. + * Otherwise you would need to parse the JSON on every search item iteration -- + * which would be pretty bad for performance. + * + * If you want your search to be case insensitive for example, you can lowercase + * the string in the link property. + */ +export interface SimpleStoredRepositoryItem { + + /** + * Unique ID + */ + readonly id : string; + + /** Current item data as JSON string */ + readonly target : string; + +} + +export function isStoredRepositoryItem (value: any): value is SimpleStoredRepositoryItem { + return ( + isRegularObject(value) + && isString(value?.id) + && isString(value?.target) + ); +} + +export function stringifyStoredRepositoryItem (value: SimpleStoredRepositoryItem): string { + return `StoredRepositoryItem(${value})`; +} + +export function parseStoredRepositoryItem (value: any): SimpleStoredRepositoryItem | undefined { + if ( isStoredRepositoryItem(value) ) return value; + return undefined; +} + +export interface StoredRepositoryItemTestCallback { + (value: SimpleStoredRepositoryItem) : boolean; +} + +export interface StoredRepositoryItemExplainCallback { + (value: SimpleStoredRepositoryItem) : string; +} diff --git a/sms/SmsMessageDTO.ts b/sms/SmsMessageDTO.ts new file mode 100644 index 0000000..dedaed2 --- /dev/null +++ b/sms/SmsMessageDTO.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explain, explainProperty } from "../types/explain"; +import { explainString, isString } from "../types/String"; +import { explainRegularObject, isRegularObject } from "../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../types/OtherKeys"; + +export interface SmsMessageDTO { + readonly to : string; + readonly body : string; +} + +export function createSmsMessageDTO ( + to : string, + body : string +) : SmsMessageDTO { + return { + to, + body + }; +} + +export function isSmsMessageDTO (value: any) : value is SmsMessageDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'to', + 'body' + ]) + && isString(value?.to) + && isString(value?.body) + ); +} + +export function explainSmsMessageDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'to', + 'body' + ]) + , explainProperty("to", explainString(value?.to)) + , explainProperty("body", explainString(value?.body)) + ] + ); +} + +export function stringifySmsMessageDTO (value : SmsMessageDTO) : string { + return `SmsMessageDTO(${value})`; +} + +export function parseSmsMessageDTO (value: any) : SmsMessageDTO | undefined { + if (isSmsMessageDTO(value)) return value; + return undefined; +} diff --git a/sms/SmsMessageListDTO.ts b/sms/SmsMessageListDTO.ts new file mode 100644 index 0000000..8270ca1 --- /dev/null +++ b/sms/SmsMessageListDTO.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainSmsMessageDTO, isSmsMessageDTO, SmsMessageDTO } from "./SmsMessageDTO"; +import { explain, explainProperty } from "../types/explain"; +import { explainRegularObject, isRegularObject } from "../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../types/OtherKeys"; +import { explainArrayOf, isArrayOf } from "../types/Array"; + +export interface SmsMessageListDTO { + readonly payload: readonly SmsMessageDTO[]; +} + +export function createSmsMessageListDTO ( + payload: readonly SmsMessageDTO[] +): SmsMessageListDTO { + return { + payload + }; +} + +export function isSmsMessageListDTO (value: any): value is SmsMessageListDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'payload' + ]) + && isArrayOf(value?.payload, isSmsMessageDTO) + ); +} + +export function explainSmsMessageListDTO (value: any): string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'payload' + ]), + explainProperty( + "payload", + explainArrayOf( + "SmsMessageDTO", + explainSmsMessageDTO, + value?.payload, + isSmsMessageDTO + ) + ) + ] + ); +} + +export function stringifySmsMessageListDTO (value: SmsMessageListDTO): string { + return `SmsMessageListDTO(${value})`; +} + +export function parseSmsMessageListDTO (value: any): SmsMessageListDTO | undefined { + if ( isSmsMessageListDTO(value) ) return value; + return undefined; +} diff --git a/sms/dto/NewSmsQueueDTO.ts b/sms/dto/NewSmsQueueDTO.ts new file mode 100644 index 0000000..53adadd --- /dev/null +++ b/sms/dto/NewSmsQueueDTO.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isBoolean } from "../../types/Boolean"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrNull, isString } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +/** + * SMS Queue DTO + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/accounts + * + */ +export interface NewSmsQueueDTO { + readonly invoiceId: string; + readonly clientId: string; + readonly senderAddress: string; + readonly targetAddress: string; + readonly message: string; + readonly sent: boolean; + readonly failed: boolean; + readonly isTerminated: boolean; +} + +export function createNewSmsQueueDTO ( + invoiceId: string, + clientId: string, + senderAddress: string, + targetAddress: string, + message: string, + sent: boolean, + failed: boolean, + isTerminated: boolean, +) : NewSmsQueueDTO { + return { + invoiceId, + clientId, + senderAddress, + targetAddress, + message, + sent, + failed, + isTerminated, + }; +} + +export function isNewSmsQueueDTO (value: unknown) : value is NewSmsQueueDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'invoiceId', + 'clientId', + 'senderAddress', + 'targetAddress', + 'message', + 'sent', + 'failed', + 'isTerminated', + ]) + && isString(value?.invoiceId) + && isString(value?.clientId) + && isString(value?.senderAddress) + && isString(value?.targetAddress) + && isString(value?.message) + && isBoolean(value?.sent) + && isBoolean(value?.failed) + && isBoolean(value?.isTerminated) + ); +} + +export function explainNewSmsQueueDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'invoiceId', + 'clientId', + 'senderAddress', + 'targetAddress', + 'message', + 'sent', + 'failed', + 'isTerminated', + ]) + , explainProperty("invoiceId", explainString(value?.invoiceId)) + , explainProperty("clientId", explainString(value?.clientId)) + , explainProperty("senderAddress", explainString(value?.senderAddress)) + , explainProperty("targetAddress", explainString(value?.targetAddress)) + , explainProperty("message", explainString(value?.message)) + , explainProperty("sent", explainStringOrNull(value?.sent)) + , explainProperty("failed", explainStringOrNull(value?.failed)) + , explainProperty("isTerminated", explainString(value?.isTerminated)) + ] + ); +} + +export function parseNewSmsQueueDTO (value: unknown) : NewSmsQueueDTO | undefined { + if (isNewSmsQueueDTO(value)) return value; + return undefined; +} + +export function isNewSmsQueueDTOOrUndefined (value: unknown): value is NewSmsQueueDTO | undefined { + return isUndefined(value) || isNewSmsQueueDTO(value); +} + +export function explainNewSmsQueueDTOOrUndefined (value: unknown): string { + return isNewSmsQueueDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['NewSmsQueueDTO', 'undefined'])); +} diff --git a/sms/dto/SmsQueueDTO.ts b/sms/dto/SmsQueueDTO.ts new file mode 100644 index 0000000..019a42f --- /dev/null +++ b/sms/dto/SmsQueueDTO.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isBoolean } from "../../types/Boolean"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainString, explainStringOrNull, isString } from "../../types/String"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; + +/** + * SMS Queue DTO + * + * @see https://op-developer.fi/products/banking/docs/op-corporate-account-data-api#operation/accounts + * + */ +export interface SmsQueueDTO { + readonly smsQueueId: string; + readonly invoiceId: string; + readonly clientId: string; + readonly updated: string; + readonly created: string; + readonly senderAddress: string; + readonly targetAddress: string; + readonly message: string; + readonly sent: boolean; + readonly failed: boolean; + readonly isTerminated: boolean; +} + +export function createSmsQueueDTO ( + smsQueueId: string, + invoiceId: string, + clientId: string, + updated: string, + created: string, + senderAddress: string, + targetAddress: string, + message: string, + sent: boolean, + failed: boolean, + isTerminated: boolean, +) : SmsQueueDTO { + return { + smsQueueId, + invoiceId, + clientId, + updated, + created, + senderAddress, + targetAddress, + message, + sent, + failed, + isTerminated, + }; +} + +export function isSmsQueueDTO (value: unknown) : value is SmsQueueDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'smsQueueId', + 'invoiceId', + 'clientId', + 'updated', + 'created', + 'senderAddress', + 'targetAddress', + 'message', + 'sent', + 'failed', + 'isTerminated', + ]) + && isString(value?.smsQueueId) + && isString(value?.invoiceId) + && isString(value?.clientId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.senderAddress) + && isString(value?.targetAddress) + && isString(value?.message) + && isBoolean(value?.sent) + && isBoolean(value?.failed) + && isBoolean(value?.isTerminated) + ); +} + +export function explainSmsQueueDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'smsQueueId', + 'invoiceId', + 'clientId', + 'updated', + 'created', + 'senderAddress', + 'targetAddress', + 'message', + 'sent', + 'failed', + 'isTerminated', + ]) + , explainProperty("smsQueueId", explainString(value?.smsQueueId)) + , explainProperty("invoiceId", explainString(value?.invoiceId)) + , explainProperty("clientId", explainString(value?.clientId)) + , explainProperty("updated", explainStringOrNull(value?.updated)) + , explainProperty("created", explainStringOrNull(value?.created)) + , explainProperty("senderAddress", explainString(value?.senderAddress)) + , explainProperty("targetAddress", explainString(value?.targetAddress)) + , explainProperty("message", explainString(value?.message)) + , explainProperty("sent", explainStringOrNull(value?.sent)) + , explainProperty("failed", explainStringOrNull(value?.failed)) + , explainProperty("isTerminated", explainString(value?.isTerminated)) + ] + ); +} + +export function parseSmsQueueDTO (value: unknown) : SmsQueueDTO | undefined { + if (isSmsQueueDTO(value)) return value; + return undefined; +} + +export function isSmsQueueDTOOrUndefined (value: unknown): value is SmsQueueDTO | undefined { + return isUndefined(value) || isSmsQueueDTO(value); +} + +export function explainSmsQueueDTOOrUndefined (value: unknown): string { + return isSmsQueueDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['SmsQueueDTO', 'undefined'])); +} diff --git a/sms/repository/SmsQueueEntity.ts b/sms/repository/SmsQueueEntity.ts new file mode 100644 index 0000000..f07d335 --- /dev/null +++ b/sms/repository/SmsQueueEntity.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Column } from "../../data/Column"; +import { CreationTimestamp } from "../../data/CreationTimestamp"; +import { Entity } from "../../data/Entity"; +import { Id } from "../../data/Id"; +import { Table } from "../../data/Table"; +import { Temporal } from "../../data/Temporal"; +import { TemporalType } from "../../data/types/TemporalType"; +import { UpdateTimestamp } from "../../data/UpdateTimestamp"; +import { EntityUtils } from "../../data/utils/EntityUtils"; +import { LogService } from "../../LogService"; +import type { NewSmsQueueDTO } from "../dto/NewSmsQueueDTO"; +import { createSmsQueueDTO, explainSmsQueueDTO, isSmsQueueDTO, SmsQueueDTO } from "../dto/SmsQueueDTO"; + +const LOG = LogService.createLogger('SmsQueueEntity'); + +@Table("sms_queue") +export class SmsQueueEntity extends Entity { + + // The constructor + public constructor (); + public constructor (dto : NewSmsQueueDTO); + + public constructor (dto ?: NewSmsQueueDTO) { + super(); + this.invoiceId = dto?.invoiceId; + this.clientId = dto?.clientId; + this.senderAddress = dto?.senderAddress; + this.targetAddress = dto?.targetAddress; + this.message = dto?.message; + this.sent = !!dto?.sent; + this.failed = !!dto?.failed; + this.isTerminated = !!dto?.isTerminated; + } + + @Id() + @Column("sms_queue_id", 'BIGINT', { updatable : false, insertable: false }) + public smsQueueId?: string; + + @Column("client_id", 'BIGINT') + public clientId?: string; + + @Column("invoice_id") + public invoiceId?: string; + + @UpdateTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column("updated", 'DATETIME') + public updated?: string; + + @CreationTimestamp() + @Temporal(TemporalType.TIMESTAMP) + @Column("creation", 'DATETIME') + public created?: string; + + @Column("sender_address") + public senderAddress ?: string; + + @Column("target_address") + public targetAddress ?: string; + + @Column("message") + public message ?: string; + + @Column("sent", 'BOOL') + public sent ?: boolean; + + @Column("failed", 'BOOL') + public failed ?: boolean; + + @Column("is_terminated", 'BOOL') + public isTerminated ?: boolean; + + public static toDTO (entity: SmsQueueEntity) : SmsQueueDTO { + if (entity.smsQueueId === undefined) throw new TypeError('entity.smsQueueId missing'); + if (entity.updated === undefined) throw new TypeError('entity.updated missing'); + if (entity.created === undefined) throw new TypeError('entity.created missing'); + if (entity.invoiceId === undefined) throw new TypeError('entity.invoiceId missing'); + if (entity.clientId === undefined) throw new TypeError('entity.clientId missing'); + if (entity.senderAddress === undefined) throw new TypeError('entity.senderAddress missing'); + if (entity.targetAddress === undefined) throw new TypeError('entity.targetAddress missing'); + if (entity.message === undefined) throw new TypeError('entity.message missing'); + if (entity.sent === undefined) throw new TypeError('entity.sent missing'); + if (entity.failed === undefined) throw new TypeError('entity.failed missing'); + if (entity.isTerminated === undefined) throw new TypeError('entity.isTerminated missing'); + const dto : SmsQueueDTO = createSmsQueueDTO( + entity.smsQueueId, + entity.updated, + entity.created, + entity.invoiceId, + entity.clientId, + entity.senderAddress, + entity.targetAddress, + entity.message, + EntityUtils.parseBoolean(entity.sent), + EntityUtils.parseBoolean(entity.failed), + EntityUtils.parseBoolean(entity.isTerminated), + ); + // Redundant fail safe + if (!isSmsQueueDTO(dto)) { + LOG.debug(`toDTO: dto / entity = `, dto, entity); + throw new TypeError(`Failed to create valid SmsQueueDTO: ${explainSmsQueueDTO(dto)}`); + } + return dto; + } + +} diff --git a/sms/repository/SmsQueueRepository.ts b/sms/repository/SmsQueueRepository.ts new file mode 100644 index 0000000..52e0a17 --- /dev/null +++ b/sms/repository/SmsQueueRepository.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Sort } from "../../data/Sort"; +import { Repository } from "../../data/types/Repository"; +import { SmsQueueEntity } from "./SmsQueueEntity"; + +export interface SmsQueueRepository extends Repository { + findAllByClientId (clientId: string, sort?: Sort) : Promise; + findAllByInvoiceId (invoiceId: string, sort?: Sort) : Promise; + findAllBySenderAddress (from: string, sort?: Sort) : Promise; + findAllByTargetAddress (to: string, sort?: Sort) : Promise; + findAllBySent (sent: boolean, sort?: Sort) : Promise; + findAllByFailed (failed: boolean, sort?: Sort) : Promise; + findAllByIsTerminated (isTerminated: boolean, sort?: Sort) : Promise; +} diff --git a/sms/types/SmsMessage.ts b/sms/types/SmsMessage.ts new file mode 100644 index 0000000..9e63dbd --- /dev/null +++ b/sms/types/SmsMessage.ts @@ -0,0 +1,9 @@ + +/** + * Sms message DTO + */ +export interface SmsMessage { + readonly from?: string; + readonly to: string | string[]; + readonly content?: string; +} diff --git a/store/constants/storeTranslation.ts b/store/constants/storeTranslation.ts new file mode 100644 index 0000000..a0316bc --- /dev/null +++ b/store/constants/storeTranslation.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ProductFeatureCategory } from "../types/product/features/ProductFeatureCategory"; +import { ProductFeatureId } from "../types/product/features/ProductFeatureId"; +import { ProductPriceType } from "../types/product/ProductPriceType"; +import { DiskType } from "../types/product/features/DiskType"; +import { ProductType } from "../types/product/ProductType"; +import { SiType } from "../../types/SiType"; +import { ProductPrice } from "../types/product/ProductPrice"; +import { InventoryState } from "../types/inventory/InventoryState"; +import { NetworkType } from "../types/product/features/NetworkType"; +import { RaidType } from "../types/product/features/RaidType"; +import { NetworkIpType } from "../types/product/features/NetworkIpType"; +import { NetworkZone } from "../types/product/features/NetworkZone"; +import { VirtualizationType } from "../types/product/features/VirtualizationType"; +import { OperatingSystem } from "../types/product/features/OperatingSystem"; + +export function getProductFeatureCategoryTitleTranslationToken (categoryId: ProductFeatureCategory) { + return `productFeatureCategory.${categoryId}`; +} + +export function getProductFeatureTitleTranslationToken (featureId: ProductFeatureId) { + return `productFeatureId.${featureId}`; +} + +export function getPriceTypeTranslationToken (priceType: ProductPriceType) { + return `priceType.${priceType}`; +} + +export function getDiskTypeFeatureTranslation (diskType: DiskType): string { + return `diskType.${diskType}`; +} + +export function getBackupFeatureTranslation (diskType: DiskType): string { + return `backupType.${diskType}`; +} + +export function getSelectPriceTypeTextForPriceType (type: ProductPriceType): string { + return `product.selectPriceType.${type}`; +} + +export function getProductTypeTranslationKey (type: ProductType): string { + return `product.type.${type}`; +} + +export function getCommonShortSi (type: SiType): string { + return `common.si.${type}.short`; +} + +export function getSelectPriceTypeTextForPrice (item: ProductPrice): string { + return getSelectPriceTypeTextForPriceType(item.type); +} + +export function getInventoryStateTranslationKey (type: InventoryState | undefined): string { + return `inventoryItem.state.${type}`; +} + +export const T_COMMON_TRAFFIC_MONTHLY = "common.traffic.monthly"; +export const T_COMMON_BYTE_SHORT = "common.byte.short"; +export const T_COMMON_PIECE = "common.piece"; + +export function getNetworkTypeFeatureTranslation (networkType: NetworkType): string { + return `networkType.${networkType}`; +} + +export function getNetworkZoneFeatureTranslation (networkZone: NetworkZone): string { + return `networkZone.${networkZone}`; +} + +export function getNetworkIpTypeFeatureTranslation (networkIpType: NetworkIpType): string { + return `networkIpType.${networkIpType}`; +} + +export function getVirtualizationTypeFeatureTranslation (value: VirtualizationType): string { + return `virtualizationType.${value}`; +} + +export function getOperatingSystemFeatureTranslation (value: OperatingSystem): string { + return `operatingSystem.${value}`; +} + +export function getDiskRaidTypeFeatureTranslation (raidType: RaidType): string { + return `raidType.${raidType}`; +} diff --git a/store/types/api/StoreErrorDTO.ts b/store/types/api/StoreErrorDTO.ts new file mode 100644 index 0000000..da32272 --- /dev/null +++ b/store/types/api/StoreErrorDTO.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../types/String"; + +export interface StoreErrorDTO { + readonly error: string; +} + +export function isStoreErrorDTO (value: any): value is StoreErrorDTO { + return ( + !!value + && isString(value?.error) + ); +} + +export function stringifyStoreErrorDTO (value: StoreErrorDTO): string { + if ( !isStoreErrorDTO(value) ) throw new TypeError(`Not StoreErrorDTO: ${value}`); + return `StoreErrorDTO(${value})`; +} + +export function parseStoreErrorDTO (value: any): StoreErrorDTO | undefined { + if ( isStoreErrorDTO(value) ) return value; + return undefined; +} diff --git a/store/types/api/StoreIndexDTO.ts b/store/types/api/StoreIndexDTO.ts new file mode 100644 index 0000000..fc7ce43 --- /dev/null +++ b/store/types/api/StoreIndexDTO.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isProductListDTO, ProductListDTO } from "../product/ProductListDTO"; +import { isUndefined } from "../../../types/undefined"; + +export interface StoreIndexDTO { + readonly products ?: ProductListDTO; +} + +export function isStoreIndexDTO (value: any): value is StoreIndexDTO { + return ( + !!value + && ( isUndefined(value?.products) || isProductListDTO(value?.products) ) + ); +} + +export function stringifyStoreIndexDTO (value: StoreIndexDTO): string { + if ( !isStoreIndexDTO(value) ) throw new TypeError(`Not StoreIndexDTO: ${value}`); + return `StoreIndexDTO(${value})`; +} + +export function parseStoreIndexDTO (value: any): StoreIndexDTO | undefined { + if ( isStoreIndexDTO(value) ) return value; + return undefined; +} diff --git a/store/types/bankAccount/BankAccountRowDTO.ts b/store/types/bankAccount/BankAccountRowDTO.ts new file mode 100644 index 0000000..4d2bec5 --- /dev/null +++ b/store/types/bankAccount/BankAccountRowDTO.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../types/String"; +import { isNumber } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface BankAccountRowDTO { + readonly bankAccountRowId : string; + readonly updated : string; + readonly created : string; + readonly bankAccountId : string; + readonly invoiceId : string; + readonly documentId : string; + readonly banksonInboundPaymentId : string; + readonly archiveId : string; + readonly purchaseInvoiceId : string; + readonly date : string; + readonly referenceNumber : string; + readonly name : string; + readonly description : string; + readonly message : string; + readonly internalNote : string; + readonly bankAccountNumber : string; + readonly sum : number; +} + +export function createBankAccountRowDTO ( + bankAccountRowId : string, + updated : string, + created : string, + sum : number, + bankAccountId : string, + invoiceId : string, + documentId : string, + archiveId : string, + purchaseInvoiceId : string, + date : string, + referenceNumber : string, + name : string, + description : string, + message : string, + internalNote : string, + bankAccountNumber : string, + banksonInboundPaymentId : string, +): BankAccountRowDTO { + return { + bankAccountRowId, + updated, + created, + bankAccountId, + invoiceId, + documentId, + banksonInboundPaymentId, + archiveId, + purchaseInvoiceId, + date, + referenceNumber, + name, + description, + message, + internalNote, + bankAccountNumber, + sum, + }; +} + +export function isBankAccountRowDTO (value: any): value is BankAccountRowDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'bankAccountRowId', + 'updated', + 'created', + 'bankAccountId', + 'invoiceId', + 'documentId', + 'banksonInboundPaymentId', + 'archiveId', + 'purchaseInvoiceId', + 'date', + 'referenceNumber', + 'name', + 'description', + 'message', + 'internalNote', + 'bankAccountNumber', + 'sum', + ]) + && isString(value?.bankAccountRowId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.bankAccountId) + && isString(value?.invoiceId) + && isString(value?.documentId) + && isString(value?.banksonInboundPaymentId) + && isString(value?.archiveId) + && isString(value?.purchaseInvoiceId) + && isString(value?.date) + && isString(value?.referenceNumber) + && isString(value?.name) + && isString(value?.description) + && isString(value?.message) + && isString(value?.internalNote) + && isString(value?.bankAccountNumber) + && isNumber(value?.sum) + ); +} + +export function parseBankAccountRowDTO (value: any): BankAccountRowDTO | undefined { + if ( isBankAccountRowDTO(value) ) return value; + return undefined; +} diff --git a/store/types/bankAccount/NewBankAccountRowDTO.ts b/store/types/bankAccount/NewBankAccountRowDTO.ts new file mode 100644 index 0000000..0e549cc --- /dev/null +++ b/store/types/bankAccount/NewBankAccountRowDTO.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../types/String"; +import { isNumber } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface NewBankAccountRowDTO { + readonly bankAccountId ?: string; + readonly invoiceId ?: string; + readonly documentId ?: string; + readonly banksonInboundPaymentId ?: string; + readonly archiveId ?: string; + readonly purchaseInvoiceId ?: string; + readonly date ?: string; + readonly referenceNumber ?: string; + readonly name ?: string; + readonly description ?: string; + readonly message ?: string; + readonly internalNote ?: string; + readonly bankAccountNumber ?: string; + readonly sum ?: number; +} + +export function createNewBankAccountRowDTO ( + bankAccountId ?: string, + invoiceId ?: string, + documentId ?: string, + banksonInboundPaymentId ?: string, + archiveId ?: string, + purchaseInvoiceId ?: string, + date ?: string, + referenceNumber ?: string, + name ?: string, + description ?: string, + message ?: string, + internalNote ?: string, + bankAccountNumber ?: string, + sum ?: number, +): NewBankAccountRowDTO { + return { + bankAccountId, + invoiceId, + documentId, + banksonInboundPaymentId, + archiveId, + purchaseInvoiceId, + date, + referenceNumber, + name, + description, + message, + internalNote, + bankAccountNumber, + sum, + }; +} + +export function isNewBankAccountRowDTO (value: any): value is NewBankAccountRowDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'bankAccountId', + 'invoiceId', + 'documentId', + 'banksonInboundPaymentId', + 'archiveId', + 'purchaseInvoiceId', + 'date', + 'referenceNumber', + 'name', + 'description', + 'message', + 'internalNote', + 'bankAccountNumber', + 'sum', + ]) + && isString(value?.bankAccountId) + && isString(value?.invoiceId) + && isString(value?.documentId) + && isString(value?.banksonInboundPaymentId) + && isString(value?.archiveId) + && isString(value?.purchaseInvoiceId) + && isString(value?.date) + && isString(value?.referenceNumber) + && isString(value?.name) + && isString(value?.description) + && isString(value?.message) + && isString(value?.internalNote) + && isString(value?.bankAccountNumber) + && isNumber(value?.sum) + ); +} + +export function parseNewBankAccountRowDTO (value: any): NewBankAccountRowDTO | undefined { + if ( isNewBankAccountRowDTO(value) ) return value; + return undefined; +} diff --git a/store/types/cart/ShoppingCart.ts b/store/types/cart/ShoppingCart.ts new file mode 100644 index 0000000..f176ffc --- /dev/null +++ b/store/types/cart/ShoppingCart.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainShoppingCartItem, isShoppingCartItem, ShoppingCartItem } from "./ShoppingCartItem"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainArrayOf, isArrayOf } from "../../../types/Array"; +import { explain, explainProperty } from "../../../types/explain"; + +export interface ShoppingCart { + readonly items : readonly ShoppingCartItem[]; +} + +export function createShoppingCart (items ?: ShoppingCartItem[]) { + return { + items: items ?? [] + }; +} + +export function isShoppingCart (value: unknown): value is ShoppingCart { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'items' + ]) + && isArrayOf(value?.items, isShoppingCartItem) + ); +} + +export function explainShoppingCart (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'items' + ]) + , explainProperty("items", explainArrayOf("ShoppingCartItem", explainShoppingCartItem, value?.items, isShoppingCartItem)) + ] + ); +} + +export function stringifyShoppingCart (value: ShoppingCart): string { + return `ShoppingCart(${value})`; +} + +export function parseShoppingCart (value: any): ShoppingCart | undefined { + if ( isShoppingCart(value) ) return value; + return undefined; +} diff --git a/store/types/cart/ShoppingCartItem.ts b/store/types/cart/ShoppingCartItem.ts new file mode 100644 index 0000000..7b26c9a --- /dev/null +++ b/store/types/cart/ShoppingCartItem.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { ProductPrice, isProductPrice, explainProductPrice } from "../product/ProductPrice"; +import { Product, isProduct, explainProduct } from "../product/Product"; +import { explainString, isString } from "../../../types/String"; +import { explainNumber, isNumber } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explain, explainProperty } from "../../../types/explain"; + +export interface ShoppingCartItem { + readonly id : string; + readonly amount : number; + readonly price : ProductPrice; + readonly product : Product; +} + +export function createShoppingCartItem ( + id : string, + amount : number, + price : ProductPrice, + product: Product +) : ShoppingCartItem { + return { + id, + amount, + price, + product + }; +} + +export function isShoppingCartItem (value: unknown): value is ShoppingCartItem { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'id', + 'amount', + 'price', + 'product' + ]) + && isString(value?.id) + && isNumber(value?.amount) + && isProductPrice(value?.price) + && isProduct(value?.product) + ); +} + +export function explainShoppingCartItem (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'id', + 'amount', + 'price', + 'product' + ]) + , explainProperty("id", explainString(value?.id)) + , explainProperty("amount", explainNumber(value?.amount)) + , explainProperty("price", explainProductPrice(value?.price)) + , explainProperty("product", explainProduct(value?.product)) + ] + ); +} + + + +export function stringifyShoppingCartItem (value: ShoppingCartItem): string { + return `CartItem(${value})`; +} + +export function parseShoppingCartItem (value: any): ShoppingCartItem | undefined { + if ( isShoppingCartItem(value) ) return value; + return undefined; +} diff --git a/store/types/category/StoreCategoryType.ts b/store/types/category/StoreCategoryType.ts new file mode 100644 index 0000000..460f048 --- /dev/null +++ b/store/types/category/StoreCategoryType.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export enum StoreCategoryType { + + HOSTING = "HOSTING", + DOMAINS = "DOMAINS", + USED_COMPUTERS = "USED_COMPUTERS", + NETWORK = "NETWORK", + OTHER = "OTHER", + + /** + * Use DOMAINS + * @deprecated + */ + SHORT_DOMAINS = "SHORT_DOMAINS", + + /** + * Use HOSTING + * @deprecated + */ + EMAIL = "EMAIL", + + /** + * Use HOSTING + * @deprecated + */ + SHELL = "SHELL", + + /** + * Use HOSTING + * @deprecated + */ + VPS = "VPS", + + /** + * Use HOSTING + * @deprecated + */ + VIRTUALSERVERS = "VIRTUALSERVERS", + + /** + * Use HOSTING + * @deprecated + */ + WEBHOTEL = "WEBHOTEL" + +} + +export function isStoreCategoryType (value : any) : value is StoreCategoryType { + switch (value) { + case StoreCategoryType.VIRTUALSERVERS: + case StoreCategoryType.EMAIL: + case StoreCategoryType.SHELL: + case StoreCategoryType.WEBHOTEL: + case StoreCategoryType.VPS: + case StoreCategoryType.SHORT_DOMAINS: + case StoreCategoryType.HOSTING: + case StoreCategoryType.DOMAINS: + case StoreCategoryType.USED_COMPUTERS: + case StoreCategoryType.NETWORK: + case StoreCategoryType.OTHER: + return true; + + default: + return false; + } +} + +export function parseStoreCategoryType (value : any) : StoreCategoryType { + switch (`${value}`.toUpperCase()) { + case "EMAIL" : return StoreCategoryType.HOSTING; + case "SHELL" : return StoreCategoryType.HOSTING; + case "WEBHOTEL" : return StoreCategoryType.HOSTING; + case "WEB_HOTEL" : return StoreCategoryType.HOSTING; + case "WEBSERVER" : return StoreCategoryType.HOSTING; + case "WEB_SERVER" : return StoreCategoryType.HOSTING; + case "VPS" : return StoreCategoryType.HOSTING; + case "VIRTUALSERVERS" : return StoreCategoryType.HOSTING; + case "VIRTUAL_SERVERS" : return StoreCategoryType.HOSTING; + case "HOSTING" : return StoreCategoryType.HOSTING; + case "SHORTDOMAINS" : return StoreCategoryType.DOMAINS; + case "SHORT_DOMAINS" : return StoreCategoryType.DOMAINS; + case "DOMAINS" : return StoreCategoryType.DOMAINS; + case "COMPUTERS" : return StoreCategoryType.USED_COMPUTERS; + case "USED_COMPUTERS" : return StoreCategoryType.USED_COMPUTERS; + case "USEDCOMPUTERS" : return StoreCategoryType.USED_COMPUTERS; + case "NETWORK" : return StoreCategoryType.NETWORK; + case "OTHER" : return StoreCategoryType.OTHER; + default : return StoreCategoryType.OTHER; + } +} + diff --git a/store/types/checkoutTransaction/CheckoutTransactionDTO.ts b/store/types/checkoutTransaction/CheckoutTransactionDTO.ts new file mode 100644 index 0000000..2ac5335 --- /dev/null +++ b/store/types/checkoutTransaction/CheckoutTransactionDTO.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainString, isString } from "../../../types/String"; +import { explainNumber, isNumber } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explain, explainProperty } from "../../../types/explain"; + +export interface CheckoutTransactionDTO { + readonly checkoutTransactionId : string; + readonly invoiceId : string; + readonly creation : string; + readonly updated : string; + readonly transactionId : string; + readonly href : string; + readonly reference : string; + readonly raw : string; + readonly status : string; + readonly amount : number; +} + +export function createCheckoutTransactionDTO ( + checkoutTransactionId : string, + invoiceId : string, + creation : string, + updated : string, + transactionId : string, + href : string, + reference : string, + raw : string, + status : string, + amount : number, +): CheckoutTransactionDTO { + return { + checkoutTransactionId, + invoiceId, + creation, + updated, + transactionId, + href, + reference, + raw, + status, + amount, + }; +} + +export function isCheckoutTransactionDTO (value: any): value is CheckoutTransactionDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'checkoutTransactionId', + 'invoiceId', + 'creation', + 'updated', + 'transactionId', + 'href', + 'reference', + 'raw', + 'status', + 'amount', + ]) + && isString(value?.checkoutTransactionId) + && isString(value?.invoiceId) + && isString(value?.creation) + && isString(value?.updated) + && isString(value?.transactionId) + && isString(value?.href) + && isString(value?.reference) + && isString(value?.raw) + && isString(value?.status) + && isNumber(value?.amount) + ); +} + +export function explainCheckoutTransactionDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'checkoutTransactionId', + 'invoiceId', + 'creation', + 'updated', + 'transactionId', + 'href', + 'reference', + 'raw', + 'status', + 'amount', + ]) + , explainProperty("checkoutTransactionId", explainString(value?.checkoutTransactionId)) + , explainProperty("invoiceId", explainString(value?.invoiceId)) + , explainProperty("creation", explainString(value?.creation)) + , explainProperty("updated", explainString(value?.updated)) + , explainProperty("transactionId", explainString(value?.transactionId)) + , explainProperty("href", explainString(value?.href)) + , explainProperty("reference", explainString(value?.reference)) + , explainProperty("raw", explainString(value?.raw)) + , explainProperty("status", explainString(value?.status)) + , explainProperty("amount", explainNumber(value?.amount)) + ] + ); +} + +export function parseCheckoutTransactionDTO (value: any): CheckoutTransactionDTO | undefined { + if ( isCheckoutTransactionDTO(value) ) return value; + return undefined; +} diff --git a/store/types/checkoutTransaction/NewCheckoutTransactionDTO.ts b/store/types/checkoutTransaction/NewCheckoutTransactionDTO.ts new file mode 100644 index 0000000..880d45d --- /dev/null +++ b/store/types/checkoutTransaction/NewCheckoutTransactionDTO.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainString, isString } from "../../../types/String"; +import { explainNumber, isNumber } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explain, explainProperty } from "../../../types/explain"; + +export interface NewCheckoutTransactionDTO { + readonly invoiceId : string; + readonly transactionId : string; + readonly href : string; + readonly reference : string; + readonly raw : string; + readonly status : string; + readonly amount : number; +} + +export function createNewCheckoutTransactionDTO ( + invoiceId : string, + transactionId : string, + href : string, + reference : string, + raw : string, + status : string, + amount : number, +): NewCheckoutTransactionDTO { + return { + invoiceId, + transactionId, + href, + reference, + raw, + status, + amount, + }; +} + +export function isNewCheckoutTransactionDTO (value: any): value is NewCheckoutTransactionDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'invoiceId', + 'transactionId', + 'href', + 'reference', + 'raw', + 'status', + 'amount', + ]) + && isString(value?.invoiceId) + && isString(value?.transactionId) + && isString(value?.href) + && isString(value?.reference) + && isString(value?.raw) + && isString(value?.status) + && isNumber(value?.amount) + ); +} + +export function explainNewCheckoutTransactionDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'invoiceId', + 'transactionId', + 'href', + 'reference', + 'raw', + 'status', + 'amount', + ]) + , explainProperty("invoiceId", explainString(value?.invoiceId)) + , explainProperty("transactionId", explainString(value?.transactionId)) + , explainProperty("href", explainString(value?.href)) + , explainProperty("reference", explainString(value?.reference)) + , explainProperty("raw", explainString(value?.raw)) + , explainProperty("status", explainString(value?.status)) + , explainProperty("amount", explainNumber(value?.amount)) + ] + ); +} + +export function parseNewCheckoutTransactionDTO (value: any): NewCheckoutTransactionDTO | undefined { + if ( isNewCheckoutTransactionDTO(value) ) return value; + return undefined; +} diff --git a/store/types/client/ClientDTO.ts b/store/types/client/ClientDTO.ts new file mode 100644 index 0000000..43e8fb3 --- /dev/null +++ b/store/types/client/ClientDTO.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { isArray, isArrayOrUndefinedOf } from "../../../types/Array"; +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../types/OtherKeys"; + +/** + * The client object used in the REST API communication + */ +export interface ClientDTO { + readonly id : string; + readonly updated ?: string; + readonly created ?: string; + readonly date ?: string; + readonly company ?: string; + readonly companyCode ?: string; + readonly firstname ?: string; + readonly lastname ?: string; + readonly address ?: string[]; + readonly postCode ?: string; + readonly postName ?: string; + readonly country ?: string; + readonly email ?: string[]; + readonly phone ?: string; + readonly mobile ?: string; + readonly fax ?: string; + readonly billingLang ?: string; + readonly sendEmail ?: boolean; + readonly sendPost ?: boolean; + readonly isTerminated ?: boolean; +} + +export function createClientDTO ( + id : string, + updated : string, + created : string, + date : string, + company : string, + companyCode : string, + firstname : string, + lastname : string, + address : string | readonly string[] | undefined, + postCode : string, + postName : string, + country : string, + email : string | readonly string[] | undefined, + phone : string, + mobile : string, + fax : string, + billingLang : string, + sendEmail : boolean, + sendPost : boolean, + isTerminated : boolean +) : ClientDTO { + return { + id, + updated, + created, + date, + company, + companyCode, + firstname, + lastname, + address: (!address) ? [] : (isArray(address) ? [...address] : [address]), + postCode, + postName, + country, + email: (!email) ? [] : (isArray(email) ? [...email] : [email]), + phone, + mobile, + fax, + billingLang, + sendEmail, + sendPost, + isTerminated + }; +} + +export function isClientDTO (value: any): value is ClientDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + "id", + "updated", + "created", + "date", + "company", + "companyCode", + "firstname", + "lastname", + "address", + "postCode", + "postName", + "country", + "email", + "phone", + "mobile", + "fax", + "billingLang", + "sendEmail", + "sendPost", + "isTerminated" + ]) + && isString(value?.id) + && isStringOrUndefined(value?.updated) + && isStringOrUndefined(value?.created) + && isStringOrUndefined(value?.date) + && isStringOrUndefined(value?.company) + && isStringOrUndefined(value?.companyCode) + && isStringOrUndefined(value?.firstname) + && isStringOrUndefined(value?.lastname) + && isArrayOrUndefinedOf(value?.address, isString) + && isStringOrUndefined(value?.postCode) + && isStringOrUndefined(value?.postName) + && isStringOrUndefined(value?.country) + && isArrayOrUndefinedOf(value?.email, isString) + && isStringOrUndefined(value?.phone) + && isStringOrUndefined(value?.mobile) + && isStringOrUndefined(value?.fax) + && isStringOrUndefined(value?.billingLang) + && isBooleanOrUndefined(value?.sendEmail) + && isBooleanOrUndefined(value?.sendPost) + && isBooleanOrUndefined(value?.isTerminated) + ); +} + +export function stringifyClientDTO (value: ClientDTO): string { + if ( !isClientDTO(value) ) throw new TypeError(`Not ClientDTO: ${value}`); + return `ClientDTO(${value})`; +} + +export function parseClientDTO (value: any): ClientDTO | undefined { + if ( isClientDTO(value) ) return value; + return undefined; +} diff --git a/store/types/client/ClientListDTO.ts b/store/types/client/ClientListDTO.ts new file mode 100644 index 0000000..3b5357d --- /dev/null +++ b/store/types/client/ClientListDTO.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { ClientDTO, isClientDTO } from "./ClientDTO"; +import { isArrayOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication + */ +export interface ClientListDTO { + readonly payload: readonly ClientDTO[]; +} + +export function createClientListDTO (items: ClientDTO[]): ClientListDTO { + return { + payload: map(items, (item: ClientDTO): ClientDTO => item) + }; +} + +export function isClientListDTO (value: any): value is ClientListDTO { + return ( + !!value + && isArrayOf(value?.payload, isClientDTO) + ); +} + +export function stringifyClientListDTO (value: ClientListDTO): string { + if ( !isClientListDTO(value) ) throw new TypeError(`Not ClientListDTO: ${value}`); + return `ClientListDTO(${value})`; +} + +export function parseClientListDTO (value: any): ClientListDTO | undefined { + if ( isClientListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/client/NewClientDTO.ts b/store/types/client/NewClientDTO.ts new file mode 100644 index 0000000..e29e012 --- /dev/null +++ b/store/types/client/NewClientDTO.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { isArrayOrUndefinedOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication when creating a new client record + */ +export interface NewClientDTO { + readonly company ?: string; + readonly companyCode ?: string; + readonly firstName ?: string; + readonly lastName ?: string; + readonly address ?: string | string[]; + readonly postCode ?: string; + readonly postName ?: string; + readonly country ?: string; + readonly email ?: string | string[]; + readonly phone ?: string; + readonly mobile ?: string; + readonly fax ?: string; + readonly billingLang ?: string; + readonly sendEmail ?: boolean; + readonly sendPost ?: boolean; +} + +export function createNewClientDTO ( + company ?: string, + companyCode ?: string, + firstName ?: string, + lastName ?: string, + address ?: string | string[], + postCode ?: string, + postName ?: string, + country ?: string, + email ?: string | string[], + phone ?: string, + mobile ?: string, + fax ?: string, + billingLang ?: string, + sendEmail ?: boolean, + sendPost ?: boolean, +): NewClientDTO { + return { + ...(company !== undefined ? {company} : {}), + ...(companyCode !== undefined ? {companyCode} : {}), + ...(firstName !== undefined ? {firstName} : {}), + ...(lastName !== undefined ? {lastName} : {}), + ...(address !== undefined ? {address} : {}), + ...(postCode !== undefined ? {postCode} : {}), + ...(postName !== undefined ? {postName} : {}), + ...(country !== undefined ? {country} : {}), + ...(email !== undefined ? {email} : {}), + ...(phone !== undefined ? {phone} : {}), + ...(mobile !== undefined ? {mobile} : {}), + ...(fax !== undefined ? {fax} : {}), + ...(billingLang !== undefined ? {billingLang} : {}), + ...(sendEmail !== undefined ? {sendEmail} : {}), + ...(sendPost !== undefined ? {sendPost} : {}), + }; +} + +export function isNewClientDTO (value: any): value is NewClientDTO { + return ( + isRegularObject(value) + && isStringOrUndefined(value?.company) + && isStringOrUndefined(value?.companyCode) + && isStringOrUndefined(value?.firstName) + && isStringOrUndefined(value?.lastName) + && (isString(value?.address) || isArrayOrUndefinedOf(value?.address, isString)) + && isStringOrUndefined(value?.postCode) + && isStringOrUndefined(value?.postName) + && isStringOrUndefined(value?.country) + && (isString(value?.email) || isArrayOrUndefinedOf(value?.email, isString)) + && isStringOrUndefined(value?.phone) + && isStringOrUndefined(value?.mobile) + && isStringOrUndefined(value?.fax) + && isStringOrUndefined(value?.billingLang) + && isBooleanOrUndefined(value?.sendEmail) + && isBooleanOrUndefined(value?.sendPost) + ); +} + +export function stringifyNewClientDTO (value: NewClientDTO): string { + if ( !isNewClientDTO(value) ) throw new TypeError(`Not NewClientDTO: ${value}`); + return `NewClientDTO(${value})`; +} + +export function parseNewClientDTO (value: any): NewClientDTO | undefined { + if ( isNewClientDTO(value) ) return value; + return undefined; +} diff --git a/store/types/domain/DomainSearchDTO.ts b/store/types/domain/DomainSearchDTO.ts new file mode 100644 index 0000000..a2ecf8d --- /dev/null +++ b/store/types/domain/DomainSearchDTO.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { DomainSearchState, explainDomainSearchState, isDomainSearchState } from "./DomainSearchState"; +import { DomainSearchResult, explainDomainSearchResult, isDomainSearchResult } from "./DomainSearchResult"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, isString } from "../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; +import { explainArrayOf, isArrayOf } from "../../../types/Array"; + +export interface DomainSearchDTO { + + /** + * The search string for domains + */ + readonly search : string; + + /** + * This is the status of the main result only if there is complete match for `name`. + * + * When there's at least one result still on-going it will be defined as SEARCHING. + * + * If there is no results at all, it will be UNAVAILABLE. + */ + readonly state : DomainSearchState; + + /** + * Available results + */ + readonly results : readonly DomainSearchResult[]; + +} + +export function createDomainSearchDTO ( + search : string, + state : DomainSearchState, + results : readonly DomainSearchResult[] +) : DomainSearchDTO { + return { + search, + state, + results + }; +} + +export function isDomainSearchDTO (value: any) : value is DomainSearchDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'search', + 'state', + 'results' + ]) + && isString(value?.search) + && isDomainSearchState(value?.state) + && isArrayOf(value?.results, isDomainSearchResult) + ); +} + +export function explainDomainSearchDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'search', + 'state', + 'results' + ]) + , explainProperty("search", explainString(value?.search)) + , explainProperty("state", explainDomainSearchState(value?.state)) + , explainProperty("results", explainArrayOf( + "DomainSearchResult", + explainDomainSearchResult, + value?.state, + isDomainSearchResult + )) + ] + ); +} + +export function stringifyDomainProductSearchDTO (value : DomainSearchDTO) : string { + return `DomainProductSearchDTO(${value})`; +} + +export function parseDomainProductSearchDTO (value: any) : DomainSearchDTO | undefined { + if (isDomainSearchDTO(value)) return value; + return undefined; +} diff --git a/store/types/domain/DomainSearchResult.ts b/store/types/domain/DomainSearchResult.ts new file mode 100644 index 0000000..f96e710 --- /dev/null +++ b/store/types/domain/DomainSearchResult.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainProduct, isProduct, Product } from "../product/Product"; +import { DomainSearchState, explainDomainSearchState, isDomainSearchState } from "./DomainSearchState"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, isString } from "../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; +import { explainArrayOf, isArrayOf } from "../../../types/Array"; + +export interface DomainSearchResult { + + /** + * Domain name + */ + readonly name : string; + + readonly state : DomainSearchState; + + /** + * Available products to buy if any found + */ + readonly productList : readonly Product[]; + +} + +export function createDomainSearchResult ( + name : string, + state : DomainSearchState, + productList : readonly Product[] +) : DomainSearchResult { + return { + name, + state, + productList + }; +} + +export function isDomainSearchResult (value: any) : value is DomainSearchResult { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name', + 'state', + 'productList' + ]) + && isString(value?.name) + && isDomainSearchState(value?.state) + && isArrayOf(value?.productList, isProduct) + ); +} + +export function explainDomainSearchResult (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'name', + 'state', + 'product' + ]), + explainProperty("name", explainString(value?.name)), + explainProperty("state", explainDomainSearchState(value?.state)), + explainProperty("product", explainArrayOf( + "Product", + explainProduct, + value?.productList, + isProduct + )) + ] + ); +} + +export function stringifyDomainSearchResult (value : DomainSearchResult) : string { + return `DomainSearchResult(${value})`; +} + +export function parseDomainSearchResult (value: any) : DomainSearchResult | undefined { + if (isDomainSearchResult(value)) return value; + return undefined; +} diff --git a/store/types/domain/DomainSearchState.ts b/store/types/domain/DomainSearchState.ts new file mode 100644 index 0000000..4a622af --- /dev/null +++ b/store/types/domain/DomainSearchState.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../types/Enum"; + +export enum DomainSearchState { + SEARCHING = "SEARCHING", + TRANSFERABLE = "TRANSFERABLE", + UNAVAILABLE = "UNAVAILABLE", + AVAILABLE = "AVAILABLE" +} + +export function isDomainSearchState (value: any) : value is DomainSearchState { + switch (value) { + case DomainSearchState.SEARCHING: + case DomainSearchState.TRANSFERABLE: + case DomainSearchState.UNAVAILABLE: + case DomainSearchState.AVAILABLE: + return true; + default: + return false; + } +} + +export function explainDomainSearchState (value : any) : string { + return explainEnum("DomainSearchState", DomainSearchState, isDomainSearchState, value); +} + +export function stringifyDomainSearchState (value : DomainSearchState) : string { + switch (value) { + case DomainSearchState.SEARCHING : return 'SEARCHING'; + case DomainSearchState.TRANSFERABLE : return 'UNAVAILABLE'; + case DomainSearchState.UNAVAILABLE : return 'UNAVAILABLE'; + case DomainSearchState.AVAILABLE : return 'AVAILABLE'; + } + throw new TypeError(`Unsupported DomainSearchState value: ${value}`) +} + +export function parseDomainSearchState (value: any) : DomainSearchState | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'SEARCHING' : return DomainSearchState.SEARCHING; + case 'TRANSFERABLE' : return DomainSearchState.TRANSFERABLE; + case 'UNAVAILABLE' : return DomainSearchState.UNAVAILABLE; + case 'AVAILABLE' : return DomainSearchState.AVAILABLE; + default : return undefined; + } +} diff --git a/store/types/inventory/InventoryItemDTO.ts b/store/types/inventory/InventoryItemDTO.ts new file mode 100644 index 0000000..435a922 --- /dev/null +++ b/store/types/inventory/InventoryItemDTO.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isBoolean } from "../../../types/Boolean"; +import { explainProductType, isProductType, ProductType } from "../product/ProductType"; +import { explainProductPriceType, isProductPriceType, ProductPriceType } from "../product/ProductPriceType"; +import { isInventoryState, InventoryState, explainInventoryState } from "./InventoryState"; +import { explainInventoryData, InventoryData, isInventoryData } from "./data/InventoryData"; +import { ReadonlyJsonObject } from "../../../Json"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainBoolean } from "../../../types/Boolean"; +import { explainString, isString } from "../../../types/String"; +import { explainNumber, isNumber } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface InventoryItemDTO extends ReadonlyJsonObject { + readonly inventoryItemId : string; + readonly clientId : string; + readonly updated : string; + readonly created : string; + readonly date : string; + readonly endDate : string; + readonly state : InventoryState; + readonly title : string; + readonly summary : string; + readonly productId : string; + readonly productType : ProductType; + readonly priceSum : number; + readonly priceVatPercent : number; + readonly priceType : ProductPriceType; + readonly internalNote : string; + readonly onHold : boolean; + readonly isTerminated : boolean; + readonly data : InventoryData; +} + +export function createInventoryItemDTO ( + inventoryItemId : string, + clientId : string, + updated : string, + created : string, + date : string, + endDate : string, + state : InventoryState | undefined, + title : string, + summary : string, + productId : string, + productType : ProductType, + priceSum : number, + priceVatPercent : number, + priceType : ProductPriceType, + internalNote : string, + onHold : boolean, + isTerminated : boolean, + data : InventoryData = {} +): InventoryItemDTO { + return { + inventoryItemId, + clientId, + updated, + created, + date, + endDate, + state: state ?? InventoryState.UNINITIALIZED, + title, + summary, + productId, + productType, + priceSum, + priceVatPercent, + priceType, + internalNote, + onHold, + isTerminated, + data + }; +} + +export function isInventoryItemDTO (value: any): value is InventoryItemDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'inventoryItemId', + 'clientId', + 'updated', + 'created', + 'date', + 'endDate', + 'state', + 'title', + 'summary', + 'productId', + 'productType', + 'priceSum', + 'priceVatPercent', + 'priceType', + 'internalNote', + 'isTerminated', + 'onHold', + 'data' + ]) + && isString(value?.inventoryItemId) + && isString(value?.clientId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.date) + && isString(value?.endDate) + && isInventoryState(value?.state) + && isString(value?.title) + && isString(value?.summary) + && isString(value?.productId) + && isProductType(value?.productType) + && isNumber(value?.priceSum) + && isNumber(value?.priceVatPercent) + && isProductPriceType(value?.priceType) + && isString(value?.internalNote) + && isBoolean(value?.isTerminated) + && isBoolean(value?.onHold) + && isInventoryData(value?.data) + ); +} + +export function explainInventoryItemDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'inventoryItemId', + 'clientId', + 'updated', + 'created', + 'date', + 'endDate', + 'state', + 'title', + 'summary', + 'productId', + 'productType', + 'priceSum', + 'priceVatPercent', + 'priceType', + 'internalNote', + 'isTerminated', + 'onHold', + 'data' + ]), + explainProperty("inventoryItemId", explainString(value?.inventoryItemId)), + explainProperty("clientId", explainString(value?.clientId)), + explainProperty("updated", explainString(value?.updated)), + explainProperty("created", explainString(value?.created)), + explainProperty("date", explainString(value?.date)), + explainProperty("endDate", explainString(value?.endDate)), + explainProperty("state", explainInventoryState(value?.state)), + explainProperty("title", explainString(value?.title)), + explainProperty("summary", explainString(value?.summary)), + explainProperty("productId", explainString(value?.productId)), + explainProperty("productType", explainProductType(value?.productType)), + explainProperty("priceSum", explainNumber(value?.priceSum)), + explainProperty("priceVatPercent", explainNumber(value?.priceVatPercent)), + explainProperty("priceType", explainProductPriceType(value?.priceType)), + explainProperty("internalNote", explainString(value?.internalNote)), + explainProperty("isTerminated", explainBoolean(value?.isTerminated)), + explainProperty("onHold", explainBoolean(value?.onHold)), + explainProperty("data", explainInventoryData(value?.data)) + ] + ); +} + +export function stringifyInventoryItemDTO (value: InventoryItemDTO): string { + return `InventoryItemDTO(${value})`; +} + +export function parseInventoryItemDTO (value: any): InventoryItemDTO | undefined { + if ( isInventoryItemDTO(value) ) return value; + return undefined; +} diff --git a/store/types/inventory/InventoryItemListDTO.ts b/store/types/inventory/InventoryItemListDTO.ts new file mode 100644 index 0000000..a589970 --- /dev/null +++ b/store/types/inventory/InventoryItemListDTO.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { InventoryItemDTO, isInventoryItemDTO } from "./InventoryItemDTO"; +import { isArrayOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication + */ +export interface InventoryItemListDTO { + readonly payload: readonly InventoryItemDTO[]; +} + +export function createInventoryItemListDTO (items: readonly InventoryItemDTO[]): InventoryItemListDTO { + return { + payload: map(items, (item: InventoryItemDTO): InventoryItemDTO => item) + } as InventoryItemListDTO; +} + +export function isInventoryItemListDTO (value: any): value is InventoryItemListDTO { + return ( + !!value + && isArrayOf(value?.payload, isInventoryItemDTO) + ); +} + +export function stringifyInventoryItemListDTO (value: InventoryItemListDTO): string { + if ( !isInventoryItemListDTO(value) ) throw new TypeError(`Not InventoryItemListDTO: ${value}`); + return `InventoryItemListDTO(${value})`; +} + +export function parseInventoryItemListDTO (value: any): InventoryItemListDTO | undefined { + if ( isInventoryItemListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/inventory/InventoryState.ts b/store/types/inventory/InventoryState.ts new file mode 100644 index 0000000..a7bc0af --- /dev/null +++ b/store/types/inventory/InventoryState.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; + +export enum InventoryState { + + /** + * Special type which may be used to mean the inventory item has not been + * saved yet + */ + UNINITIALIZED = "UNINITIALIZED", + + /** + * Inventory item is under construction + */ + DRAFT = "DRAFT", + + /** + * Inventory item is queued to be approved to the background services + */ + WAITING_APPROVAL = "WAITING_APPROVAL", + + /** + * Inventory item is was approved and is queued to be modified to the background services + */ + CHANGING = "CHANGING", + + /** + * Inventory item has been successfully updated to the background services + */ + READY = "READY", + + /** + * Inventory item was not successfully updated to the background services + */ + ERROR = "ERROR", + + /** + * Inventory item was locked + */ + LOCKED = "LOCKED", + + /** + * Inventory item was cancelled + */ + CANCELLED = "CANCELLED", + + /** + * Inventory item was removed + */ + DELETED = "DELETED" + +} + +export function isInventoryState (value: any): value is InventoryState { + switch (value) { + case InventoryState.UNINITIALIZED: + case InventoryState.DRAFT: + case InventoryState.WAITING_APPROVAL: + case InventoryState.CHANGING: + case InventoryState.READY: + case InventoryState.ERROR: + return true; + default: + return false; + } +} + +export function explainInventoryState (value : any) : string { + return explainEnum("InventoryState", InventoryState, isInventoryState, value); +} + +export function stringifyInventoryState (value: InventoryState): string { + switch (value) { + case InventoryState.UNINITIALIZED : return 'UNINITIALIZED'; + case InventoryState.DRAFT : return 'DRAFT'; + case InventoryState.WAITING_APPROVAL : return 'WAITING_APPROVAL'; + case InventoryState.CHANGING : return 'CHANGING'; + case InventoryState.READY : return 'READY'; + case InventoryState.ERROR : return 'ERROR'; + } + throw new TypeError(`Unsupported InventoryState value: ${value}`); +} + +export function parseInventoryState (value: any): InventoryState | undefined { + switch (`${value}`.toUpperCase()) { + case 'UNINITIALIZED' : return InventoryState.UNINITIALIZED; + case 'DRAFT' : return InventoryState.DRAFT; + case 'WAITING_APPROVAL' : return InventoryState.WAITING_APPROVAL; + case 'CHANGING' : return InventoryState.CHANGING; + case 'READY' : return InventoryState.READY; + case 'ERROR' : return InventoryState.ERROR; + default : return undefined; + } +} + +export function isInventoryStateOrUndefined (value: unknown): value is InventoryState | undefined { + return isUndefined(value) || isInventoryState(value); +} + +export function explainInventoryStateOrUndefined (value: unknown): string { + return isInventoryStateOrUndefined(value) ? explainOk() : explainNot(explainOr(['InventoryState', 'undefined'])); +} diff --git a/store/types/inventory/NewInventoryItemDTO.ts b/store/types/inventory/NewInventoryItemDTO.ts new file mode 100644 index 0000000..0f584ea --- /dev/null +++ b/store/types/inventory/NewInventoryItemDTO.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../../types/Boolean"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; +import { explainProductTypeOrUndefined, isProductTypeOrUndefined, ProductType } from "../product/ProductType"; +import { explainProductPriceTypeOrUndefined, isProductPriceTypeOrUndefined, ProductPriceType } from "../product/ProductPriceType"; +import { explainInventoryStateOrUndefined, InventoryState, isInventoryStateOrUndefined } from "./InventoryState"; +import { explainReadonlyJsonObjectOrUndefined, isReadonlyJsonObjectOrUndefined, ReadonlyJsonObject } from "../../../Json"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../types/String"; +import { explainNumberOrUndefined, isNumberOrUndefined } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface NewInventoryItemDTO { + readonly clientId : string; + readonly date ?: string; + readonly endDate ?: string; + readonly state ?: InventoryState; + readonly title ?: string; + readonly summary ?: string; + readonly productId ?: string; + readonly productType ?: ProductType; + readonly priceSum ?: number; + readonly priceVatPercent ?: number; + readonly priceType ?: ProductPriceType; + readonly internalNote ?: string; + readonly onHold ?: boolean; + readonly isTerminated ?: boolean; + readonly data ?: ReadonlyJsonObject; +} + +export function createNewInventoryItemDTO ( + clientId : string, + date : string | undefined, + endDate : string | undefined, + title : string | undefined, + state : InventoryState | undefined, + summary : string | undefined, + productId : string | undefined, + productType : ProductType | undefined, + priceSum : number | undefined, + priceVatPercent : number | undefined, + priceType : ProductPriceType | undefined, + internalNote : string | undefined, + onHold : boolean | undefined, + isTerminated : boolean | undefined, + data : ReadonlyJsonObject = {} +): NewInventoryItemDTO { + return { + clientId, + date, + endDate, + state, + title, + summary, + productId, + productType, + priceSum, + priceVatPercent, + priceType, + internalNote, + onHold, + isTerminated, + data + }; +} + +export function isNewInventoryItemDTO (value: any): value is NewInventoryItemDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'clientId', + 'date', + 'endDate', + 'state', + 'title', + 'summary', + 'productId', + 'productType', + 'priceSum', + 'priceVatPercent', + 'priceType', + 'internalNote', + 'isTerminated', + 'onHold', + 'data' + ]) + && isString(value?.clientId) + && isStringOrUndefined(value?.date) + && isStringOrUndefined(value?.endDate) + && isInventoryStateOrUndefined(value?.state) + && isStringOrUndefined(value?.title) + && isStringOrUndefined(value?.summary) + && isStringOrUndefined(value?.productId) + && isProductTypeOrUndefined(value?.productType) + && isNumberOrUndefined(value?.priceSum) + && isNumberOrUndefined(value?.priceVatPercent) + && isProductPriceTypeOrUndefined(value?.priceType) + && isStringOrUndefined(value?.internalNote) + && isBooleanOrUndefined(value?.isTerminated) + && isBooleanOrUndefined(value?.onHold) + && isReadonlyJsonObjectOrUndefined(value?.data) + ); +} + +export function stringifyNewInventoryItemDTO (value: NewInventoryItemDTO): string { + return `NewInventoryItemDTO(${value})`; +} + +export function parseNewInventoryItemDTO (value: any): NewInventoryItemDTO | undefined { + if ( isNewInventoryItemDTO(value) ) return value; + return undefined; +} + +export function explainNewInventoryItemDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'clientId', + 'date', + 'endDate', + 'state', + 'title', + 'summary', + 'productId', + 'productType', + 'priceSum', + 'priceVatPercent', + 'priceType', + 'internalNote', + 'isTerminated', + 'onHold', + 'data' + ]) + , explainProperty("clientId", explainString(value?.clientId)) + , explainProperty("date", explainStringOrUndefined(value?.date)) + , explainProperty("endDate", explainStringOrUndefined(value?.endDate)) + , explainProperty("state", explainInventoryStateOrUndefined(value?.state)) + , explainProperty("title", explainStringOrUndefined(value?.title)) + , explainProperty("summary", explainStringOrUndefined(value?.summary)) + , explainProperty("productId", explainStringOrUndefined(value?.productId)) + , explainProperty("productType", explainProductTypeOrUndefined(value?.productType)) + , explainProperty("priceSum", explainNumberOrUndefined(value?.priceSum)) + , explainProperty("priceVatPercent", explainNumberOrUndefined(value?.priceVatPercent)) + , explainProperty("priceType", explainProductPriceTypeOrUndefined(value?.priceType)) + , explainProperty("internalNote", explainStringOrUndefined(value?.internalNote)) + , explainProperty("isTerminated", explainBooleanOrUndefined(value?.isTerminated)) + , explainProperty("onHold", explainBooleanOrUndefined(value?.onHold)) + , explainProperty("data", explainReadonlyJsonObjectOrUndefined(value?.data)) + ] + ); +} + +export function isNewInventoryItemDTOOrUndefined (value: unknown): value is NewInventoryItemDTO | undefined { + return isUndefined(value) || isNewInventoryItemDTO(value); +} + +export function explainNewInventoryItemDTOOrUndefined (value: unknown): string { + return isNewInventoryItemDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['NewInventoryItemDTO', 'undefined'])); +} diff --git a/store/types/inventory/data/DatabaseInventoryData.ts b/store/types/inventory/data/DatabaseInventoryData.ts new file mode 100644 index 0000000..057989b --- /dev/null +++ b/store/types/inventory/data/DatabaseInventoryData.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { InventoryData } from "./InventoryData"; +import { isString } from "../../../../types/String"; +import { isRegularObject } from "../../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface DatabaseInventoryData extends InventoryData { + + readonly hostname : string; + readonly username : string; + + /** + * Database name + */ + readonly name : string; + +} + +export function createDatabaseInventoryData ( + hostname : string, + username : string, + name : string +): DatabaseInventoryData { + return { + hostname, + username, + name + }; +} + +export function isDatabaseInventoryData (value: any): value is DatabaseInventoryData { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'hostname', + 'username', + 'name' + ]) + && isString(value?.hostname) + && isString(value?.username) + && isString(value?.name) + ); +} + +export function stringifyDatabaseInventoryData (value: DatabaseInventoryData): string { + return `DatabaseInventoryData(${value})`; +} + +export function parseDatabaseInventoryData (value: any): DatabaseInventoryData | undefined { + if ( isDatabaseInventoryData(value) ) return value; + return undefined; +} diff --git a/store/types/inventory/data/DomainInventoryData.ts b/store/types/inventory/data/DomainInventoryData.ts new file mode 100644 index 0000000..f160448 --- /dev/null +++ b/store/types/inventory/data/DomainInventoryData.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { InventoryData } from "./InventoryData"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface DomainInventoryData extends InventoryData { + + /** + * Domain name, e.g. `example.fi` + */ + readonly name : string; + +} + +export function createDomainInventoryData ( + name: string +): DomainInventoryData { + return { + name + }; +} + +export function isDomainInventoryData (value: any): value is DomainInventoryData { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name' + ]) + && isString(value?.name) + ); +} + +export function isPartialDomainInventoryData (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name' + ]) + && isStringOrUndefined(value?.name) + ); +} + +export function explainDomainInventoryData (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'name' + ]) + , explainProperty("name", explainString(value?.name)) + ] + ); +} + +export function explainPartialDomainInventoryData (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'name' + ]) + , explainProperty("name", explainStringOrUndefined(value?.name)) + ] + ); +} + + +export function stringifyDomainInventoryData (value: DomainInventoryData): string { + return `DomainInventoryData(${value})`; +} + +export function parseDomainInventoryData (value: any): DomainInventoryData | undefined { + if ( isDomainInventoryData(value) ) return value; + return undefined; +} diff --git a/store/types/inventory/data/DomainTransferInventoryData.ts b/store/types/inventory/data/DomainTransferInventoryData.ts new file mode 100644 index 0000000..2b36ed4 --- /dev/null +++ b/store/types/inventory/data/DomainTransferInventoryData.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { InventoryData } from "./InventoryData"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface DomainTransferInventoryData extends InventoryData { + + /** + * Domain name, e.g. `example.fi` + */ + readonly name : string; + + readonly authId : string; + +} + +export function createDomainTransferInventoryData ( + name: string, + authId: string +): DomainTransferInventoryData { + return { + name, + authId + }; +} + +export function isDomainTransferInventoryData (value: any): value is DomainTransferInventoryData { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name', + 'authId' + ]) + && isString(value?.name) + && isString(value?.authId) + ); +} + +export function isPartialDomainTransferInventoryData (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name', + 'authId' + ]) + && isStringOrUndefined(value?.name) + && isStringOrUndefined(value?.authId) + ); +} + +export function explainDomainTransferInventoryData (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'name' + ]) + , explainProperty("name", explainString(value?.name)) + , explainProperty("authId", explainString(value?.authId)) + ] + ); +} + +export function explainPartialDomainTransferInventoryData (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'name' + ]) + , explainProperty("name", explainStringOrUndefined(value?.name)) + , explainProperty("authId", explainStringOrUndefined(value?.authId)) + ] + ); +} + +export function stringifyDomainTransferInventoryData (value: DomainTransferInventoryData): string { + return `DomainTransferInventoryData(${value})`; +} + +export function parseDomainTransferInventoryData (value: any): DomainTransferInventoryData | undefined { + if ( isDomainTransferInventoryData(value) ) return value; + return undefined; +} diff --git a/store/types/inventory/data/EmailInventoryData.ts b/store/types/inventory/data/EmailInventoryData.ts new file mode 100644 index 0000000..5c129a9 --- /dev/null +++ b/store/types/inventory/data/EmailInventoryData.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { explainNumberOrUndefined, isNumberOrUndefined } from "../../../../types/Number"; +import { InventoryData } from "./InventoryData"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface EmailInventoryData extends InventoryData { + readonly hostname : string; + readonly username : string; + + /** + * Total storage in MBs this item should use (but not limited to this) + */ + readonly totalStorage ?: number | undefined; + + /** + * Total used storage in MBs from the system + */ + readonly usedStorage ?: number | undefined; + +} + +export function createEmailInventoryData ( + hostname: string, + username: string, + totalStorage ?: number | undefined, + usedStorage ?: number | undefined, +): EmailInventoryData { + return { + hostname, + username, + totalStorage: totalStorage ?? undefined, + usedStorage: usedStorage ?? undefined, + }; +} + +export function isEmailInventoryData (value: any): value is EmailInventoryData { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'hostname', + 'username', + 'totalStorage', + 'usedStorage', + ]) + && isString(value?.hostname) + && isString(value?.username) + && isNumberOrUndefined(value?.totalStorage) + && isNumberOrUndefined(value?.usedStorage) + ); +} + +export function explainEmailInventoryData (value: any): string { + return explain( + [ + explainRegularObject(value) + && explainNoOtherKeys(value, [ + 'hostname', + 'username', + 'totalStorage', + 'usedStorage', + ]) + && explainProperty("hostname", explainString(value?.hostname)) + && explainProperty("username", explainString(value?.username)) + && explainProperty("totalStorage", explainNumberOrUndefined(value?.totalStorage)) + && explainProperty("usedStorage", explainNumberOrUndefined(value?.usedStorage)) + ] + ); +} + +export function isPartialEmailInventoryData (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'hostname', + 'username', + 'totalStorage', + 'usedStorage', + ]) + && isStringOrUndefined(value?.hostname) + && isStringOrUndefined(value?.username) + && isNumberOrUndefined(value?.totalStorage) + && isNumberOrUndefined(value?.usedStorage) + ); +} + +export function explainPartialEmailInventoryData (value: any): string { + return explain( + [ + explainRegularObject(value) + && explainNoOtherKeys(value, [ + 'hostname', + 'username', + 'totalStorage', + 'usedStorage', + ]) + && explainProperty("hostname", explainStringOrUndefined(value?.hostname)) + && explainProperty("username", explainStringOrUndefined(value?.username)) + && explainProperty("totalStorage", explainNumberOrUndefined(value?.totalStorage)) + && explainProperty("usedStorage", explainNumberOrUndefined(value?.usedStorage)) + ] + ); +} + +export function parseEmailInventoryData (value: any): EmailInventoryData | undefined { + if ( isEmailInventoryData(value) ) return value; + return undefined; +} diff --git a/store/types/inventory/data/InventoryData.ts b/store/types/inventory/data/InventoryData.ts new file mode 100644 index 0000000..9b38960 --- /dev/null +++ b/store/types/inventory/data/InventoryData.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { + explainReadonlyJsonObject, + isReadonlyJsonObject, + ReadonlyJsonObject +} from "../../../../Json"; + +export interface InventoryData extends ReadonlyJsonObject { +} + +export function isInventoryData (value: any) : value is InventoryData { + return isReadonlyJsonObject(value); +} + +export function explainInventoryData (value: any) : string { + return explainReadonlyJsonObject(value); +} diff --git a/store/types/inventory/data/ShellInventoryData.ts b/store/types/inventory/data/ShellInventoryData.ts new file mode 100644 index 0000000..2eff920 --- /dev/null +++ b/store/types/inventory/data/ShellInventoryData.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { InventoryData } from "./InventoryData"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainNumber, explainNumberOrUndefined, isNumber, isNumberOrUndefined } from "../../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface ShellInventoryData extends InventoryData { + + readonly hostname : string; + + readonly username : string; + + /** + * Note! Other users of the server may see this + */ + readonly realName : string; + + /** + * SSH Port, defaults to 22. + */ + readonly port : number; + + /** + * Total storage in MBs this item should use (but not limited to this) + */ + readonly totalStorage ?: number | undefined; + + /** + * Total used storage in MBs from the system + */ + readonly usedStorage ?: number | undefined; + +} + +export function createShellInventoryData ( + hostname : string, + username : string, + realName : string, + port ?: number, + totalStorage ?: number | undefined, + usedStorage ?: number | undefined, +): ShellInventoryData { + return { + hostname, + username, + realName, + port: port ?? 22, + totalStorage: totalStorage ?? undefined, + usedStorage: usedStorage ?? undefined, + }; +} + +export function isShellInventoryData (value: any): value is ShellInventoryData { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'hostname', + 'username', + 'realName', + 'port', + 'totalStorage', + 'usedStorage', + ]) + && isString(value?.hostname) + && isString(value?.username) + && isString(value?.realName) + && isNumber(value?.port) + && isNumberOrUndefined(value?.totalStorage) + && isNumberOrUndefined(value?.usedStorage) + ); +} + +export function explainShellInventoryData (value: any): string { + return explain( + [ + explainRegularObject(value) + && explainNoOtherKeysInDevelopment(value, [ + 'hostname', + 'username', + 'realName', + 'port', + 'totalStorage', + 'usedStorage', + ]) + && explainProperty("hostname", explainString(value?.hostname)) + && explainProperty("username", explainString(value?.username)) + && explainProperty("realName", explainString(value?.realName)) + && explainProperty("port", explainNumber(value?.port)) + && explainProperty("totalStorage", explainNumberOrUndefined(value?.totalStorage)) + && explainProperty("usedStorage", explainNumberOrUndefined(value?.usedStorage)) + ] + ); +} + +export function isPartialShellInventoryData (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'hostname', + 'username', + 'realName', + 'port', + 'totalStorage', + 'usedStorage', + ]) + && isStringOrUndefined(value?.hostname) + && isStringOrUndefined(value?.username) + && isStringOrUndefined(value?.realName) + && isNumberOrUndefined(value?.port) + && isNumberOrUndefined(value?.totalStorage) + && isNumberOrUndefined(value?.usedStorage) + ); +} + +export function explainPartialShellInventoryData (value: any): string { + return explain( + [ + explainRegularObject(value) + && explainNoOtherKeysInDevelopment(value, [ + 'hostname', + 'username', + 'realName', + 'port', + 'totalStorage', + 'usedStorage', + ]) + && explainProperty("hostname", explainStringOrUndefined(value?.hostname)) + && explainProperty("username", explainStringOrUndefined(value?.username)) + && explainProperty("realName", explainStringOrUndefined(value?.realName)) + && explainProperty("port", explainNumberOrUndefined(value?.port)) + && explainProperty("totalStorage", explainNumberOrUndefined(value?.totalStorage)) + && explainProperty("usedStorage", explainNumberOrUndefined(value?.usedStorage)) + ] + ); +} + +export function parseShellInventoryData (value: any): ShellInventoryData | undefined { + if ( isShellInventoryData(value) ) return value; + return undefined; +} diff --git a/store/types/inventory/data/VirtualServerInventoryData.ts b/store/types/inventory/data/VirtualServerInventoryData.ts new file mode 100644 index 0000000..7fbbb72 --- /dev/null +++ b/store/types/inventory/data/VirtualServerInventoryData.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { InventoryData } from "./InventoryData"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface VirtualServerInventoryData extends InventoryData { + + /** + * Host system name, e.g. `"vm3"` + */ + readonly system : string; + + /** + * The virtual server name, e.g. `"s123"` + */ + readonly name : string; + +} + +export function createVirtualServerInventoryData ( + system: string, + name : string +): VirtualServerInventoryData { + return { + system, + name + }; +} + +export function isVirtualServerInventoryData (value: any): value is VirtualServerInventoryData { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'system', + 'name' + ]) + && isString(value?.system) + && isString(value?.name) + ); +} + +export function explainVirtualServerInventoryData (value: any): string { + return explain( + [ + explainRegularObject(value) + && explainNoOtherKeys(value, [ + 'system', + 'name' + ]) + && explainProperty("system", explainString(value?.system)) + && explainProperty("name", explainString(value?.name)) + ] + ); +} + +export function isPartialVirtualServerInventoryData (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'system', + 'name' + ]) + && isStringOrUndefined(value?.system) + && isStringOrUndefined(value?.name) + ); +} + +export function explainPartialVirtualServerInventoryData (value: any): string { + return explain( + [ + explainRegularObject(value) + && explainNoOtherKeys(value, [ + 'system', + 'name' + ]) + && explainProperty("system", explainStringOrUndefined(value?.system)) + && explainProperty("name", explainStringOrUndefined(value?.name)) + ] + ); +} + +export function stringifyVirtualServerInventoryData (value: VirtualServerInventoryData): string { + return `VirtualServerInventoryData(${value})`; +} + +export function parseVirtualServerInventoryData (value: any): VirtualServerInventoryData | undefined { + if ( isVirtualServerInventoryData(value) ) return value; + return undefined; +} diff --git a/store/types/inventory/data/WebHotelInventoryData.ts b/store/types/inventory/data/WebHotelInventoryData.ts new file mode 100644 index 0000000..c0af573 --- /dev/null +++ b/store/types/inventory/data/WebHotelInventoryData.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainNumberOrUndefined, isNumberOrUndefined } from "../../../../types/Number"; +import { InventoryData } from "./InventoryData"; +import { explain, explainProperty } from "../../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, explainNoOtherKeysInDevelopment, hasNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../../../types/OtherKeys"; + +export interface WebHotelInventoryData extends InventoryData { + + /** + * Host system name, e.g. `"lxc3"` + */ + readonly system : string; + + /** + * The virtual server name, e.g. `"example-1"` + */ + readonly name : string; + + /** + * Total storage in MBs this item should use (but not limited to this) + */ + readonly totalStorage ?: number | undefined; + + /** + * Total used storage in MBs from the system + */ + readonly usedStorage ?: number | undefined; + +} + +export function createWebHotelInventoryData ( + system: string, + name : string, + totalStorage ?: number | undefined, + usedStorage ?: number | undefined, +): WebHotelInventoryData { + return { + system, + name, + totalStorage: totalStorage ?? undefined, + usedStorage: usedStorage ?? undefined, + }; +} + +export function isWebHotelInventoryData (value: any): value is WebHotelInventoryData { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'system', + 'name', + 'totalStorage', + 'usedStorage', + ]) + && isString(value?.system) + && isString(value?.name) + && isNumberOrUndefined(value?.totalStorage) + && isNumberOrUndefined(value?.usedStorage) + ); +} + +export function explainWebHotelInventoryData (value: any): string { + return explain( + [ + explainRegularObject(value) + && explainNoOtherKeysInDevelopment(value, [ + 'system', + 'name', + 'totalStorage', + 'usedStorage', + ]) + && explainProperty("system", explainString(value?.system)) + && explainProperty("name", explainString(value?.name)) + && explainProperty("totalStorage", explainNumberOrUndefined(value?.totalStorage)) + && explainProperty("usedStorage", explainNumberOrUndefined(value?.usedStorage)) + ] + ); +} + +export function isPartialWebHotelInventoryData (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'system', + 'name', + 'totalStorage', + 'usedStorage', + ]) + && isStringOrUndefined(value?.system) + && isStringOrUndefined(value?.name) + && isNumberOrUndefined(value?.totalStorage) + && isNumberOrUndefined(value?.usedStorage) + ); +} + +export function explainPartialWebHotelInventoryData (value: any): string { + return explain( + [ + explainRegularObject(value) + && explainNoOtherKeys(value, [ + 'system', + 'name', + 'totalStorage', + 'usedStorage', + ]) + && explainProperty("system", explainStringOrUndefined(value?.system)) + && explainProperty("name", explainStringOrUndefined(value?.name)) + && explainProperty("totalStorage", explainNumberOrUndefined(value?.totalStorage)) + && explainProperty("usedStorage", explainNumberOrUndefined(value?.usedStorage)) + ] + ); +} + +export function parseWebHotelInventoryData (value: any): WebHotelInventoryData | undefined { + if ( isWebHotelInventoryData(value) ) return value; + return undefined; +} diff --git a/store/types/invoice/InvoiceDTO.test.ts b/store/types/invoice/InvoiceDTO.test.ts new file mode 100644 index 0000000..d0c869e --- /dev/null +++ b/store/types/invoice/InvoiceDTO.test.ts @@ -0,0 +1,473 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createInvoiceDTO, explainInvoiceDTO, explainInvoiceDTOOrUndefined, InvoiceDTO, isInvoiceDTO, isInvoiceDTOOrUndefined, parseInvoiceDTO } from "./InvoiceDTO"; +import { createInvoiceRowDTO, InvoiceRowDTO } from "./InvoiceRowDTO"; +import { PaytrailPaymentProviderListDTO } from "../../../paytrail/dtos/PaytrailPaymentProviderListDTO"; +import { PaytrailPaymentMethodGroup } from "../../../paytrail/types/PaytrailPaymentMethodGroup"; +import { PaytrailProvider } from "../../../paytrail/types/PaytrailProvider"; +import { PaytrailPaymentDTO } from "../../../paytrail/dtos/PaytrailPaymentDTO"; +import { PaytrailStatus } from "../../../paytrail/types/PaytrailStatus"; +import { PaytrailCurrency } from "../../../paytrail/types/PaytrailCurrency"; + +describe('InvoiceDTO', () => { + + const validInvoiceRowDTO: InvoiceRowDTO = createInvoiceRowDTO( + 'INV1', + 'INV1', + 'PAY1', + 'CAM1', + 'CAM_PAY1', + 'PROD1', + '123456', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-12-31T00:00:00Z', + 'Some Product', + 'Internal note', + 1, + 100, + 0.2, + 0.1 + ); + + const validPaytrailPaymentProviderListDTO : PaytrailPaymentProviderListDTO = { + "terms": "Valitsemalla maksutavan hyväksyt maksupalveluehdot", + "providers": [ + { + "id": "pivo", + "name": "Pivo", + "icon": "https://resources.paytrail.com/images/payment-method-logos/pivo.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/pivo.svg", + "group": PaytrailPaymentMethodGroup.MOBILE + }, + { + "id": "osuuspankki", + "name": "OP", + "icon": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "nordea", + "name": "Nordea", + "icon": "https://resources.paytrail.com/images/payment-method-logos/nordea.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/nordea.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "handelsbanken", + "name": "Handelsbanken", + "icon": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "pop", + "name": "POP Pankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/pop.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/pop.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "aktia", + "name": "Aktia", + "icon": "https://resources.paytrail.com/images/payment-method-logos/aktia.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/aktia.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "saastopankki", + "name": "Säästöpankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "omasp", + "name": "Oma Säästöpankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/omasp.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/omasp.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "spankki", + "name": "S-pankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/spankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/spankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "alandsbanken", + "name": "Ålandsbanken", + "icon": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "danske", + "name": "Danske Bank", + "icon": "https://resources.paytrail.com/images/payment-method-logos/danske.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/danske.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "creditcard", + "name": "Visa", + "icon": "https://resources.paytrail.com/images/payment-method-logos/visa.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/visa.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "creditcard", + "name": "Visa Electron", + "icon": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "creditcard", + "name": "Mastercard", + "icon": "https://resources.paytrail.com/images/payment-method-logos/mastercard.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/mastercard.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "amex", + "name": "American Express", + "icon": "https://resources.paytrail.com/images/payment-method-logos/amex.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/amex.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "collectorb2c", + "name": "Collector", + "icon": "https://resources.paytrail.com/images/payment-method-logos/walley.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/walley.svg", + "group": PaytrailPaymentMethodGroup.CREDIT + }, + { + "id": "collectorb2b", + "name": "Collector B2B", + "icon": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.svg", + "group": PaytrailPaymentMethodGroup.CREDIT + } + ], + "groups": [ + { + "id": PaytrailPaymentMethodGroup.MOBILE, + "name": "Mobiilimaksutavat", + "icon": "https://resources.paytrail.com/images/payment-group-icons/mobile.png", + "svg": "https://resources.paytrail.com/images/payment-group-icons/mobile.svg", + "providers": [ + { + "id": "pivo", + "name": "Pivo", + "icon": "https://resources.paytrail.com/images/payment-method-logos/pivo.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/pivo.svg", + "group": PaytrailPaymentMethodGroup.MOBILE + } + ] + }, + { + "id": PaytrailPaymentMethodGroup.BANK, + "name": "Pankkimaksutavat", + "icon": "https://resources.paytrail.com/images/payment-group-icons/bank.png", + "svg": "https://resources.paytrail.com/images/payment-group-icons/bank.svg", + "providers": [ + { + "id": "osuuspankki", + "name": "OP", + "icon": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/osuuspankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "nordea", + "name": "Nordea", + "icon": "https://resources.paytrail.com/images/payment-method-logos/nordea.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/nordea.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "handelsbanken", + "name": "Handelsbanken", + "icon": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/handelsbanken.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "pop", + "name": "POP Pankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/pop.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/pop.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "aktia", + "name": "Aktia", + "icon": "https://resources.paytrail.com/images/payment-method-logos/aktia.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/aktia.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "saastopankki", + "name": "Säästöpankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/saastopankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "omasp", + "name": "Oma Säästöpankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/omasp.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/omasp.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "spankki", + "name": "S-pankki", + "icon": "https://resources.paytrail.com/images/payment-method-logos/spankki.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/spankki.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "alandsbanken", + "name": "Ålandsbanken", + "icon": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/alandsbanken.svg", + "group": PaytrailPaymentMethodGroup.BANK + }, + { + "id": "danske", + "name": "Danske Bank", + "icon": "https://resources.paytrail.com/images/payment-method-logos/danske.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/danske.svg", + "group": PaytrailPaymentMethodGroup.BANK + } + ] + }, + { + "id": PaytrailPaymentMethodGroup.CREDIT_CARD, + "name": "Korttimaksutavat", + "icon": "https://resources.paytrail.com/images/payment-group-icons/creditcard.png", + "svg": "https://resources.paytrail.com/images/payment-group-icons/creditcard.svg", + "providers": [ + { + "id": "creditcard", + "name": "Visa", + "icon": "https://resources.paytrail.com/images/payment-method-logos/visa.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/visa.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "creditcard", + "name": "Visa Electron", + "icon": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/visa-electron.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "creditcard", + "name": "Mastercard", + "icon": "https://resources.paytrail.com/images/payment-method-logos/mastercard.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/mastercard.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + }, + { + "id": "amex", + "name": "American Express", + "icon": "https://resources.paytrail.com/images/payment-method-logos/amex.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/amex.svg", + "group": PaytrailPaymentMethodGroup.CREDIT_CARD + } + ] + }, + { + "id": PaytrailPaymentMethodGroup.CREDIT, + "name": "Lasku- ja osamaksutavat", + "icon": "https://resources.paytrail.com/images/payment-group-icons/credit.png", + "svg": "https://resources.paytrail.com/images/payment-group-icons/credit.svg", + "providers": [ + { + "id": "collectorb2c", + "name": "Collector", + "icon": "https://resources.paytrail.com/images/payment-method-logos/walley.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/walley.svg", + "group": PaytrailPaymentMethodGroup.CREDIT + }, + { + "id": "collectorb2b", + "name": "Collector B2B", + "icon": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.png", + "svg": "https://resources.paytrail.com/images/payment-method-logos/walley-yrityslasku.svg", + "group": PaytrailPaymentMethodGroup.CREDIT + } + ] + } + ] + }; + + const mockedPaytrailProvider : PaytrailProvider = { + url: 'https://testurl.com', + icon: 'https://testiconurl.com', + svg: 'https://testsvgurl.com', + group: PaytrailPaymentMethodGroup.CREDIT, + name: 'testName', + id: 'testId', + parameters: [], + }; + + const mockedPaytrailPaymentMethodGroupData = { + id: PaytrailPaymentMethodGroup.CREDIT, + name: 'testName', + icon: 'https://testiconurl.com', + svg: 'https://testsvgurl.com', + }; + + const mockedReadonlyJsonObject = { + key: 'testKey', + value: 'testValue', + }; + + const validPaytrailCreatePaymentDTO = { + transactionId: 'testTransactionId', + href: 'https://testhref.com', + terms: 'testTerms', + groups: [mockedPaytrailPaymentMethodGroupData], + reference: 'testReference', + providers: [mockedPaytrailProvider], + customProviders: mockedReadonlyJsonObject, + }; + + const validPaytrailPaymentDTO : PaytrailPaymentDTO = { + transactionId: '681538c4-fc84-11e9-83bc-2ffcef4c3453', + status: PaytrailStatus.OK, + amount: 1689, + currency: PaytrailCurrency.EUR, + stamp: '15725981193483', + reference: '4940046476', + createdAt: '2019-11-01T10:48:39.979Z', + href: 'https://pay.paytrail.com/pay/681538c4-fc84-11e9-83bc-2ffcef4c3453', + provider: undefined, + fillingCode: undefined, + paidAt: undefined, + settlementReference: undefined, + }; + + const validInvoiceDTO: InvoiceDTO = { + invoiceId: 'invoiceId', + clientId: 'clientId', + campaignId: 'campaignId', + groupId: 'groupId', + bankAccountId: 'bankAccountId', + wcOrderId: 'wcOrderId', + updated: '2023-07-09', + created: '2023-07-01', + date: '2023-07-09', + dueDate: '2023-08-09', + remindDate: '2023-08-01', + checkoutDate: '2023-08-05', + referenceNumber: 'referenceNumber', + internalNote: 'internalNote', + extraNotice: 'extraNotice', + webSecret: 'webSecret', + checkoutStamp: 'checkoutStamp', + onHold: false, + isReminded: false, + onCollection: false, + isTerminated: false, + buildDocuments: false, + sendDocuments: false, + dueDays: 30, + rows: [validInvoiceRowDTO], // Assuming you have defined a valid InvoiceRowDTO as per your previous tests. + isPaid: true, + payment: validPaytrailPaymentProviderListDTO, // Assuming you have defined a valid PaytrailPaymentProviderListDTO. + newTransaction: validPaytrailCreatePaymentDTO, // Assuming you have defined a valid PaytrailCreatePaymentDTO. + transaction: validPaytrailPaymentDTO, // Assuming you have defined a valid PaytrailPaymentDTO. + }; + + it('createInvoiceDTO should create a valid InvoiceDTO', () => { + const createdDTO = createInvoiceDTO( + 'invoiceId', + 'clientId', + 'campaignId', + 'groupId', + 'bankAccountId', + 'wcOrderId', + '2023-07-09', + '2023-07-01', + '2023-07-09', + '2023-08-09', + '2023-08-01', + '2023-08-05', + 'referenceNumber', + 'internalNote', + 'extraNotice', + 'webSecret', + 'checkoutStamp', + false, + false, + false, + false, + false, + false, + 30, + [validInvoiceRowDTO], + true, + validPaytrailPaymentProviderListDTO, + validPaytrailCreatePaymentDTO, + validPaytrailPaymentDTO + ); + expect(isInvoiceDTO(createdDTO)).toBe(true); + }); + + it('isInvoiceDTO should return true for valid InvoiceDTO', () => { + expect(isInvoiceDTO(validInvoiceDTO)).toBe(true); + }); + + it('isInvoiceDTO should return false for invalid InvoiceDTO', () => { + const invalidDTO = { ...validInvoiceDTO, invoiceId: 123 }; // invoiceId should be a string + expect(isInvoiceDTO(invalidDTO)).toBe(false); + }); + + it('explainInvoiceDTO should explain valid InvoiceDTO', () => { + expect(explainInvoiceDTO(validInvoiceDTO)).toContain('OK'); + }); + + it('explainInvoiceDTO should explain invalid InvoiceDTO', () => { + const invalidDTO = { ...validInvoiceDTO, clientId: 123 }; // clientId should be a string + expect(explainInvoiceDTO(invalidDTO)).toContain('property "clientId" not string'); + }); + + it('isInvoiceDTOOrUndefined should return true for valid InvoiceDTO or undefined', () => { + expect(isInvoiceDTOOrUndefined(validInvoiceDTO)).toBe(true); + expect(isInvoiceDTOOrUndefined(undefined)).toBe(true); + }); + + it('isInvoiceDTOOrUndefined should return false for invalid InvoiceDTO', () => { + const invalidDTO = { ...validInvoiceDTO, wcOrderId: 123 }; // wcOrderId should be a string + expect(isInvoiceDTOOrUndefined(invalidDTO)).toBe(false); + }); + + it('explainInvoiceDTOOrUndefined should explain valid InvoiceDTO or undefined', () => { + expect(explainInvoiceDTOOrUndefined(validInvoiceDTO)).toContain('OK'); + expect(explainInvoiceDTOOrUndefined(undefined)).toContain('OK'); + }); + + it('explainInvoiceDTOOrUndefined should explain invalid InvoiceDTO', () => { + const invalidDTO = { ...validInvoiceDTO, dueDate: 123 }; // dueDate should be a string + expect(explainInvoiceDTOOrUndefined(invalidDTO)).toContain('not InvoiceDTO | undefined'); + }); + + it('parseInvoiceDTO should return InvoiceDTO when provided with a valid object', () => { + expect(parseInvoiceDTO(validInvoiceDTO)).toEqual(validInvoiceDTO); + }); + + it('parseInvoiceDTO should return undefined when provided with an invalid object', () => { + const invalidDTO = { ...validInvoiceDTO, remindDate: 123 }; // remindDate should be a string + expect(parseInvoiceDTO(invalidDTO)).toBeUndefined(); + }); + +}); diff --git a/store/types/invoice/InvoiceDTO.ts b/store/types/invoice/InvoiceDTO.ts new file mode 100644 index 0000000..d4558d1 --- /dev/null +++ b/store/types/invoice/InvoiceDTO.ts @@ -0,0 +1,319 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainBoolean, explainBooleanOrUndefined, isBoolean } from "../../../types/Boolean"; +import { isInvoiceRowDTO, InvoiceRowDTO, explainInvoiceRowDTO } from "./InvoiceRowDTO"; +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { explainString, isString } from "../../../types/String"; +import { explainNumber, explainNumberOrUndefined, isNumber, isNumberOrUndefined } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainArrayOfOrUndefined, isArrayOfOrUndefined } from "../../../types/Array"; +import { explain, explainNot, explainOk, explainProperty } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; +import { explainPaytrailPaymentProviderListDTOOrUndefined, isPaytrailPaymentProviderListDTOOrUndefined, PaytrailPaymentProviderListDTO } from "../../../paytrail/dtos/PaytrailPaymentProviderListDTO"; +import { explainPaytrailPaymentDTOOrUndefined, isPaytrailPaymentDTOOrUndefined, PaytrailPaymentDTO } from "../../../paytrail/dtos/PaytrailPaymentDTO"; +import { explainPaytrailCreatePaymentDTOOrUndefined, isPaytrailCreatePaymentDTOOrUndefined, PaytrailCreatePaymentDTO } from "../../../paytrail/dtos/PaytrailCreatePaymentDTO"; +import { CurrencyUtils } from "../../../CurrencyUtils"; + +export interface InvoiceDTO { + readonly invoiceId : string; + readonly clientId : string; + readonly campaignId : string; + readonly groupId : string; + readonly bankAccountId : string; + readonly wcOrderId : string; + readonly updated : string; + readonly created : string; + readonly date : string; + readonly dueDate : string; + readonly remindDate : string; + readonly checkoutDate : string; + readonly referenceNumber : string; + readonly internalNote : string; + readonly extraNotice : string; + readonly webSecret : string; + readonly checkoutStamp : string; + readonly onHold : boolean; + readonly isReminded : boolean; + readonly onCollection : boolean; + readonly isTerminated : boolean; + readonly buildDocuments : boolean; + readonly sendDocuments : boolean; + readonly dueDays : number; + readonly rows ?: readonly InvoiceRowDTO[]; + readonly isPaid ?: boolean | undefined; + readonly payment ?: PaytrailPaymentProviderListDTO; + readonly newTransaction ?: PaytrailCreatePaymentDTO; + readonly transaction ?: PaytrailPaymentDTO; + readonly totalSum ?: number; + readonly totalVat ?: number; + readonly totalSumIncludingVat ?: number; + readonly totalPaid ?: number; + readonly totalOpen ?: number; +} + +export function createInvoiceDTO ( + invoiceId : string, + clientId : string, + campaignId : string, + groupId : string, + bankAccountId : string, + wcOrderId : string, + updated : string, + created : string, + date : string, + dueDate : string, + remindDate : string, + checkoutDate : string, + referenceNumber : string, + internalNote : string, + extraNotice : string, + webSecret : string, + checkoutStamp : string, + onHold : boolean, + isReminded : boolean, + onCollection : boolean, + isTerminated : boolean, + buildDocuments : boolean, + sendDocuments : boolean, + dueDays : number, + rows ?: readonly InvoiceRowDTO[], + isPaid ?: boolean, + payment ?: PaytrailPaymentProviderListDTO, + newTransaction ?: PaytrailCreatePaymentDTO, + transaction ?: PaytrailPaymentDTO, + totalSum ?: number, + totalVat ?: number, + totalSumIncludingVat ?: number, + totalPaid ?: number, + totalOpen ?: number, +): InvoiceDTO { + + if ( (totalVat === undefined) && (totalSum !== undefined) && (totalSumIncludingVat !== undefined) ) { + totalVat = totalSumIncludingVat - totalSum; + } + + if ( (totalSumIncludingVat === undefined) && (totalSum !== undefined) && (totalVat !== undefined) ) { + totalSumIncludingVat = Math.round(totalSum*100 + totalVat*100 ) / 100; + } + + if ( (totalSum === undefined) && (totalSumIncludingVat !== undefined) && (totalVat !== undefined) ) { + totalSum = totalSumIncludingVat - totalVat; + } + + if ( (totalOpen === undefined) && (totalSumIncludingVat !== undefined) && (totalPaid !== undefined) ) { + totalOpen = Math.round(totalSumIncludingVat*100 - totalPaid*100) / 100; + } + + if ( (totalPaid === undefined) && (totalSumIncludingVat !== undefined) && (totalOpen !== undefined) ) { + totalPaid = totalSumIncludingVat - totalOpen; + } + + if ( (isPaid === undefined) && (totalOpen !== undefined) ) { + isPaid = CurrencyUtils.getCents(totalOpen) <= 0; + } + + return { + invoiceId, + clientId, + campaignId, + groupId, + bankAccountId, + wcOrderId, + updated, + created, + date, + dueDate, + remindDate, + checkoutDate, + referenceNumber, + internalNote, + extraNotice, + webSecret, + checkoutStamp, + onHold, + isReminded, + onCollection, + isTerminated, + buildDocuments, + sendDocuments, + dueDays, + rows, + ...(isPaid !== undefined ? {isPaid} : {} ), + ...(payment ? {payment}: {}), + ...(newTransaction ? {newTransaction}: {}), + ...(transaction ? {transaction}: {}), + ...(totalSum !== undefined ? { totalSum }: {}), + ...(totalVat !== undefined ? { totalVat }: {}), + ...(totalSumIncludingVat !== undefined ? { totalSumIncludingVat }: {}), + ...(totalPaid !== undefined ? { totalPaid }: {}), + ...(totalOpen !== undefined ? { totalOpen }: {}), + }; +} + +export function isInvoiceDTO (value: any): value is InvoiceDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'invoiceId', + 'clientId', + 'campaignId', + 'groupId', + 'bankAccountId', + 'wcOrderId', + 'updated', + 'created', + 'date', + 'dueDate', + 'remindDate', + 'checkoutDate', + 'referenceNumber', + 'internalNote', + 'extraNotice', + 'webSecret', + 'checkoutStamp', + 'onHold', + 'isReminded', + 'onCollection', + 'isTerminated', + 'isPaid', + 'buildDocuments', + 'sendDocuments', + 'dueDays', + 'rows', + 'payment', + 'newTransaction', + 'transaction', + 'totalSum', + 'totalVat', + 'totalSumIncludingVat', + 'totalPaid', + 'totalOpen', + ]) + && isString(value?.invoiceId) + && isString(value?.clientId) + && isString(value?.campaignId) + && isString(value?.groupId) + && isString(value?.bankAccountId) + && isString(value?.wcOrderId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.date) + && isString(value?.dueDate) + && isString(value?.remindDate) + && isString(value?.checkoutDate) + && isString(value?.referenceNumber) + && isString(value?.internalNote) + && isString(value?.extraNotice) + && isString(value?.webSecret) + && isString(value?.checkoutStamp) + && isBoolean(value?.onHold) + && isBoolean(value?.isReminded) + && isBoolean(value?.onCollection) + && isBoolean(value?.isTerminated) + && isBooleanOrUndefined(value?.isPaid) + && isBoolean(value?.buildDocuments) + && isBoolean(value?.sendDocuments) + && isNumber(value?.dueDays) + && isArrayOfOrUndefined(value?.rows, isInvoiceRowDTO) + && isPaytrailPaymentProviderListDTOOrUndefined(value?.payment) + && isPaytrailCreatePaymentDTOOrUndefined(value?.newTransaction) + && isPaytrailPaymentDTOOrUndefined(value?.transaction) + && isNumberOrUndefined(value?.totalSum) + && isNumberOrUndefined(value?.totalVat) + && isNumberOrUndefined(value?.totalSumIncludingVat) + && isNumberOrUndefined(value?.totalPaid) + && isNumberOrUndefined(value?.totalOpen) + ); +} + +export function explainInvoiceDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'invoiceId', + 'clientId', + 'campaignId', + 'groupId', + 'bankAccountId', + 'wcOrderId', + 'updated', + 'created', + 'date', + 'dueDate', + 'remindDate', + 'checkoutDate', + 'referenceNumber', + 'internalNote', + 'extraNotice', + 'webSecret', + 'checkoutStamp', + 'onHold', + 'isReminded', + 'onCollection', + 'isTerminated', + 'isPaid', + 'buildDocuments', + 'sendDocuments', + 'dueDays', + 'rows', + 'payment', + 'newTransaction', + 'transaction', + 'totalSum', + 'totalVat', + 'totalSumIncludingVat', + 'totalPaid', + 'totalOpen', + ]) + , explainProperty("invoiceId", explainString(value?.invoiceId)) + , explainProperty("clientId", explainString(value?.clientId)) + , explainProperty("campaignId", explainString(value?.campaignId)) + , explainProperty("groupId", explainString(value?.groupId)) + , explainProperty("bankAccountId", explainString(value?.bankAccountId)) + , explainProperty("wcOrderId", explainString(value?.wcOrderId)) + , explainProperty("updated", explainString(value?.updated)) + , explainProperty("created", explainString(value?.created)) + , explainProperty("date", explainString(value?.date)) + , explainProperty("dueDate", explainString(value?.dueDate)) + , explainProperty("remindDate", explainString(value?.remindDate)) + , explainProperty("checkoutDate", explainString(value?.checkoutDate)) + , explainProperty("referenceNumber", explainString(value?.referenceNumber)) + , explainProperty("internalNote", explainString(value?.internalNote)) + , explainProperty("extraNotice", explainString(value?.extraNotice)) + , explainProperty("webSecret", explainString(value?.webSecret)) + , explainProperty("checkoutStamp", explainString(value?.checkoutStamp)) + , explainProperty("onHold", explainBoolean(value?.onHold)) + , explainProperty("isReminded", explainBoolean(value?.isReminded)) + , explainProperty("onCollection", explainBoolean(value?.onCollection)) + , explainProperty("isTerminated", explainBoolean(value?.isTerminated)) + , explainProperty("isPaid", explainBooleanOrUndefined(value?.isPaid)) + , explainProperty("buildDocuments", explainBoolean(value?.buildDocuments)) + , explainProperty("sendDocuments", explainBoolean(value?.sendDocuments)) + , explainProperty("dueDays", explainNumber(value?.dueDays)) + , explainProperty("rows", explainArrayOfOrUndefined("InvoiceRowDTO", explainInvoiceRowDTO, value?.rows, isInvoiceRowDTO)) + , explainProperty("payment", explainPaytrailPaymentProviderListDTOOrUndefined(value?.payment)) + , explainProperty("newTransaction", explainPaytrailCreatePaymentDTOOrUndefined(value?.newTransaction)) + , explainProperty("transaction", explainPaytrailPaymentDTOOrUndefined(value?.transaction)) + , explainProperty("totalSum", explainNumberOrUndefined(value?.totalSum)) + , explainProperty("totalVat", explainNumberOrUndefined(value?.totalVat)) + , explainProperty("totalSumIncludingVat", explainNumberOrUndefined(value?.totalSumIncludingVat)) + , explainProperty("totalPaid", explainNumberOrUndefined(value?.totalPaid)) + , explainProperty("totalOpen", explainNumberOrUndefined(value?.totalOpen)) + ] + ); +} + +export function isInvoiceDTOOrUndefined (value: unknown) : value is InvoiceDTO | undefined { + return isUndefined(value) || isInvoiceDTO(value); +} + +export function explainInvoiceDTOOrUndefined (value: any) : string { + return isInvoiceDTOOrUndefined(value) ? explainOk() : explainNot('InvoiceDTO | undefined'); +} + +export function parseInvoiceDTO (value: any): InvoiceDTO | undefined { + if ( isInvoiceDTO(value) ) return value; + return undefined; +} diff --git a/store/types/invoice/InvoiceListDTO.ts b/store/types/invoice/InvoiceListDTO.ts new file mode 100644 index 0000000..789527c --- /dev/null +++ b/store/types/invoice/InvoiceListDTO.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { InvoiceDTO, isInvoiceDTO } from "./InvoiceDTO"; +import { isArrayOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication + */ +export interface InvoiceListDTO { + readonly payload: readonly InvoiceDTO[]; +} + +export function createInvoiceListDTO ( + items: InvoiceDTO[] +): InvoiceListDTO { + return { + payload: map(items, (item: InvoiceDTO): InvoiceDTO => item) + } as InvoiceListDTO; +} + +export function isInvoiceListDTO (value: any): value is InvoiceListDTO { + return ( + !!value + && isArrayOf(value?.payload, isInvoiceDTO) + ); +} + +export function stringifyInvoiceListDTO (value: InvoiceListDTO): string { + if ( !isInvoiceListDTO(value) ) throw new TypeError(`Not InvoiceListDTO: ${value}`); + return `InvoiceListDTO(${value})`; +} + +export function parseInvoiceListDTO (value: any): InvoiceListDTO | undefined { + if ( isInvoiceListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/invoice/InvoicePaymentDTO.ts b/store/types/invoice/InvoicePaymentDTO.ts new file mode 100644 index 0000000..3cc18dd --- /dev/null +++ b/store/types/invoice/InvoicePaymentDTO.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../types/String"; +import { isNumber } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface InvoicePaymentDTO { + readonly invoicePaymentId : string; + readonly updated : string; + readonly created : string; + readonly invoiceId : string; + readonly date : string; + readonly name : string; + readonly description : string; + readonly sum : number; + readonly documentId : string; + readonly opTransactionId : string; +} + +export function createInvoicePaymentDTO ( + invoicePaymentId: string, + created: string, + updated: string, + invoiceId: string, + date: string, + name: string, + description: string, + sum: number, + documentId : string, + opTransactionId : string, +): InvoicePaymentDTO { + return { + invoicePaymentId, + created, + updated, + invoiceId, + date, + name, + description, + sum, + documentId, + opTransactionId, + }; +} + +export function isInvoicePaymentDTO (value: any): value is InvoicePaymentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'invoicePaymentId', + 'created', + 'updated', + 'invoiceId', + 'date', + 'name', + 'description', + 'sum', + 'documentId', + 'opTransactionId', + ]) + && isString(value?.invoicePaymentId) + && isString(value?.created) + && isString(value?.updated) + && isString(value?.invoiceId) + && isString(value?.date) + && isString(value?.name) + && isString(value?.description) + && isNumber(value?.sum) + && isString(value?.documentId) + && isString(value?.opTransactionId) + ); +} + +export function parseInvoicePaymentDTO (value: any): InvoicePaymentDTO | undefined { + if ( isInvoicePaymentDTO(value) ) return value; + return undefined; +} diff --git a/store/types/invoice/InvoiceRowDTO.test.ts b/store/types/invoice/InvoiceRowDTO.test.ts new file mode 100644 index 0000000..77b1f0d --- /dev/null +++ b/store/types/invoice/InvoiceRowDTO.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createInvoiceRowDTO, explainInvoiceRowDTO, InvoiceRowDTO, isInvoiceRowDTO, parseInvoiceRowDTO } from "./InvoiceRowDTO"; + +describe('InvoiceRowDTO', () => { + + const validInvoiceRow: InvoiceRowDTO = createInvoiceRowDTO( + 'INV1', + 'INV1', + 'PAY1', + 'CAM1', + 'CAM_PAY1', + 'PROD1', + '1234', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-12-31T00:00:00Z', + 'Some Product', + 'Internal note', + 1, + 100, + 0.2, + 0.1 + ); + + const invalidInvoiceRow: any = { + foo: 'bar' + }; + + describe('createInvoiceRowDTO', () => { + it('creates a valid InvoiceRowDTO object', () => { + expect(validInvoiceRow).toHaveProperty('invoiceRowId'); + expect(validInvoiceRow).toHaveProperty('invoiceId'); + expect(validInvoiceRow).toHaveProperty('paymentId'); + expect(validInvoiceRow).toHaveProperty('campaignId'); + expect(validInvoiceRow).toHaveProperty('campaignPaymentId'); + expect(validInvoiceRow).toHaveProperty('productId'); + expect(validInvoiceRow).toHaveProperty('updated'); + expect(validInvoiceRow).toHaveProperty('created'); + expect(validInvoiceRow).toHaveProperty('startDate'); + expect(validInvoiceRow).toHaveProperty('endDate'); + expect(validInvoiceRow).toHaveProperty('description'); + expect(validInvoiceRow).toHaveProperty('internalNote'); + expect(validInvoiceRow).toHaveProperty('amount'); + expect(validInvoiceRow).toHaveProperty('price'); + expect(validInvoiceRow).toHaveProperty('vatPercent'); + expect(validInvoiceRow).toHaveProperty('discountPercent'); + }); + }); + + describe('isInvoiceRowDTO', () => { + it('validates a correct InvoiceRowDTO object', () => { + expect(isInvoiceRowDTO(validInvoiceRow)).toBe(true); + }); + + it('invalidates an incorrect object', () => { + expect(isInvoiceRowDTO(invalidInvoiceRow)).toBe(false); + }); + }); + + describe('explainInvoiceRowDTO', () => { + it('explains a correct InvoiceRowDTO object', () => { + // assuming that the explain* methods return a readable representation + expect(explainInvoiceRowDTO(validInvoiceRow)).toBe('OK'); + }); + + it('explains an incorrect object', () => { + // assuming that the explain* methods return 'Invalid' for invalid entries + expect(explainInvoiceRowDTO(invalidInvoiceRow)).toContain('Value had extra properties: foo'); + }); + }); + + describe('parseInvoiceRowDTO', () => { + it('parses a correct InvoiceRowDTO object', () => { + expect(parseInvoiceRowDTO(validInvoiceRow)).toEqual(validInvoiceRow); + }); + + it('returns undefined for an incorrect object', () => { + expect(parseInvoiceRowDTO(invalidInvoiceRow)).toBeUndefined(); + }); + }); + +}); diff --git a/store/types/invoice/InvoiceRowDTO.ts b/store/types/invoice/InvoiceRowDTO.ts new file mode 100644 index 0000000..72e7b26 --- /dev/null +++ b/store/types/invoice/InvoiceRowDTO.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainString, isString } from "../../../types/String"; +import { explainNumber, isNumber } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explain, explainProperty } from "../../../types/explain"; + +export interface InvoiceRowDTO { + readonly invoiceRowId : string; + readonly invoiceId : string; + readonly paymentId : string; + readonly campaignId : string; + readonly campaignPaymentId : string; + readonly productId : string; + readonly inventoryItemId : string; + readonly updated : string; + readonly created : string; + readonly startDate : string; + readonly endDate : string; + readonly description : string; + readonly internalNote : string; + readonly amount : number; + readonly price : number; + readonly vatPercent : number; + readonly discountPercent : number; +} + +export function createInvoiceRowDTO ( + invoiceRowId : string, + invoiceId : string, + paymentId : string, + campaignId : string, + campaignPaymentId : string, + productId : string, + inventoryItemId : string, + updated : string, + created : string, + startDate : string, + endDate : string, + description : string, + internalNote : string, + amount : number, + price : number, + vatPercent : number, + discountPercent : number +): InvoiceRowDTO { + return { + invoiceRowId, + invoiceId, + paymentId, + campaignId, + campaignPaymentId, + productId, + inventoryItemId, + updated, + created, + startDate, + endDate, + description, + internalNote, + amount, + price, + vatPercent, + discountPercent + }; +} + +export function isInvoiceRowDTO (value: any): value is InvoiceRowDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'invoiceRowId', + 'invoiceId', + 'paymentId', + 'campaignId', + 'campaignPaymentId', + 'productId', + 'inventoryItemId', + 'updated', + 'created', + 'startDate', + 'endDate', + 'description', + 'internalNote', + 'amount', + 'price', + 'vatPercent', + 'discountPercent' + ]) + && isString(value?.invoiceRowId) + && isString(value?.invoiceId) + && isString(value?.paymentId) + && isString(value?.campaignId) + && isString(value?.campaignPaymentId) + && isString(value?.productId) + && isString(value?.inventoryItemId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.startDate) + && isString(value?.endDate) + && isString(value?.description) + && isString(value?.internalNote) + && isNumber(value?.amount) + && isNumber(value?.price) + && isNumber(value?.vatPercent) + && isNumber(value?.discountPercent) + ); +} + +export function explainInvoiceRowDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'invoiceRowId', + 'invoiceId', + 'paymentId', + 'campaignId', + 'campaignPaymentId', + 'productId', + 'inventoryItemId', + 'updated', + 'created', + 'startDate', + 'endDate', + 'description', + 'internalNote', + 'amount', + 'price', + 'vatPercent', + 'discountPercent' + ]) + , explainProperty("invoiceRowId", explainString(value?.invoiceRowId)) + , explainProperty("invoiceId", explainString(value?.invoiceId)) + , explainProperty("paymentId", explainString(value?.paymentId)) + , explainProperty("campaignId", explainString(value?.campaignId)) + , explainProperty("campaignPaymentId", explainString(value?.campaignPaymentId)) + , explainProperty("productId", explainString(value?.productId)) + , explainProperty("inventoryItemId", explainString(value?.inventoryItemId)) + , explainProperty("updated", explainString(value?.updated)) + , explainProperty("created", explainString(value?.created)) + , explainProperty("startDate", explainString(value?.startDate)) + , explainProperty("endDate", explainString(value?.endDate)) + , explainProperty("description", explainString(value?.description)) + , explainProperty("internalNote", explainString(value?.internalNote)) + , explainProperty("amount", explainNumber(value?.amount)) + , explainProperty("price", explainNumber(value?.price)) + , explainProperty("vatPercent", explainNumber(value?.vatPercent)) + , explainProperty("discountPercent", explainNumber(value?.discountPercent)) + ] + ); +} + +export function parseInvoiceRowDTO (value: any): InvoiceRowDTO | undefined { + if ( isInvoiceRowDTO(value) ) return value; + return undefined; +} diff --git a/store/types/invoice/InvoiceRowListDTO.ts b/store/types/invoice/InvoiceRowListDTO.ts new file mode 100644 index 0000000..a078d0f --- /dev/null +++ b/store/types/invoice/InvoiceRowListDTO.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { InvoiceRowDTO, isInvoiceRowDTO } from "./InvoiceRowDTO"; +import { isArrayOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication + */ +export interface InvoiceRowListDTO { + readonly payload: readonly InvoiceRowDTO[]; +} + +export function createInvoiceRowListDTO (items: readonly InvoiceRowDTO[]): InvoiceRowListDTO { + return { + payload: map(items, (item: InvoiceRowDTO): InvoiceRowDTO => item) + } as InvoiceRowListDTO; +} + +export function isInvoiceRowListDTO (value: any): value is InvoiceRowListDTO { + return ( + !!value + && isArrayOf(value?.payload, isInvoiceRowDTO) + ); +} + +export function stringifyInvoiceRowListDTO (value: InvoiceRowListDTO): string { + if ( !isInvoiceRowListDTO(value) ) throw new TypeError(`Not InvoiceRowListDTO: ${value}`); + return `InvoiceRowListDTO(${value})`; +} + +export function parseInvoiceRowListDTO (value: any): InvoiceRowListDTO | undefined { + if ( isInvoiceRowListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/invoice/NewInvoiceDTO.ts b/store/types/invoice/NewInvoiceDTO.ts new file mode 100644 index 0000000..f223026 --- /dev/null +++ b/store/types/invoice/NewInvoiceDTO.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isNumberOrUndefined } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface NewInvoiceDTO { + readonly clientId : string; + readonly campaignId ?: string; + readonly groupId ?: string; + readonly bankAccountId ?: string; + readonly wcOrderId ?: string; + readonly date ?: string; + readonly dueDate ?: string; + readonly remindDate ?: string; + readonly checkoutDate ?: string; + readonly referenceNumber ?: string; + readonly internalNote ?: string; + readonly extraNotice ?: string; + readonly webSecret ?: string; + readonly checkoutStamp ?: string; + readonly onHold ?: boolean; + readonly isReminded ?: boolean; + readonly onCollection ?: boolean; + readonly isTerminated ?: boolean; + readonly buildDocuments ?: boolean; + readonly sendDocuments ?: boolean; + readonly dueDays ?: number; +} + +export function createNewInvoiceDTO ( + clientId : string, + campaignId : string | undefined, + groupId : string | undefined, + bankAccountId : string | undefined, + wcOrderId : string | undefined, + date : string | undefined, + dueDate : string | undefined, + remindDate : string | undefined, + checkoutDate : string | undefined, + referenceNumber : string | undefined, + internalNote : string | undefined, + extraNotice : string | undefined, + webSecret : string | undefined, + checkoutStamp : string | undefined, + onHold : boolean | undefined, + isReminded : boolean | undefined, + onCollection : boolean | undefined, + isTerminated : boolean | undefined, + buildDocuments : boolean | undefined, + sendDocuments : boolean | undefined, + dueDays : number | undefined +): NewInvoiceDTO { + return { + clientId, + campaignId, + groupId, + bankAccountId, + wcOrderId, + date, + dueDate, + remindDate, + checkoutDate, + referenceNumber, + internalNote, + extraNotice, + webSecret, + checkoutStamp, + onHold, + isReminded, + onCollection, + isTerminated, + buildDocuments, + sendDocuments, + dueDays + }; +} + +export function isNewInvoiceDTO (value: any): value is NewInvoiceDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'clientId', + 'campaignId', + 'groupId', + 'bankAccountId', + 'wcOrderId', + 'date', + 'dueDate', + 'remindDate', + 'checkoutDate', + 'referenceNumber', + 'internalNote', + 'extraNotice', + 'webSecret', + 'checkoutStamp', + 'onHold', + 'isReminded', + 'onCollection', + 'isTerminated', + 'buildDocuments', + 'sendDocuments', + 'dueDays' + ]) + && isString(value?.clientId) + && isStringOrUndefined(value?.campaignId) + && isStringOrUndefined(value?.groupId) + && isStringOrUndefined(value?.bankAccountId) + && isStringOrUndefined(value?.wcOrderId) + && isStringOrUndefined(value?.date) + && isStringOrUndefined(value?.dueDate) + && isStringOrUndefined(value?.remindDate) + && isStringOrUndefined(value?.checkoutDate) + && isStringOrUndefined(value?.referenceNumber) + && isStringOrUndefined(value?.internalNote) + && isStringOrUndefined(value?.extraNotice) + && isStringOrUndefined(value?.webSecret) + && isStringOrUndefined(value?.checkoutStamp) + && isBooleanOrUndefined(value?.onHold) + && isBooleanOrUndefined(value?.isReminded) + && isBooleanOrUndefined(value?.onCollection) + && isBooleanOrUndefined(value?.isTerminated) + && isBooleanOrUndefined(value?.buildDocuments) + && isBooleanOrUndefined(value?.sendDocuments) + && isNumberOrUndefined(value?.dueDays) + ); +} + +export function stringifyNewInvoiceDTO (value: NewInvoiceDTO): string { + return `NewInvoiceDTO(${value})`; +} + +export function parseNewInvoiceDTO (value: any): NewInvoiceDTO | undefined { + if ( isNewInvoiceDTO(value) ) return value; + return undefined; +} diff --git a/store/types/invoice/NewInvoicePaymentDTO.ts b/store/types/invoice/NewInvoicePaymentDTO.ts new file mode 100644 index 0000000..b87b511 --- /dev/null +++ b/store/types/invoice/NewInvoicePaymentDTO.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isNumber } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface NewInvoicePaymentDTO { + readonly invoiceId : string; + readonly date : string; + readonly name : string; + readonly description : string; + readonly sum : number; + readonly documentId ?: string; + readonly opTransactionId ?: string; +} + +export function createNewInvoicePaymentDTO ( + invoiceId: string, + date: string, + name: string, + description: string, + sum: number, + documentId : string | undefined, + opTransactionId : string | undefined, +): NewInvoicePaymentDTO { + return { + invoiceId, + date, + name, + description, + sum, + documentId, + opTransactionId, + }; +} + +export function isNewInvoicePaymentDTO (value: any): value is NewInvoicePaymentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'invoiceId', + 'date', + 'name', + 'description', + 'sum', + 'documentId', + 'opTransactionId', + ]) + && isString(value?.invoiceId) + && isString(value?.date) + && isString(value?.name) + && isString(value?.description) + && isNumber(value?.sum) + && isStringOrUndefined(value?.documentId) + && isStringOrUndefined(value?.opTransactionId) + ); +} + +export function parseNewInvoicePaymentDTO (value: any): NewInvoicePaymentDTO | undefined { + if ( isNewInvoicePaymentDTO(value) ) return value; + return undefined; +} diff --git a/store/types/invoice/NewInvoiceRowDTO.ts b/store/types/invoice/NewInvoiceRowDTO.ts new file mode 100644 index 0000000..b04d967 --- /dev/null +++ b/store/types/invoice/NewInvoiceRowDTO.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isNumberOrUndefined } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface NewInvoiceRowDTO { + readonly invoiceId : string; + readonly paymentId ?: string; + readonly campaignId ?: string; + readonly campaignPaymentId ?: string; + readonly productId ?: string; + readonly inventoryItemId ?: string; + readonly startDate ?: string; + readonly endDate ?: string; + readonly description ?: string; + readonly internalNote ?: string; + readonly amount ?: number; + readonly price ?: number; + readonly vatPercent ?: number; + readonly discountPercent ?: number; +} + +export function createNewInvoiceRowDTO ( + invoiceId: string, + paymentId: string | undefined, + campaignId: string | undefined, + campaignPaymentId: string | undefined, + productId: string | undefined, + inventoryItemId: string | undefined, + startDate: string | undefined, + endDate: string | undefined, + description: string | undefined, + internalNote: string | undefined, + amount: number | undefined, + price: number | undefined, + vatPercent: number | undefined, + discountPercent: number | undefined +): NewInvoiceRowDTO { + return { + invoiceId, + paymentId, + campaignId, + campaignPaymentId, + productId, + inventoryItemId, + startDate, + endDate, + description, + internalNote, + amount, + price, + vatPercent, + discountPercent + }; +} + +export function isNewInvoiceRowDTO (value: any): value is NewInvoiceRowDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'invoiceId', + 'paymentId', + 'campaignId', + 'campaignPaymentId', + 'productId', + 'inventoryItemId', + 'startDate', + 'endDate', + 'description', + 'internalNote', + 'amount', + 'price', + 'vatPercent', + 'discountPercent' + ]) + && isString(value?.invoiceId) + && isStringOrUndefined(value?.paymentId) + && isStringOrUndefined(value?.campaignId) + && isStringOrUndefined(value?.campaignPaymentId) + && isStringOrUndefined(value?.productId) + && isStringOrUndefined(value?.inventoryItemId) + && isStringOrUndefined(value?.startDate) + && isStringOrUndefined(value?.endDate) + && isStringOrUndefined(value?.description) + && isStringOrUndefined(value?.internalNote) + && isNumberOrUndefined(value?.amount) + && isNumberOrUndefined(value?.price) + && isNumberOrUndefined(value?.vatPercent) + && isNumberOrUndefined(value?.discountPercent) + ); +} + +export function stringifyNewInvoiceRowDTO (value: NewInvoiceRowDTO): string { + return `NewInvoiceRowDTO(${value})`; +} + +export function parseNewInvoiceRowDTO (value: any): NewInvoiceRowDTO | undefined { + if ( isNewInvoiceRowDTO(value) ) return value; + return undefined; +} diff --git a/store/types/order/NewOrderDTO.ts b/store/types/order/NewOrderDTO.ts new file mode 100644 index 0000000..03b307f --- /dev/null +++ b/store/types/order/NewOrderDTO.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isShoppingCart, ShoppingCart } from "../cart/ShoppingCart"; +import { isReadonlyJsonAny, parseJson, ReadonlyJsonAny } from "../../../Json"; +import { isOrderStatus, OrderStatus } from "./OrderStatus"; +import { isUndefined } from "../../../types/undefined"; +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface NewOrderDTO { + readonly clientId : string; + readonly cart : ShoppingCart; + readonly wcOrderId ?: string; + readonly date ?: string; + readonly status ?: OrderStatus; + readonly description ?: string; + readonly internalNote ?: string; + readonly viewUrl ?: string; + readonly adminUrl ?: string; + readonly onHold ?: boolean; + readonly isCompleted ?: boolean; + readonly isPaid ?: boolean; + readonly isTerminated ?: boolean; + readonly meta ?: ReadonlyJsonAny; +} + +export function createNewOrderDTO ( + clientId : string, + cart : ShoppingCart, + wcOrderId ?: string, + date ?: string, + status ?: OrderStatus, + description ?: string, + internalNote ?: string, + viewUrl ?: string, + adminUrl ?: string, + onHold ?: boolean, + isCompleted ?: boolean, + isPaid ?: boolean, + isTerminated ?: boolean, + meta ?: ReadonlyJsonAny | string, +): NewOrderDTO { + return { + clientId, + wcOrderId, + date, + status, + description, + internalNote, + viewUrl, + adminUrl, + onHold, + isCompleted, + isPaid, + isTerminated, + meta : (isString(meta) ? parseJson(meta) : meta) as ReadonlyJsonAny, + cart + }; +} + +export function isNewOrderDTO (value: any): value is NewOrderDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'clientId', + 'wcOrderId', + 'date', + 'status', + 'description', + 'internalNote', + 'viewUrl', + 'adminUrl', + 'onHold', + 'isCompleted', + 'isPaid', + 'isTerminated', + 'meta', + 'cart' + ]) + && isString(value?.clientId) + && isShoppingCart(value?.cart) + && isStringOrUndefined(value?.wcOrderId) + && isStringOrUndefined(value?.date) + && ( isUndefined(value?.status) || isOrderStatus(value?.status)) + && isStringOrUndefined(value?.description) + && isStringOrUndefined(value?.internalNote) + && isStringOrUndefined(value?.viewUrl) + && isStringOrUndefined(value?.adminUrl) + && isBooleanOrUndefined(value?.onHold) + && isBooleanOrUndefined(value?.isCompleted) + && isBooleanOrUndefined(value?.isPaid) + && isBooleanOrUndefined(value?.isTerminated) + && ( isUndefined(value?.meta) || isReadonlyJsonAny(value?.meta) ) + ); +} + +export function stringifyNewOrderDTO (value: NewOrderDTO): string { + return `NewOrderDTO(${value})`; +} + +export function parseNewOrderDTO (value: any): NewOrderDTO | undefined { + if ( isNewOrderDTO(value) ) return value; + return undefined; +} diff --git a/store/types/order/OrderDTO.ts b/store/types/order/OrderDTO.ts new file mode 100644 index 0000000..77de296 --- /dev/null +++ b/store/types/order/OrderDTO.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainBoolean, isBoolean } from "../../../types/Boolean"; +import { explainShoppingCart, isShoppingCart, ShoppingCart } from "../cart/ShoppingCart"; +import { explainReadonlyJsonAny, isReadonlyJsonAny, parseJson, ReadonlyJsonAny } from "../../../Json"; +import { isInvoiceDTOOrUndefined, InvoiceDTO, explainInvoiceDTOOrUndefined } from "../invoice/InvoiceDTO"; +import { isInventoryItemDTO, InventoryItemDTO, explainInventoryItemDTO } from "../inventory/InventoryItemDTO"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainArrayOf, isArrayOfOrUndefined } from "../../../types/Array"; +import { explain, explainProperty } from "../../../types/explain"; + +export interface OrderDTO { + readonly orderId : string; + readonly clientId : string; + readonly wcOrderId ?: string | undefined; + readonly updated : string; + readonly created : string; + readonly date : string; + readonly status : string; + readonly description : string; + readonly internalNote : string; + readonly viewUrl : string; + readonly adminUrl : string; + readonly onHold : boolean; + readonly isCompleted : boolean; + readonly isPaid : boolean; + readonly isTerminated : boolean; + readonly meta : ReadonlyJsonAny; + readonly cart : ShoppingCart; + readonly invoice ?: InvoiceDTO; + readonly inventoryItems ?: readonly InventoryItemDTO[]; +} + +export function createOrderDTO ( + orderId : string, + clientId : string, + cart : ShoppingCart, + wcOrderId : string, + updated : string, + created : string, + date : string, + status : string, + description : string, + internalNote : string, + viewUrl : string, + adminUrl : string, + onHold : boolean, + isCompleted : boolean, + isPaid : boolean, + isTerminated : boolean, + meta : ReadonlyJsonAny | string, + invoice ?: InvoiceDTO, + inventoryItems ?: readonly InventoryItemDTO[] +): OrderDTO { + return { + orderId, + clientId, + wcOrderId, + updated, + created, + date, + status, + description, + internalNote, + viewUrl, + adminUrl, + onHold, + isCompleted, + isPaid, + isTerminated, + meta : (isString(meta) ? parseJson(meta) : meta) as ReadonlyJsonAny, + cart, + invoice, + inventoryItems + }; +} + +export function isOrderDTO (value: unknown): value is OrderDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'orderId', + 'clientId', + 'wcOrderId', + 'updated', + 'created', + 'date', + 'status', + 'description', + 'internalNote', + 'viewUrl', + 'adminUrl', + 'onHold', + 'isCompleted', + 'isPaid', + 'isTerminated', + 'meta', + 'invoice', + 'inventoryItems', + 'cart' + ]) + && isString(value?.orderId) + && isString(value?.clientId) + && isStringOrUndefined(value?.wcOrderId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.date) + && isString(value?.status) + && isString(value?.description) + && isString(value?.internalNote) + && isString(value?.viewUrl) + && isString(value?.adminUrl) + && isBoolean(value?.onHold) + && isBoolean(value?.isCompleted) + && isBoolean(value?.isPaid) + && isBoolean(value?.isTerminated) + && isReadonlyJsonAny(value?.meta) + && isShoppingCart(value?.cart) + && isInvoiceDTOOrUndefined(value?.invoice) + && isArrayOfOrUndefined(value?.inventoryItems, isInventoryItemDTO) + ); +} + +export function explainOrderDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'orderId', + 'clientId', + 'wcOrderId', + 'updated', + 'created', + 'date', + 'status', + 'description', + 'internalNote', + 'viewUrl', + 'adminUrl', + 'onHold', + 'isCompleted', + 'isPaid', + 'isTerminated', + 'meta', + 'invoice', + 'inventoryItems', + 'cart' + ]) + , explainProperty("orderId", explainString(value?.orderId)) + , explainProperty("clientId", explainString(value?.clientId)) + , explainProperty("wcOrderId", explainStringOrUndefined(value?.wcOrderId)) + , explainProperty("updated", explainString(value?.updated)) + , explainProperty("created", explainString(value?.created)) + , explainProperty("date", explainString(value?.date)) + , explainProperty("status", explainString(value?.status)) + , explainProperty("description", explainString(value?.description)) + , explainProperty("internalNote", explainString(value?.internalNote)) + , explainProperty("viewUrl", explainString(value?.viewUrl)) + , explainProperty("adminUrl", explainString(value?.adminUrl)) + , explainProperty("onHold", explainBoolean(value?.onHold)) + , explainProperty("isCompleted", explainBoolean(value?.isCompleted)) + , explainProperty("isPaid", explainBoolean(value?.isPaid)) + , explainProperty("isTerminated", explainBoolean(value?.isTerminated)) + , explainProperty("meta", explainReadonlyJsonAny(value?.meta)) + , explainProperty("invoice", explainInvoiceDTOOrUndefined(value?.invoice)) + , explainProperty("inventoryItems", explainArrayOf("InventoryItemDTO", explainInventoryItemDTO, value?.inventoryItems, isInventoryItemDTO)) + , explainProperty("cart", explainShoppingCart(value?.cart)) + ] + ); +} + +export function stringifyOrderDTO (value: OrderDTO): string { + return `OrderDTO(${value})`; +} + +export function parseOrderDTO (value: any): OrderDTO | undefined { + if ( isOrderDTO(value) ) return value; + return undefined; +} diff --git a/store/types/order/OrderListDTO.ts b/store/types/order/OrderListDTO.ts new file mode 100644 index 0000000..fe241fa --- /dev/null +++ b/store/types/order/OrderListDTO.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { OrderDTO, isOrderDTO } from "./OrderDTO"; +import { isArrayOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication + */ +export interface OrderListDTO { + readonly payload: readonly OrderDTO[]; +} + +export function createOrderListDTO (items: OrderDTO[]): OrderListDTO { + return { + payload: map(items, (item: OrderDTO): OrderDTO => item) + } as OrderListDTO; +} + +export function isOrderListDTO (value: any): value is OrderListDTO { + return ( + !!value + && isArrayOf(value?.payload, isOrderDTO) + ); +} + +export function stringifyOrderListDTO (value: OrderListDTO): string { + if ( !isOrderListDTO(value) ) throw new TypeError(`Not OrderListDTO: ${value}`); + return `OrderListDTO(${value})`; +} + +export function parseOrderListDTO (value: any): OrderListDTO | undefined { + if ( isOrderListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/order/OrderStatus.ts b/store/types/order/OrderStatus.ts new file mode 100644 index 0000000..e409aca --- /dev/null +++ b/store/types/order/OrderStatus.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export enum OrderStatus { + CANCELLED = "cancelled", + COMPLETED = "completed", + PROCESSING = "processing", + REFUNDED = "refunded" +} + +export function isOrderStatus (value: any): value is OrderStatus { + switch (value) { + case OrderStatus.CANCELLED: + case OrderStatus.COMPLETED: + case OrderStatus.PROCESSING: + case OrderStatus.REFUNDED: + return true; + default: + return false; + } +} + +export function stringifyOrderStatus (value: OrderStatus): string { + switch (value) { + case OrderStatus.CANCELLED : return 'cancelled'; + case OrderStatus.COMPLETED : return 'completed'; + case OrderStatus.PROCESSING : return 'processing'; + case OrderStatus.REFUNDED : return 'refunded'; + } + throw new TypeError(`Unsupported OrderStatus value: ${value}`); +} + +export function parseOrderStatus (value: any): OrderStatus | undefined { + switch (`${value}`.toUpperCase()) { + case 'CANCELLED' : return OrderStatus.CANCELLED; + case 'COMPLETED' : return OrderStatus.COMPLETED; + case 'PROCESSING' : return OrderStatus.PROCESSING; + case 'REFUNDED' : return OrderStatus.REFUNDED; + default : return undefined; + } +} diff --git a/store/types/payment/NewPaymentDTO.ts b/store/types/payment/NewPaymentDTO.ts new file mode 100644 index 0000000..38bcbcc --- /dev/null +++ b/store/types/payment/NewPaymentDTO.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface NewPaymentDTO { + readonly clientId : string; + readonly price ?: number; + readonly vatPercent ?: number; + readonly startDate ?: string; + readonly description ?: string; + readonly endDate ?: string; + readonly billingPeriod ?: number; + readonly amount ?: number; + readonly bankAccountId ?: string; + readonly inventoryItemId ?: string; + readonly refClientId ?: string; + readonly billingClientId ?: string; + readonly invoiceId ?: string; + readonly campaignId ?: string; + readonly productId ?: string; + readonly groupId ?: string; + readonly dueDate ?: string; + readonly extraNotice ?: string; + readonly discountPercent ?: number; + readonly internalNote ?: string; + readonly isRecurring ?: boolean; + readonly onHold ?: boolean; + readonly isTerminated ?: boolean; +} + +export function createNewPaymentDTO ( + clientId : string, + refClientId : string | undefined, + billingClientId : string | undefined, + invoiceId : string | undefined, + campaignId : string | undefined, + productId : string | undefined, + groupId : string | undefined, + bankAccountId : string | undefined, + inventoryItemId : string | undefined, + startDate : string | undefined, + endDate : string | undefined, + dueDate : string | undefined, + description : string | undefined, + extraNotice : string | undefined, + amount : number | undefined, + price : number | undefined, + vatPercent : number | undefined, + discountPercent : number | undefined, + billingPeriod : number | undefined, + internalNote : string | undefined, + isRecurring : boolean | undefined, + onHold : boolean | undefined, + isTerminated : boolean | undefined, +): NewPaymentDTO { + return { + clientId, + refClientId, + billingClientId, + invoiceId, + campaignId, + productId, + groupId, + bankAccountId, + inventoryItemId, + startDate, + endDate, + dueDate, + description, + extraNotice, + amount, + price, + vatPercent, + discountPercent, + billingPeriod, + internalNote, + isRecurring, + onHold, + isTerminated + }; +} + +export function isNewPaymentDTO (value: any): value is NewPaymentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'clientId', + 'refClientId', + 'billingClientId', + 'invoiceId', + 'campaignId', + 'productId', + 'groupId', + 'bankAccountId', + 'inventoryItemId', + 'startDate', + 'endDate', + 'dueDate', + 'description', + 'extraNotice', + 'amount', + 'price', + 'vatPercent', + 'discountPercent', + 'billingPeriod', + 'internalNote', + 'isRecurring', + 'onHold', + 'isTerminated' + ]) + && isString(value?.clientId) + && isStringOrUndefined(value?.refClientId) + && isStringOrUndefined(value?.billingClientId) + && isStringOrUndefined(value?.invoiceId) + && isStringOrUndefined(value?.campaignId) + && isStringOrUndefined(value?.productId) + && isStringOrUndefined(value?.groupId) + && isStringOrUndefined(value?.bankAccountId) + && isStringOrUndefined(value?.inventoryItemId) + && isStringOrUndefined(value?.startDate) + && isStringOrUndefined(value?.endDate) + && isStringOrUndefined(value?.dueDate) + && isStringOrUndefined(value?.description) + && isStringOrUndefined(value?.extraNotice) + && isStringOrUndefined(value?.amount) + && isStringOrUndefined(value?.price) + && isStringOrUndefined(value?.vatPercent) + && isStringOrUndefined(value?.discountPercent) + && isStringOrUndefined(value?.billingPeriod) + && isStringOrUndefined(value?.internalNote) + && isStringOrUndefined(value?.isRecurring) + && isStringOrUndefined(value?.onHold) + && isStringOrUndefined(value?.isTerminated) + ); +} + +export function stringifyNewPaymentDTO (value: NewPaymentDTO): string { + return `NewPaymentDTO(${value})`; +} + +export function parseNewPaymentDTO (value: any): NewPaymentDTO | undefined { + if ( isNewPaymentDTO(value) ) return value; + return undefined; +} diff --git a/store/types/payment/PaymentDTO.ts b/store/types/payment/PaymentDTO.ts new file mode 100644 index 0000000..4c08195 --- /dev/null +++ b/store/types/payment/PaymentDTO.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isBoolean } from "../../../types/Boolean"; +import { isString } from "../../../types/String"; +import { isNumber } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface PaymentDTO { + readonly paymentId : string; + readonly clientId : string; + readonly refClientId : string; + readonly billingClientId : string; + readonly invoiceId : string; + readonly campaignId : string; + readonly productId : string; + readonly groupId : string; + readonly bankAccountId : string; + readonly inventoryItemId : string; + readonly updated : string; + readonly created : string; + readonly startDate : string; + readonly endDate : string; + readonly dueDate : string; + readonly description : string; + readonly extraNotice : string; + readonly amount : number; + readonly price : number; + readonly vatPercent : number; + readonly discountPercent : number; + readonly billingPeriod : number; + readonly internalNote : string; + readonly isRecurring : boolean; + readonly onHold : boolean; + readonly isTerminated : boolean; +} + +export function createPaymentDTO ( + paymentId : string, + clientId : string, + refClientId : string, + billingClientId : string, + invoiceId : string, + campaignId : string, + productId : string, + groupId : string, + bankAccountId : string, + inventoryItemId : string, + updated : string, + created : string, + startDate : string, + endDate : string, + dueDate : string, + description : string, + extraNotice : string, + amount : number, + price : number, + vatPercent : number, + discountPercent : number, + billingPeriod : number, + internalNote : string, + isRecurring : boolean, + onHold : boolean, + isTerminated : boolean, +): PaymentDTO { + return { + paymentId, + clientId, + refClientId, + billingClientId, + invoiceId, + campaignId, + productId, + groupId, + bankAccountId, + inventoryItemId, + updated, + created, + startDate, + endDate, + dueDate, + description, + extraNotice, + amount, + price, + vatPercent, + discountPercent, + billingPeriod, + internalNote, + isRecurring, + onHold, + isTerminated + }; +} + +export function isPaymentDTO (value: any): value is PaymentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'paymentId', + 'clientId', + 'refClientId', + 'billingClientId', + 'invoiceId', + 'campaignId', + 'productId', + 'groupId', + 'bankAccountId', + 'inventoryItemId', + 'updated', + 'created', + 'startDate', + 'endDate', + 'dueDate', + 'description', + 'extraNotice', + 'amount', + 'price', + 'vatPercent', + 'discountPercent', + 'billingPeriod', + 'internalNote', + 'isRecurring', + 'onHold', + 'isTerminated' + ]) + && isString(value?.paymentId) + && isString(value?.clientId) + && isString(value?.refClientId) + && isString(value?.billingClientId) + && isString(value?.invoiceId) + && isString(value?.campaignId) + && isString(value?.productId) + && isString(value?.groupId) + && isString(value?.bankAccountId) + && isString(value?.inventoryItemId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.startDate) + && isString(value?.endDate) + && isString(value?.dueDate) + && isString(value?.description) + && isString(value?.extraNotice) + && isNumber(value?.amount) + && isNumber(value?.price) + && isNumber(value?.vatPercent) + && isNumber(value?.discountPercent) + && isNumber(value?.billingPeriod) + && isString(value?.internalNote) + && isBoolean(value?.isRecurring) + && isBoolean(value?.onHold) + && isBoolean(value?.isTerminated) + ); +} + +export function stringifyPaymentDTO (value: PaymentDTO): string { + return `PaymentDTO(${value})`; +} + +export function parsePaymentDTO (value: any): PaymentDTO | undefined { + if ( isPaymentDTO(value) ) return value; + return undefined; +} diff --git a/store/types/payment/PaymentListDTO.ts b/store/types/payment/PaymentListDTO.ts new file mode 100644 index 0000000..ad82251 --- /dev/null +++ b/store/types/payment/PaymentListDTO.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { PaymentDTO, isPaymentDTO } from "./PaymentDTO"; +import { isArrayOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication + */ +export interface PaymentListDTO { + readonly payload: readonly PaymentDTO[]; +} + +export function createPaymentListDTO (items: PaymentDTO[]): PaymentListDTO { + return { + payload: map(items, (item: PaymentDTO): PaymentDTO => item) + } as PaymentListDTO; +} + +export function isPaymentListDTO (value: any): value is PaymentListDTO { + return ( + !!value + && isArrayOf(value?.payload, isPaymentDTO) + ); +} + +export function stringifyPaymentListDTO (value: PaymentListDTO): string { + if ( !isPaymentListDTO(value) ) throw new TypeError(`Not PaymentListDTO: ${value}`); + return `PaymentListDTO(${value})`; +} + +export function parsePaymentListDTO (value: any): PaymentListDTO | undefined { + if ( isPaymentListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/product/CompositeProductOption.ts b/store/types/product/CompositeProductOption.ts new file mode 100644 index 0000000..9faab7e --- /dev/null +++ b/store/types/product/CompositeProductOption.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainProductIdListWithAmount, isProductIdListWithAmount, ProductIdListWithAmount } from "./ProductIdList"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainStringOrUndefined, isStringOrUndefined } from "../../../types/String"; +import { explainNumber, explainNumberOrUndefined, isNumber, isNumberOrUndefined } from "../../../types/Number"; +import { explainNumberOrStringOrBooleanOrUndefined, isNumberOrStringOrBooleanOrUndefined } from "../../../types/NumberOrStringOrBooleanOrUndefined"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface CompositeProductOption { + + readonly value : number | string | boolean; + readonly minAmount : number; + readonly maxAmount ?: number; + + /** + * If defined, make this option part of a group. + * + * E.g. only one in a group can be selected at one time. + */ + readonly groupBy ?: string; + + /** + * These products will be selected if user selects this option + */ + readonly products : ProductIdListWithAmount; + +} + +export function createCompositeProductOption ( + value : number | string | boolean, + products : ProductIdListWithAmount, + groupBy ?: string, + minAmount ?: number, + maxAmount ?: number +) : CompositeProductOption { + return { + value, + groupBy, + products: products ?? [], + minAmount: minAmount ?? 0, + maxAmount + }; +} + +export function isCompositeProductOption (value: any) : value is CompositeProductOption { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'value', + 'products', + 'groupBy', + 'minAmount', + 'maxAmount' + ]) + && isNumberOrStringOrBooleanOrUndefined(value?.value) + && isStringOrUndefined(value?.groupBy) + && isProductIdListWithAmount(value?.products) + && isNumber(value?.minAmount) + && isNumberOrUndefined(value?.maxAmount) + ); +} + +export function explainCompositeProductOption (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'value', + 'products', + 'groupBy', + 'minAmount', + 'maxAmount' + ]) + , explainProperty("value", explainNumberOrStringOrBooleanOrUndefined(value?.value)) + , explainProperty("products", explainProductIdListWithAmount(value?.products)) + , explainProperty("groupBy", explainStringOrUndefined(value?.groupBy)) + , explainProperty("minAmount", explainNumber(value?.minAmount)) + , explainProperty("maxAmount", explainNumberOrUndefined(value?.maxAmount)) + ] + ); +} + +export function stringifyCompositeProductOption (value : CompositeProductOption) : string { + return `CompositeProductOption(${value})`; +} + +export function parseCompositeProductOption (value: any) : CompositeProductOption | undefined { + if (isCompositeProductOption(value)) return value; + return undefined; +} diff --git a/store/types/product/CompositeProductSelection.ts b/store/types/product/CompositeProductSelection.ts new file mode 100644 index 0000000..badaa4d --- /dev/null +++ b/store/types/product/CompositeProductSelection.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { CompositeProductOption, explainCompositeProductOption, isCompositeProductOption } from "./CompositeProductOption"; +import { explainProductFeatureId, isProductFeatureId, ProductFeatureId } from "./features/ProductFeatureId"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../types/String"; +import { explainNumberOrUndefined, isNumberOrUndefined } from "../../../types/Number"; +import { explainNumberOrStringOrBooleanOrUndefined, isNumberOrStringOrBooleanOrUndefined } from "../../../types/NumberOrStringOrBooleanOrUndefined"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; +import { explainArrayOfOrUndefined, isArrayOfOrUndefined } from "../../../types/Array"; + +export interface CompositeProductSelection { + readonly featureId : ProductFeatureId; + readonly title : string; + readonly options : readonly CompositeProductOption[]; + readonly description ?: string; + readonly defaultValue ?: number | string | boolean; + readonly minValue ?: number; + readonly maxValue ?: number; +} + +export function createCompositeProductSelection ( + featureId : ProductFeatureId, + title : string, + description ?: string, + options ?: readonly CompositeProductOption[], + defaultValue ?: number | string | boolean, + minValue ?: number, + maxValue ?: number +) : CompositeProductSelection { + return { + featureId, + title, + description, + options: options ?? [], + defaultValue, + minValue, + maxValue + }; +} + +export function isCompositeProductSelection (value: any) : value is CompositeProductSelection { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'featureId', + 'title', + 'description', + 'options', + 'defaultValue', + 'minValue', + 'maxValue' + ]) + && isProductFeatureId(value?.featureId) + && isString(value?.title) + && isStringOrUndefined(value?.description) + && isArrayOfOrUndefined(value?.options, isCompositeProductOption) + && isNumberOrStringOrBooleanOrUndefined(value?.defaultValue) + && isNumberOrUndefined(value?.minValue) + && isNumberOrUndefined(value?.maxValue) + ); +} + +export function explainCompositeProductSelection (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'featureId', + 'title', + 'description', + 'options', + 'defaultValue', + 'minValue', + 'maxValue' + ]) + , explainProperty("featureId", explainProductFeatureId(value?.featureId)) + , explainProperty("title", explainString(value?.title)) + , explainProperty("description", explainStringOrUndefined(value?.description)) + , explainProperty( + "options", + explainArrayOfOrUndefined( + 'CompositeProductOption', + explainCompositeProductOption, + value?.options, + isCompositeProductOption + ) + ) + , explainProperty("defaultValue", explainNumberOrStringOrBooleanOrUndefined(value?.defaultValue)) + , explainProperty("minValue", explainNumberOrUndefined(value?.minValue)) + , explainProperty("maxValue", explainNumberOrUndefined(value?.maxValue)) + ] + ); +} + +export function stringifyCompositeProductSelection (value : CompositeProductSelection) : string { + return `ProductCompositeOption(${value})`; +} + +export function parseCompositeProductSelection (value: any) : CompositeProductSelection | undefined { + if (isCompositeProductSelection(value)) return value; + return undefined; +} diff --git a/store/types/product/NewProductDTO.ts b/store/types/product/NewProductDTO.ts new file mode 100644 index 0000000..a84af05 --- /dev/null +++ b/store/types/product/NewProductDTO.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { isBoolean } from "../../../types/Boolean"; +import { isString } from "../../../types/String"; +import { isNumber } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; + +export interface NewProductDTO { + readonly productGroupId : string; + readonly priceTypeId : string; + readonly number : number; + readonly name : string; + readonly description : string; + readonly expensePrice : number; + readonly price : number; + readonly vatPercent : number; + readonly onHold : boolean; + readonly isPublic : boolean; + readonly stockEnabled : boolean; + readonly stockAmount : number; +} + +export function createNewProductDTO ( + productGroupId : string, + priceTypeId : string, + number : number, + name : string, + description : string, + expensePrice : number, + price : number, + vatPercent : number, + onHold : boolean, + isPublic : boolean, + stockEnabled : boolean, + stockAmount : number +): NewProductDTO { + return { + productGroupId, + priceTypeId, + number, + name, + description, + expensePrice, + price, + vatPercent, + onHold, + isPublic, + stockEnabled, + stockAmount + }; +} + +export function isNewProductDTO (value: any): value is NewProductDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'productGroupId', + 'priceTypeId', + 'number', + 'name', + 'description', + 'expensePrice', + 'price', + 'vatPercent', + 'onHold', + 'isPublic', + 'stockEnabled', + 'stockAmount' + ]) + && isString(value?.productGroupId) + && isString(value?.priceTypeId) + && isNumber(value?.number) + && isString(value?.name) + && isString(value?.description) + && isNumber(value?.expensePrice) + && isNumber(value?.price) + && isNumber(value?.vatPercent) + && isBoolean(value?.onHold) + && isBoolean(value?.isPublic) + && isBoolean(value?.stockEnabled) + && isNumber(value?.stockAmount) + ); +} + +export function stringifyNewProductDTO (value: NewProductDTO): string { + return `NewProductDTO(${value})`; +} + +export function parseNewProductDTO (value: any): NewProductDTO | undefined { + if ( isNewProductDTO(value) ) return value; + return undefined; +} diff --git a/store/types/product/Product.ts b/store/types/product/Product.ts new file mode 100644 index 0000000..402bb59 --- /dev/null +++ b/store/types/product/Product.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainProductType, isProductType, ProductType } from "./ProductType"; +import { explainProductFeature, isProductFeature, ProductFeature } from "./features/ProductFeature"; +import { ProductPrice, isProductPrice, explainProductPrice } from "./ProductPrice"; +import { CompositeProductSelection } from "./CompositeProductSelection"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, isString } from "../../../types/String"; +import { explainNumberOrUndefined, isNumberOrUndefined } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; +import { explainArrayOf, isArrayOf } from "../../../types/Array"; + +export interface Product { + readonly id : string; + readonly type : ProductType; + readonly title : string; + readonly summary : string; + readonly features : readonly ProductFeature[]; + readonly prices : readonly ProductPrice[]; + readonly stockAmount ?: number; + + /** + * If defined, this product is a special product combined from other products + * based on customer's choices + */ + readonly composite ?: readonly CompositeProductSelection[]; + +} + +export function createProduct ( + id : string, + type : ProductType, + title : string, + summary : string, + features : readonly ProductFeature[], + prices : readonly ProductPrice[], + stockAmount : number = 0 +) : Product { + return { + id, + type, + title, + summary, + features, + prices, + stockAmount + }; +} + +export function createCompositeProduct ( + id : string, + type : ProductType, + title : string, + summary : string, + composite : readonly CompositeProductSelection[], + stockAmount : number = 0 +) : Product { + return { + id, + type, + title, + summary, + features: [], + prices: [], + composite, + stockAmount + }; +} + + +export function isProduct (value: any): value is Product { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'type', + 'title', + 'summary', + 'features', + 'prices', + 'stockAmount' + ]) + && isString(value?.id) + && isProductType(value?.type) + && isString(value?.title) + && isString(value?.summary) + && isNumberOrUndefined(value?.stockAmount) + && isArrayOf(value?.features, isProductFeature) + && isArrayOf(value?.prices, isProductPrice) + ); +} + +export function explainProduct (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'id', + 'type', + 'title', + 'summary', + 'features', + 'prices', + 'stockAmount' + ]), + explainProperty("isArrayOf", explainString(value?.isArrayOf)), + explainProperty("type", explainProductType(value?.type)), + explainProperty("title", explainString(value?.title)), + explainProperty("summary", explainString(value?.summary)), + explainProperty("stockAmount", explainNumberOrUndefined(value?.stockAmount)), + explainProperty("features", explainArrayOf("ProductFeature", explainProductFeature, value?.features)), + explainProperty("prices", explainArrayOf("ProductPrice", explainProductPrice, value?.prices)), + ] + ); +} + + +export function isProductOrUndefined (value: any): value is Product | undefined { + return value === undefined || isProduct(value); +} + +export function stringifyProduct (value: Product): string { + if ( !isProduct(value) ) throw new TypeError(`Not Product: ${value}`); + return `Product(${value?.id})`; +} + +export function parseProduct (value: any): Product | undefined { + if ( isProduct(value) ) return value; + return undefined; +} diff --git a/store/types/product/ProductDTO.ts b/store/types/product/ProductDTO.ts new file mode 100644 index 0000000..e5c3b05 --- /dev/null +++ b/store/types/product/ProductDTO.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isBoolean } from "../../../types/Boolean"; +import { isString } from "../../../types/String"; +import { isNumber } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface ProductDTO { + readonly productId : string; + readonly productGroupId : string; + readonly priceTypeId : string; + readonly updated : string; + readonly creation : string; + readonly number : number; + readonly name : string; + readonly description : string; + readonly expensePrice : number; + readonly price : number; + readonly vatPercent : number; + readonly onHold : boolean; + readonly isPublic : boolean; + readonly stockEnabled : boolean; + readonly stockAmount : number; +} + +export function createProductDTO ( + productId : string, + productGroupId : string, + priceTypeId : string, + updated : string, + creation : string, + number : number, + name : string, + description : string, + expensePrice : number, + price : number, + vatPercent : number, + onHold : boolean, + isPublic : boolean, + stockEnabled : boolean, + stockAmount : number +): ProductDTO { + return { + productId, + productGroupId, + priceTypeId, + updated, + creation, + number, + name, + description, + expensePrice, + price, + vatPercent, + onHold, + isPublic, + stockEnabled, + stockAmount + }; +} + +export function isProductDTO (value: any): value is ProductDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'productId', + 'productGroupId', + 'priceTypeId', + 'updated', + 'creation', + 'number', + 'name', + 'description', + 'expensePrice', + 'price', + 'vatPercent', + 'onHold', + 'isPublic', + 'stockEnabled', + 'stockAmount' + ]) + && isString(value?.productId) + && isString(value?.productGroupId) + && isString(value?.priceTypeId) + && isString(value?.updated) + && isString(value?.creation) + && isNumber(value?.number) + && isString(value?.name) + && isString(value?.description) + && isNumber(value?.expensePrice) + && isNumber(value?.price) + && isNumber(value?.vatPercent) + && isBoolean(value?.onHold) + && isBoolean(value?.isPublic) + && isBoolean(value?.stockEnabled) + && isNumber(value?.stockAmount) + ); +} + +export function stringifyProductDTO (value: ProductDTO): string { + return `ProductDTO(${value})`; +} + +export function parseProductDTO (value: any): ProductDTO | undefined { + if ( isProductDTO(value) ) return value; + return undefined; +} diff --git a/store/types/product/ProductId.ts b/store/types/product/ProductId.ts new file mode 100644 index 0000000..1d379b1 --- /dev/null +++ b/store/types/product/ProductId.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainString, isString } from "../../../types/String"; + +export type ProductId = string; + +export function isProductId (value: any) : value is ProductId { + return isString(value); +} + +export function explainProductId (value: any) : string { + return explainString(value); +} diff --git a/store/types/product/ProductIdList.ts b/store/types/product/ProductIdList.ts new file mode 100644 index 0000000..1d9d34b --- /dev/null +++ b/store/types/product/ProductIdList.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ProductId } from "./ProductId"; +import { explainProductIdOrProductIdWithAmount, isProductIdOrProductIdWithAmount, ProductIdWithAmount } from "./ProductIdWithAmount"; +import { explainArrayOf, isArrayOf } from "../../../types/Array"; + +export type ProductIdListWithAmount = readonly (ProductId | ProductIdWithAmount)[]; + +export function isProductIdListWithAmount (value: any) : value is ProductIdListWithAmount { + return isArrayOf(value, isProductIdOrProductIdWithAmount); +} + +export function explainProductIdListWithAmount (value: any) : string { + return explainArrayOf( + "ProductId | ProductIdWithAmount", + explainProductIdOrProductIdWithAmount, + value, + isProductIdOrProductIdWithAmount + ); +} diff --git a/store/types/product/ProductIdWithAmount.ts b/store/types/product/ProductIdWithAmount.ts new file mode 100644 index 0000000..a99fd90 --- /dev/null +++ b/store/types/product/ProductIdWithAmount.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isProductId, ProductId } from "./ProductId"; +import { isArray } from "../../../types/Array"; +import { explainNot, explainOk } from "../../../types/explain"; +import { isNumber } from "../../../types/Number"; + +export type ProductIdWithAmount = readonly [number, ProductId]; + +export function isProductIdWithAmount (value: any) : value is ProductIdWithAmount { + return ( + isArray(value) + && value?.length === 2 + && isNumber(value[0]) + && isProductId(value[1]) + ); +} + +export function explainProductIdWithAmount (value: any) : string { + return isProductIdWithAmount(value) ? explainOk() : explainNot('ProductIdWithAmount [number, ProductId]'); +} + +export function isProductIdOrProductIdWithAmount ( value : any ) : value is ProductId | ProductIdWithAmount { + return isProductId(value) || isProductIdWithAmount(value); +} + +export function explainProductIdOrProductIdWithAmount (value: any) : string { + return isProductIdWithAmount(value) ? explainOk() : explainNot('ProductIdWithAmount {[number, ProductId]} or ProductId {string}'); +} diff --git a/store/types/product/ProductListDTO.ts b/store/types/product/ProductListDTO.ts new file mode 100644 index 0000000..2c00e68 --- /dev/null +++ b/store/types/product/ProductListDTO.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isProduct, Product } from "./Product"; +import { isArrayOf } from "../../../types/Array"; + +export interface ProductListDTO { + readonly items : readonly Product[]; +} + +export function isProductListDTO (value: any): value is ProductListDTO { + return ( + !!value + && isArrayOf(value?.items, isProduct) + ); +} + +export function stringifyProductListDTO (value: ProductListDTO): string { + if ( !isProductListDTO(value) ) throw new TypeError( + `Not ProductListDTO: ${value}`); + return `ProductListDTO(${value})`; +} + +export function parseProductListDTO (value: any): ProductListDTO | undefined { + if ( isProductListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/product/ProductModel.ts b/store/types/product/ProductModel.ts new file mode 100644 index 0000000..42ab72e --- /dev/null +++ b/store/types/product/ProductModel.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { isProductOrUndefined, Product } from "./Product"; +import { isProductPriceOrUndefined, ProductPrice } from "./ProductPrice"; +import { ButtonStyle, isButtonStyleOrUndefined } from "../../../frontend/button/ButtonStyle"; +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isNumber, isNumberOrUndefined } from "../../../types/Number"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface SelectProductModelCallback { + (item: ProductModel): void; +} + +export interface ProductModel { + readonly id : string; + readonly icon : any; + readonly title : string; + readonly description : string; + readonly price : number; + readonly route ?: string; + readonly buttonLabel ?: string; + readonly product ?: Product; + readonly productPrice ?: ProductPrice; + readonly buttonStyle ?: ButtonStyle; +} + +export function createProductModel ( + id: string, + icon: any, + title: string, + description: string, + price: number, + route ?: string, + buttonLabel ?: string, + product ?: Product, + productPrice ?: ProductPrice, + buttonStyle ?: ButtonStyle +): ProductModel { + return { + id, + icon, + title, + description, + price, + route, + buttonLabel, + product, + productPrice, + buttonStyle + }; +} + +export function isProductModel (value: any): value is ProductModel { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'icon', + 'title', + 'description', + 'price', + 'route', + 'buttonLabel', + 'product', + 'productPrice', + 'buttonStyle' + ]) + && isString(value?.id) + && isString(value?.title) + && isString(value?.description) + && isNumber(value?.price) + && isNumber(value?.buttonLabel) + && isStringOrUndefined(value?.route) + && isProductOrUndefined(value?.product) + && isProductPriceOrUndefined(value?.productPrice) + && isButtonStyleOrUndefined(value?.buttonStyle) + ); +} + +export function isPartialProductModel (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'icon', + 'title', + 'description', + 'price', + 'route', + 'buttonLabel', + 'product', + 'productPrice', + 'buttonStyle' + ]) + && isStringOrUndefined(value?.id) + && isStringOrUndefined(value?.title) + && isStringOrUndefined(value?.description) + && isNumberOrUndefined(value?.price) + && isStringOrUndefined(value?.route) + && isStringOrUndefined(value?.buttonLabel) + && isProductOrUndefined(value?.product) + && isProductPriceOrUndefined(value?.productPrice) + && isButtonStyleOrUndefined(value?.buttonStyle) + ); +} + +export function stringifyProductModel (value: ProductModel): string { + return `ProductModel(${value})`; +} + +export function parseProductModel (value: any): ProductModel | undefined { + if ( isProductModel(value) ) return value; + return undefined; +} + + diff --git a/store/types/product/ProductModelList.ts b/store/types/product/ProductModelList.ts new file mode 100644 index 0000000..899ea58 --- /dev/null +++ b/store/types/product/ProductModelList.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ProductModel } from "./ProductModel"; + +export interface ProductModelList { + readonly items : ProductModel[]; +} diff --git a/store/types/product/ProductPrice.ts b/store/types/product/ProductPrice.ts new file mode 100644 index 0000000..005ab86 --- /dev/null +++ b/store/types/product/ProductPrice.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainProductPriceType, isProductPriceType, ProductPriceType } from "./ProductPriceType"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainStringOrUndefined, isStringOrUndefined } from "../../../types/String"; +import { explainNumber, explainNumberOrUndefined, isNumber, isNumberOrUndefined } from "../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface ProductPrice { + readonly sum : number; + readonly vatPercent : number; + readonly type : ProductPriceType; + readonly buyUrl ?: string; + readonly discountPercent ?: number; + readonly discountFrom ?: string; + readonly discountTo ?: string; +} + +export function createProductPrice ( + sum : number, + vatPercent : number, + type : ProductPriceType, + buyUrl ?: string, + discountPercent ?: number, + discountFrom ?: string, + discountTo ?: string +): ProductPrice { + return { + sum, + vatPercent, + type, + buyUrl, + discountPercent, + discountFrom, + discountTo + }; +} + + +export function isProductPrice (value: any): value is ProductPrice { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'sum', + 'vatPercent', + 'type', + 'discountPercent', + 'discountFrom', + 'discountTo', + 'buyUrl' + ]) + && isNumber(value?.sum) + && isNumber(value?.vatPercent) + && isNumberOrUndefined(value?.discountPercent) + && isStringOrUndefined(value?.discountFrom) + && isStringOrUndefined(value?.discountTo) + && isProductPriceType(value?.type) + && isStringOrUndefined(value?.buyUrl) + ); +} + +export function explainProductPrice (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'sum', + 'vatPercent', + 'type', + 'discountPercent', + 'discountFrom', + 'discountTo', + 'buyUrl' + ]), + explainProperty("sum", explainNumber(value?.sum)), + explainProperty("vatPercent", explainNumber(value?.vatPercent)), + explainProperty("type", explainProductPriceType(value?.type)), + explainProperty("buyUrl", explainStringOrUndefined(value?.buyUrl)), + explainProperty("discountPercent", explainNumberOrUndefined(value?.discountPercent)), + explainProperty("discountFrom", explainStringOrUndefined(value?.discountFrom)), + explainProperty("discountTo", explainStringOrUndefined(value?.discountTo)) + ] + ); +} + +export function isProductPriceOrUndefined (value: any): value is ProductPrice | undefined { + return value === undefined || isProductPrice(value); +} + +export function stringifyProductPrice (value: ProductPrice): string { + return `ProductPrice(${value})`; +} + +export function parseProductPrice (value: any): ProductPrice | undefined { + if ( isProductPrice(value) ) return value; + return undefined; +} diff --git a/store/types/product/ProductPriceType.ts b/store/types/product/ProductPriceType.ts new file mode 100644 index 0000000..b978e12 --- /dev/null +++ b/store/types/product/ProductPriceType.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; + +export enum ProductPriceType { + ONCE = "ONCE", + HOURLY = "HOURLY", + DAILY = "DAILY", + WEEKLY = "WEEKLY", + BIWEEKLY = "BIWEEKLY", + MONTHLY = "MONTHLY", + BIMONTHLY = "BIMONTHLY", + QUARTERLY = "QUARTERLY", + SEASONAL = "SEASONAL", + BIANNUAL = "BIANNUAL", + YEARLY = "YEARLY", + YEARS_2 = "YEARS_2", + YEARS_3 = "YEARS_3", + YEARS_4 = "YEARS_4", + YEARS_5 = "YEARS_5", + YEARS_10 = "YEARS_10" +} + +export function isProductPriceType (value: any): value is ProductPriceType { + switch (value) { + case ProductPriceType.ONCE : + case ProductPriceType.HOURLY : + case ProductPriceType.DAILY : + case ProductPriceType.WEEKLY : + case ProductPriceType.BIWEEKLY : + case ProductPriceType.MONTHLY : + case ProductPriceType.BIMONTHLY : + case ProductPriceType.QUARTERLY : + case ProductPriceType.SEASONAL : + case ProductPriceType.BIANNUAL : + case ProductPriceType.YEARLY : + case ProductPriceType.YEARS_2 : + case ProductPriceType.YEARS_3 : + case ProductPriceType.YEARS_4 : + case ProductPriceType.YEARS_5 : + case ProductPriceType.YEARS_10 : + return true; + + default: + return false; + } +} + +export function explainProductPriceType (value : any) : string { + return explainEnum("ProductPriceType", ProductPriceType, isProductPriceType, value); +} + +export function stringifyProductPriceType (value: ProductPriceType): string { + switch (value) { + case ProductPriceType.ONCE : return 'ONCE'; + case ProductPriceType.HOURLY : return 'HOURLY'; + case ProductPriceType.DAILY : return 'DAILY'; + case ProductPriceType.WEEKLY : return 'WEEKLY'; + case ProductPriceType.BIWEEKLY : return 'BIWEEKLY'; + case ProductPriceType.MONTHLY : return 'MONTHLY'; + case ProductPriceType.BIMONTHLY : return 'BIMONTHLY'; + case ProductPriceType.QUARTERLY : return 'QUARTERLY'; + case ProductPriceType.SEASONAL : return 'SEASONAL'; + case ProductPriceType.BIANNUAL : return 'BIANNUAL'; + case ProductPriceType.YEARLY : return 'YEARLY'; + case ProductPriceType.YEARS_2 : return 'YEARS_2'; + case ProductPriceType.YEARS_3 : return 'YEARS_3'; + case ProductPriceType.YEARS_4 : return 'YEARS_4'; + case ProductPriceType.YEARS_5 : return 'YEARS_5'; + case ProductPriceType.YEARS_10 : return 'YEARS_10'; + } + throw new TypeError(`Unsupported ProductPriceType value: ${value}`); +} + +export function parseProductPriceType (value: any): ProductPriceType | undefined { + switch (`${value}`.toUpperCase()) { + case 'ONCE' : return ProductPriceType.ONCE; + case 'HOURLY' : return ProductPriceType.HOURLY; + case 'DAILY' : return ProductPriceType.DAILY; + case 'WEEKLY' : return ProductPriceType.WEEKLY; + case 'BIWEEKLY' : return ProductPriceType.BIWEEKLY; + case 'MONTHLY' : return ProductPriceType.MONTHLY; + case 'BIMONTHLY' : return ProductPriceType.BIMONTHLY; + case 'QUARTERLY' : return ProductPriceType.QUARTERLY; + case 'SEASONAL' : return ProductPriceType.SEASONAL; + case 'BIANNUAL' : return ProductPriceType.BIANNUAL; + case 'YEARLY' : return ProductPriceType.YEARLY; + case 'YEARS_2' : return ProductPriceType.YEARS_2; + case 'YEARS_3' : return ProductPriceType.YEARS_3; + case 'YEARS_4' : return ProductPriceType.YEARS_4; + case 'YEARS_5' : return ProductPriceType.YEARS_5; + case 'YEARS_10' : return ProductPriceType.YEARS_10; + default : return undefined; + } +} + +/** + * This function returns how many months this product price covers. + * + * Please note, that if the price type cannot be expressed as full months, this + * function will return `0`. + * + * @throws {TypeError} if the price type is not recognized as a valid price type + * @param priceType + */ +export function getBillingMonthsForProductPriceType (priceType: ProductPriceType) : number { + switch (priceType) { + + case ProductPriceType.ONCE: + case ProductPriceType.HOURLY: + case ProductPriceType.DAILY: + case ProductPriceType.WEEKLY: + case ProductPriceType.BIWEEKLY: + return 0; + + case ProductPriceType.MONTHLY : return 1; + case ProductPriceType.BIMONTHLY : return 2; + case ProductPriceType.QUARTERLY : return 3; + case ProductPriceType.SEASONAL : return 4; + case ProductPriceType.BIANNUAL : return 6; + case ProductPriceType.YEARLY : return 12; + case ProductPriceType.YEARS_2 : return 24; + case ProductPriceType.YEARS_3 : return 36; + case ProductPriceType.YEARS_4 : return 48; + case ProductPriceType.YEARS_5 : return 60; + case ProductPriceType.YEARS_10 : return 120; + + } + throw new TypeError(`Unsupported type: ${priceType}`); +} + +export function isProductPriceTypeOrUndefined (value: unknown): value is ProductPriceType | undefined { + return isUndefined(value) || isProductPriceType(value); +} + +export function explainProductPriceTypeOrUndefined (value: unknown): string { + return isProductPriceTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['ProductPriceType', 'undefined'])); +} + diff --git a/store/types/product/ProductTableItemDataModel.ts b/store/types/product/ProductTableItemDataModel.ts new file mode 100644 index 0000000..cbb8222 --- /dev/null +++ b/store/types/product/ProductTableItemDataModel.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2021. Heusala Group Oy . All rights reserved. + +export interface ProductTableItemDataModel { + readonly id: number; + readonly mainTitle: string; + readonly title: readonly string[]; + readonly product: readonly any[]; +} diff --git a/store/types/product/ProductTableItemModel.ts b/store/types/product/ProductTableItemModel.ts new file mode 100644 index 0000000..eb6b8e1 --- /dev/null +++ b/store/types/product/ProductTableItemModel.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { ProductPriceType } from "./ProductPriceType"; +import { Product } from "./Product"; +import { ProductPrice } from "./ProductPrice"; + +export interface ProductTableItemModel { + readonly id: number; + readonly title: string; + readonly description: string; + readonly buttonTo: string | undefined; + readonly gb: number; + readonly price: number; + readonly priceVatPercent: number; + readonly priceType: ProductPriceType; + readonly priceTypeOptions : ProductPriceType[]; + readonly isButton: boolean; + readonly priceModel : ProductPrice | undefined; + readonly productModel : Product | undefined; +} + + diff --git a/store/types/product/ProductTableModel.ts b/store/types/product/ProductTableModel.ts new file mode 100644 index 0000000..aa39041 --- /dev/null +++ b/store/types/product/ProductTableModel.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeys } from "../../../types/OtherKeys"; + +export interface SelectProductModelCallback { + (item: ProductTableModel): void; +} + +export interface ProductTableModel { + + /** + * The title of the item + */ + readonly id: number; + + /** + * The title of the item + */ + readonly title: string; + + /** + * The current description of the item + */ + readonly description: string; + + /** + * The current price of the item + */ + readonly month: number; + + /** + * The price of the item + */ + readonly price: number; + + /** + * is there button of the item + */ + readonly isButton: boolean; + +} + +export function isProductTableModel (value: any): value is ProductTableModel { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'title', + 'description', + 'month', + 'price' + + ]) + ); +} + +export function isPartialProductTableModel (value: any): value is Partial { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + + 'id', + 'title', + 'description', + 'month', + 'price' + + ]) + ); +} + +export function stringifyProductTableModel (value: ProductTableModel): string { + return `ProductTableModel(${value})`; +} + +export function parseProductTableModel (value: any): ProductTableModel | undefined { + if ( isProductTableModel(value) ) return value; + return undefined; +} + + diff --git a/store/types/product/ProductType.ts b/store/types/product/ProductType.ts new file mode 100644 index 0000000..15f1453 --- /dev/null +++ b/store/types/product/ProductType.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; + +export enum ProductType { + PHOTO = "PHOTO", + PREMIUM_DOMAIN = "PREMIUM_DOMAIN", + VIRTUAL_SERVER = "VIRTUAL_SERVER", + VIRTUAL_SERVER_EXTRA = "VIRTUAL_SERVER_EXTRA", + WEB_HOTEL_EXTRA = "WEB_HOTEL_EXTRA", + EMAIL_EXTRA = "EMAIL_EXTRA", + SHELL_EXTRA = "SHELL_EXTRA", + WEB_HOTEL = "WEB_HOTEL", + DOMAIN_TRANSFER = "DOMAIN_TRANSFER", + DOMAIN = "DOMAIN", + EMAIL = "EMAIL", + SHELL = "SHELL", + DATABASE = "DATABASE", + WP = "WP", + NET = "NET", + WEBRTC = "WEBRTC", + STOCK = "STOCK" +} + +export function isProductType (value: any): value is ProductType { + switch (value) { + case ProductType.PHOTO: + case ProductType.PREMIUM_DOMAIN: + case ProductType.VIRTUAL_SERVER: + case ProductType.VIRTUAL_SERVER_EXTRA: + case ProductType.WEB_HOTEL_EXTRA: + case ProductType.EMAIL_EXTRA: + case ProductType.SHELL_EXTRA: + case ProductType.WEB_HOTEL: + case ProductType.DOMAIN: + case ProductType.DOMAIN_TRANSFER: + case ProductType.EMAIL: + case ProductType.SHELL: + case ProductType.DATABASE: + case ProductType.WP: + case ProductType.NET: + case ProductType.WEBRTC: + case ProductType.STOCK: + return true; + default: + return false; + } +} + +export function explainProductType (value : any) : string { + return explainEnum("ProductType", ProductType, isProductType, value); +} + +export function stringifyProductType (value: ProductType): string { + switch (value) { + case ProductType.PHOTO : return 'PHOTO'; + case ProductType.PREMIUM_DOMAIN : return 'PREMIUM_DOMAIN'; + case ProductType.VIRTUAL_SERVER : return 'VIRTUAL_SERVER'; + case ProductType.VIRTUAL_SERVER_EXTRA : return 'VIRTUAL_SERVER_EXTRA'; + case ProductType.WEB_HOTEL_EXTRA : return 'WEB_HOTEL_EXTRA'; + case ProductType.EMAIL_EXTRA : return 'EMAIL_EXTRA'; + case ProductType.SHELL_EXTRA : return 'SHELL_EXTRA'; + case ProductType.WEB_HOTEL : return 'WEB_HOTEL'; + case ProductType.DOMAIN : return 'DOMAIN'; + case ProductType.DOMAIN_TRANSFER : return 'DOMAIN_TRANSFER'; + case ProductType.EMAIL : return 'EMAIL'; + case ProductType.SHELL : return 'SHELL'; + case ProductType.DATABASE : return 'DATABASE'; + case ProductType.WP : return 'WP'; + case ProductType.NET : return 'NET'; + case ProductType.WEBRTC : return 'WEBRTC'; + case ProductType.STOCK : return 'STOCK'; + } + throw new TypeError(`Unsupported ProductType value: ${value}`); +} + +export function parseProductType (value: any): ProductType | undefined { + switch (value) { + case 'PHOTO' : return ProductType.PHOTO; + case 'PREMIUM_DOMAIN' : return ProductType.PREMIUM_DOMAIN; + case 'VIRTUAL_SERVER' : return ProductType.VIRTUAL_SERVER; + case 'VIRTUAL_SERVER_EXTRA' : return ProductType.VIRTUAL_SERVER_EXTRA; + case 'WEB_HOTEL_EXTRA' : return ProductType.WEB_HOTEL_EXTRA; + case 'EMAIL_EXTRA' : return ProductType.EMAIL_EXTRA; + case 'SHELL_EXTRA' : return ProductType.SHELL_EXTRA; + case 'WEB_HOTEL' : return ProductType.WEB_HOTEL; + case 'DOMAIN' : return ProductType.DOMAIN; + case 'DOMAIN_TRANSFER' : return ProductType.DOMAIN_TRANSFER; + case 'EMAIL' : return ProductType.EMAIL; + case 'SHELL' : return ProductType.SHELL; + case 'DATABASE' : return ProductType.DATABASE; + case 'WP' : return ProductType.WP; + case 'NET' : return ProductType.NET; + case 'WEBRTC' : return ProductType.WEBRTC; + case 'STOCK' : return ProductType.STOCK; + default : + return undefined; + } +} + +export function isProductTypeOrUndefined (value: unknown): value is ProductType | undefined { + return isUndefined(value) || isProductType(value); +} + +export function explainProductTypeOrUndefined (value: unknown): string { + return isProductTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['ProductType', 'undefined'])); +} diff --git a/store/types/product/features/BackupType.ts b/store/types/product/features/BackupType.ts new file mode 100644 index 0000000..6810320 --- /dev/null +++ b/store/types/product/features/BackupType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum BackupType { + UNAVAILABLE = "UNAVAILABLE", + AVAILABLE = "AVAILABLE", + INCLUDED_RESTORE_FREE = "INCLUDED_RESTORE_FREE", + INCLUDED_WITH_RESTORE_FEE = "INCLUDED_WITH_RESTORE_FEE", +} + +export function isBackupType (value: any) : value is BackupType { + switch (value) { + case BackupType.UNAVAILABLE: + case BackupType.AVAILABLE: + case BackupType.INCLUDED_RESTORE_FREE: + case BackupType.INCLUDED_WITH_RESTORE_FEE: + return true; + default: + return false; + } +} + +export function explainBackupType (value : any) : string { + return explainEnum("BackupType", BackupType, isBackupType, value); +} + +export function stringifyBackupType (value : BackupType) : string { + switch (value) { + case BackupType.UNAVAILABLE : return 'UNAVAILABLE'; + case BackupType.AVAILABLE : return 'AVAILABLE'; + case BackupType.INCLUDED_RESTORE_FREE : return 'INCLUDED_RESTORE_FREE'; + case BackupType.INCLUDED_WITH_RESTORE_FEE : return 'INCLUDED_WITH_RESTORE_FEE'; + } + throw new TypeError(`Unsupported BackupType value: ${value}`) +} + +export function parseBackupType (value: any) : BackupType | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'UNAVAILABLE' : return BackupType.UNAVAILABLE; + case 'AVAILABLE' : return BackupType.AVAILABLE; + case 'INCLUDED_RESTORE_FREE' : return BackupType.INCLUDED_RESTORE_FREE; + case 'INCLUDED_WITH_RESTORE_FEE' : return BackupType.INCLUDED_WITH_RESTORE_FEE; + default : return undefined; + } +} diff --git a/store/types/product/features/CpuShare.ts b/store/types/product/features/CpuShare.ts new file mode 100644 index 0000000..e275b04 --- /dev/null +++ b/store/types/product/features/CpuShare.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export enum CpuShare { + CPU_1_10 = "CPU_1_10", + CPU_1_5 = "CPU_1_5" +} + +export function isCpuShare (value: any): value is CpuShare { + switch (value) { + case CpuShare.CPU_1_10: + case CpuShare.CPU_1_5: + return true; + + default: + return false; + + } +} + +export function stringifyCpuShare (value: CpuShare): string { + switch (value) { + case CpuShare.CPU_1_10 : return '1/10'; + case CpuShare.CPU_1_5 : return '1/5'; + default : return `${value}`; + } +} + +export function parseCpuShare (value: any): CpuShare | undefined { + switch (`${value}`.toUpperCase()) { + + case '0.1': + case '1/10': + case 'CPU_1_10' : return CpuShare.CPU_1_10; + + case '0.2': + case '1/5': + case 'CPU_1_5' : return CpuShare.CPU_1_5; + + default : return undefined; + } +} diff --git a/store/types/product/features/DiskType.ts b/store/types/product/features/DiskType.ts new file mode 100644 index 0000000..772e346 --- /dev/null +++ b/store/types/product/features/DiskType.ts @@ -0,0 +1,8 @@ + +export enum DiskType { + + DISK_NVME_M2_SSD = "DISK_NVME_M2_SSD", + DISK_SSD = "DISK_SSD", + DISK_HDD = "DISK_HDD" + +} diff --git a/store/types/product/features/DiskUsageType.ts b/store/types/product/features/DiskUsageType.ts new file mode 100644 index 0000000..938c7f9 --- /dev/null +++ b/store/types/product/features/DiskUsageType.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum DiskUsageType { + ANY = "ANY", + OS = "OS", + SWAP = "SWAP", + DATA = "DATA" +} + +export function isDiskUsageType (value: any) : value is DiskUsageType { + switch (value) { + case DiskUsageType.ANY: + case DiskUsageType.OS: + case DiskUsageType.SWAP: + case DiskUsageType.DATA: + return true; + default: + return false; + } +} + +export function explainDiskUsageType (value : any) : string { + return explainEnum("DiskUsageType", DiskUsageType, isDiskUsageType, value); +} + +export function stringifyDiskUsageType (value : DiskUsageType) : string { + switch (value) { + case DiskUsageType.ANY : return 'ANY'; + case DiskUsageType.OS : return 'OS'; + case DiskUsageType.SWAP : return 'SWAP'; + case DiskUsageType.DATA : return 'DATA'; + } + throw new TypeError(`Unsupported DiskUsageType value: ${value}`) +} + +export function parseDiskUsageType (value: any) : DiskUsageType | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'ANY' : return DiskUsageType.ANY; + case 'OS' : return DiskUsageType.OS; + case 'SWAP' : return DiskUsageType.SWAP; + case 'DATA' : return DiskUsageType.DATA; + default : return undefined; + } +} diff --git a/store/types/product/features/NetworkIpType.ts b/store/types/product/features/NetworkIpType.ts new file mode 100644 index 0000000..0fc7bd5 --- /dev/null +++ b/store/types/product/features/NetworkIpType.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum NetworkIpType { + + /** + * Public address included in the price + */ + PUBLIC_INCLUDED = "PUBLIC_INCLUDED", + + /** + * Public address available with extra fee. + * Otherwise same as PRIVATE_NAT. + */ + PUBLIC_AVAILABLE = "PUBLIC_AVAILABLE", + + /** + * Private address included (behind PRIVATE_NAT) + */ + PRIVATE_NAT = "PRIVATE_NAT" + +} + +export function isNetworkIpType (value: any): value is NetworkIpType { + switch (value) { + case NetworkIpType.PUBLIC_INCLUDED: + case NetworkIpType.PUBLIC_AVAILABLE: + case NetworkIpType.PRIVATE_NAT: + return true; + default: + return false; + } +} + +export function explainNetworkIpType (value: any): string { + return explainEnum("NetworkIpType", NetworkIpType, isNetworkIpType, value); +} + +export function stringifyNetworkIpType (value: NetworkIpType): string { + switch (value) { + case NetworkIpType.PUBLIC_INCLUDED : return 'PUBLIC_INCLUDED'; + case NetworkIpType.PUBLIC_AVAILABLE : return 'PUBLIC_AVAILABLE'; + case NetworkIpType.PRIVATE_NAT : return 'PRIVATE_NAT'; + } + throw new TypeError(`Unsupported NetworkIpType value: ${value}`); +} + +export function parseNetworkIpType (value: any): NetworkIpType | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toUpperCase()) { + case 'PUBLIC_INCLUDED' : return NetworkIpType.PUBLIC_INCLUDED; + case 'PUBLIC_AVAILABLE' : return NetworkIpType.PUBLIC_AVAILABLE; + case 'PRIVATE_NAT' : return NetworkIpType.PRIVATE_NAT; + default : return undefined; + } +} diff --git a/store/types/product/features/NetworkType.ts b/store/types/product/features/NetworkType.ts new file mode 100644 index 0000000..6a4510f --- /dev/null +++ b/store/types/product/features/NetworkType.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../../types/Enum"; +import { isUndefined } from "../../../../types/undefined"; +import { explainNot, explainOk, explainOr } from "../../../../types/explain"; + +export enum NetworkType { + ETH_10M = "ETH_10M", + ETH_100M = "ETH_100M", + ETH_1G = "ETH_1G", + ETH_2G = "ETH_2G", + ETH_5G = "ETH_5G", + ETH_10G = "ETH_10G", +} + +export function isNetworkType (value: unknown) : value is NetworkType { + return isEnum(NetworkType, value); +} + +export function explainNetworkType (value : unknown) : string { + return explainEnum("NetworkType", NetworkType, isNetworkType, value); +} + +export function stringifyNetworkType (value : NetworkType) : string { + return stringifyEnum(NetworkType, value); +} + +export function parseNetworkType (value: any) : NetworkType | undefined { + return parseEnum(NetworkType, value) as NetworkType | undefined; +} + +export function isNetworkTypeOrUndefined (value: unknown): value is NetworkType | undefined { + return isUndefined(value) || isNetworkType(value); +} + +export function explainNetworkTypeOrUndefined (value: unknown): string { + return isNetworkTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['NetworkType', 'undefined'])); +} diff --git a/store/types/product/features/NetworkZone.ts b/store/types/product/features/NetworkZone.ts new file mode 100644 index 0000000..dda58cd --- /dev/null +++ b/store/types/product/features/NetworkZone.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum NetworkZone { + FI_TAMPERE_1 = "FI_TAMPERE_1", + FI_TURKU_1 = "FI_TURKU_1", + FI_OULU_1 = "FI_OULU_1", +} + +export function isNetworkZone (value: any): value is NetworkZone { + switch (value) { + case NetworkZone.FI_TAMPERE_1: + case NetworkZone.FI_TURKU_1: + case NetworkZone.FI_OULU_1: + return true; + default: + return false; + } +} + +export function explainNetworkZone (value: any): string { + return explainEnum("NetworkZone", NetworkZone, isNetworkZone, value); +} + +export function stringifyNetworkZone (value: NetworkZone): string { + switch (value) { + case NetworkZone.FI_TAMPERE_1 : return 'FI_TAMPERE_1'; + case NetworkZone.FI_TURKU_1 : return 'FI_TURKU_1'; + case NetworkZone.FI_OULU_1 : return 'FI_OULU_1'; + } + throw new TypeError(`Unsupported NetworkZone value: ${value}`); +} + +export function parseNetworkZone (value: any): NetworkZone | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toUpperCase()) { + case 'FI_TAMPERE_1' : return NetworkZone.FI_TAMPERE_1; + case 'FI_TURKU_1' : return NetworkZone.FI_TURKU_1; + case 'FI_OULU_1' : return NetworkZone.FI_OULU_1; + default : return undefined; + } +} diff --git a/store/types/product/features/OperatingSystem.ts b/store/types/product/features/OperatingSystem.ts new file mode 100644 index 0000000..cb83a89 --- /dev/null +++ b/store/types/product/features/OperatingSystem.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum OperatingSystem { + LINUX_UBUNTU_LTS_18_04 = "LINUX_UBUNTU_LTS_18_04", + LINUX_UBUNTU_LTS_20_04 = "LINUX_UBUNTU_LTS_20_04", + LINUX_UBUNTU_LTS_22_04 = "LINUX_UBUNTU_LTS_22_04", + LINUX_DEBIAN_10 = "LINUX_DEBIAN_10", + LINUX_ARCH = "LINUX_ARCH", + LINUX_NIXOS = "LINUX_NIXOS", + LINUX_FREEBSD = "LINUX_FREEBSD", + FREEBSD = "FREEBSD", +} + +export function isOperatingSystem (value: any): value is OperatingSystem { + switch (value) { + case OperatingSystem.LINUX_UBUNTU_LTS_18_04: + case OperatingSystem.LINUX_UBUNTU_LTS_20_04: + case OperatingSystem.LINUX_UBUNTU_LTS_22_04: + case OperatingSystem.LINUX_DEBIAN_10: + case OperatingSystem.LINUX_ARCH: + case OperatingSystem.LINUX_NIXOS: + case OperatingSystem.LINUX_FREEBSD: + case OperatingSystem.FREEBSD: + return true; + default: + return false; + } +} + +export function explainOperatingSystem (value: any): string { + return explainEnum("OperatingSystem", OperatingSystem, isOperatingSystem, value); +} + +export function stringifyOperatingSystem (value: OperatingSystem): string { + switch (value) { + case OperatingSystem.LINUX_UBUNTU_LTS_20_04 : return 'LINUX_UBUNTU_LTS_20_04'; + case OperatingSystem.LINUX_UBUNTU_LTS_22_04 : return 'LINUX_UBUNTU_LTS_22_04'; + case OperatingSystem.LINUX_UBUNTU_LTS_18_04 : return 'LINUX_UBUNTU_LTS_18_04'; + case OperatingSystem.LINUX_DEBIAN_10 : return 'LINUX_DEBIAN_10'; + case OperatingSystem.LINUX_ARCH : return 'LINUX_ARCH'; + case OperatingSystem.LINUX_NIXOS : return 'LINUX_NIXOS'; + case OperatingSystem.FREEBSD : return 'FREEBSD'; + case OperatingSystem.LINUX_FREEBSD : return 'LINUX_FREEBSD'; + } + throw new TypeError(`Unsupported OperatingSystem value: ${value}`); +} + +export function parseOperatingSystem (value: any): OperatingSystem | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toUpperCase()) { + + case 'LINUX': + case 'LINUX_UBUNTU': + case 'LINUX_UBUNTU_LTS': + case 'LINUX_UBUNTU_LTS_22_04' : return OperatingSystem.LINUX_UBUNTU_LTS_22_04; + + case 'LINUX_UBUNTU_LTS_20_04' : return OperatingSystem.LINUX_UBUNTU_LTS_20_04; + case 'LINUX_UBUNTU_LTS_18_04' : return OperatingSystem.LINUX_UBUNTU_LTS_18_04; + + case 'LINUX_DEBIAN_10' : return OperatingSystem.LINUX_DEBIAN_10; + case 'LINUX_ARCH' : return OperatingSystem.LINUX_ARCH; + case 'LINUX_NIXOS' : return OperatingSystem.LINUX_NIXOS; + case 'FREEBSD' : return OperatingSystem.FREEBSD; + case 'LINUX_FREEBSD' : return OperatingSystem.LINUX_FREEBSD; + + default : return undefined; + } +} diff --git a/store/types/product/features/ProductFeature.ts b/store/types/product/features/ProductFeature.ts new file mode 100644 index 0000000..f78020c --- /dev/null +++ b/store/types/product/features/ProductFeature.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainProductFeatureCategory, isProductFeatureCategory, ProductFeatureCategory } from "./ProductFeatureCategory"; +import { explainProductFeatureId, isProductFeatureId, ProductFeatureId } from "./ProductFeatureId"; +import { explain, explainOr, explainProperty } from "../../../../types/explain"; +import { isBoolean, explainBoolean } from "../../../../types/Boolean"; +import { explainString, isString } from "../../../../types/String"; +import { explainNumber, isNumber } from "../../../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../../../types/OtherKeys"; + +export interface ProductFeature { + readonly id : ProductFeatureId; + readonly category : ProductFeatureCategory; + readonly value : string | number | boolean; +} + +export function createProductFeature ( + id : ProductFeatureId, + category : ProductFeatureCategory, + value : string | number | boolean +) : ProductFeature { + return { + id, + category, + value + }; +} + +export function isProductFeature (value: any): value is ProductFeature { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'id', + 'category', + 'value' + ]) + && isProductFeatureId(value?.id) + && isProductFeatureCategory(value?.category) + && ( isString(value?.value) || isNumber(value?.value) || isBoolean(value?.value) ) + ); +} + +export function explainProductFeature (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'id', + 'category', + 'value' + ]), + explainProperty("id", explainProductFeatureId(value?.id)), + explainProperty("category", explainProductFeatureCategory(value?.category)), + explainProperty( + "value", + explainOr( + [ + explainString(value?.value), + explainNumber(value?.value), + explainBoolean(value?.value) + ] + ) + ) + ] + ); +} + + +export function stringifyProductFeature (value: ProductFeature): string { + if ( !isProductFeature(value) ) throw new TypeError( + `Not ProductFeature: ${value}`); + return `ProductFeature(${value})`; +} + +export function parseProductFeature (value: any): ProductFeature | undefined { + if ( isProductFeature(value) ) return value; + return undefined; +} diff --git a/store/types/product/features/ProductFeatureCategory.ts b/store/types/product/features/ProductFeatureCategory.ts new file mode 100644 index 0000000..7280627 --- /dev/null +++ b/store/types/product/features/ProductFeatureCategory.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../../types/Enum"; + +export enum ProductFeatureCategory { + SUPPORT = "SUPPORT", + UPGRADE = "UPGRADE", + DISK = "DISK", + DISK_2 = "DISK_2", + DISK_3 = "DISK_3", + MEMORY = "MEMORY", + CPU = "CPU", + NETWORK = "NETWORK", + VPS = "VPS" +} + +export function isProductFeatureCategory (value: any): value is ProductFeatureCategory { + return isEnum(ProductFeatureCategory, value); +} + +export function explainProductFeatureCategory (value : any) : string { + return explainEnum("ProductFeatureCategory", ProductFeatureCategory, isProductFeatureCategory, value); +} + +export function stringifyProductFeatureCategory (value: ProductFeatureCategory): string { + return stringifyEnum(ProductFeatureCategory, value); +} + +export function parseProductFeatureCategory (value: any): ProductFeatureCategory | undefined { + return parseEnum(ProductFeatureCategory, value) as ProductFeatureCategory | undefined; +} diff --git a/store/types/product/features/ProductFeatureCategoryMappingType.ts b/store/types/product/features/ProductFeatureCategoryMappingType.ts new file mode 100644 index 0000000..9ce8f7a --- /dev/null +++ b/store/types/product/features/ProductFeatureCategoryMappingType.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ProductFeatureId } from "./ProductFeatureId"; + +export interface ProductFeatureCategoryMappingType { + [key: string]: ProductFeatureId[]; +} diff --git a/store/types/product/features/ProductFeatureId.ts b/store/types/product/features/ProductFeatureId.ts new file mode 100644 index 0000000..13eca67 --- /dev/null +++ b/store/types/product/features/ProductFeatureId.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../../types/Enum"; + +export enum ProductFeatureId { + WP = "WP", + VPS_TYPE = "VPS_TYPE", + VPS_OS = "VPS_OS", + + SUPPORT_1H_M = "SUPPORT_1H_M", + BACKUP_RESTORE_1H_M = "BACKUP_RESTORE_1H_M", + UPGRADE_1VPS_M = "UPGRADE_1VPS_M", + + DISK_TYPE = "DISK_TYPE", + DISK_SIZE = "DISK_SIZE", + DISK_RAID = "DISK_RAID", + DISK_BACKUP = "DISK_BACKUP", + DISK_USAGE = "DISK_USAGE", + + DISK_2_TYPE = "DISK_2_TYPE", + DISK_2_SIZE = "DISK_2_SIZE", + DISK_2_RAID = "DISK_2_RAID", + DISK_2_BACKUP = "DISK_2_BACKUP", + DISK_2_USAGE = "DISK_2_USAGE", + + DISK_3_TYPE = "DISK_3_TYPE", + DISK_3_SIZE = "DISK_3_SIZE", + DISK_3_RAID = "DISK_3_RAID", + DISK_3_BACKUP = "DISK_3_BACKUP", + DISK_3_USAGE = "DISK_3_USAGE", + + MEMORY_SIZE = "MEMORY_SIZE", + NETWORK_TYPE = "NETWORK_TYPE", + NETWORK_IP4 = "NETWORK_IP4", + NETWORK_IP6 = "NETWORK_IP6", + NETWORK_NET6 = "NETWORK_NET6", + NETWORK_TRAFFIC = "NETWORK_TRAFFIC", + NETWORK_ZONE = "NETWORK_ZONE", + CPU_SHARE = "CPU_SHARE", + CPU_AMOUNT = "CPU_AMOUNT" +} + +export function isProductFeatureId (value: any): value is ProductFeatureId { + return isEnum(ProductFeatureId, value); +} + +export function explainProductFeatureId (value : any) : string { + return explainEnum("ProductFeatureId", ProductFeatureId, isProductFeatureId, value); +} + +export function stringifyProductFeatureId (value: ProductFeatureId): string { + return stringifyEnum(ProductFeatureId, value); +} + +export function parseProductFeatureId (value: any): ProductFeatureId | undefined { + return parseEnum(ProductFeatureId, value) as ProductFeatureId | undefined; +} diff --git a/store/types/product/features/ProductFeatureType.ts b/store/types/product/features/ProductFeatureType.ts new file mode 100644 index 0000000..0f11493 --- /dev/null +++ b/store/types/product/features/ProductFeatureType.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +export enum ProductFeatureType { + OTHER = "OTHER", + SHELL = "SHELL", + SHELL_SERVER = "SHELL_SERVER", + EMAIL = "EMAIL", + EMAIL_SERVER = "EMAIL_SERVER", + WEB_SERVER = "WEB_SERVER", + PHYSICAL_PRODUCT = "PHYSICAL_PRODUCT", + VIRTUAL_SERVER = "VIRTUAL_SERVER", + SERVER = "SERVER", + CABLE = "CABLE", + DOMAIN = "DOMAIN" +} + +export function isProductFeatureType (value: any): value is ProductFeatureType { + switch (value) { + case ProductFeatureType.OTHER: + case ProductFeatureType.SHELL: + case ProductFeatureType.SHELL_SERVER: + case ProductFeatureType.EMAIL: + case ProductFeatureType.EMAIL_SERVER: + case ProductFeatureType.WEB_SERVER: + case ProductFeatureType.PHYSICAL_PRODUCT: + case ProductFeatureType.VIRTUAL_SERVER: + case ProductFeatureType.SERVER: + case ProductFeatureType.CABLE: + case ProductFeatureType.DOMAIN: + return true; + + default: + return false; + + } +} + +export function stringifyProductFeatureType (value: ProductFeatureType): string { + switch (value) { + case ProductFeatureType.OTHER : return 'OTHER'; + case ProductFeatureType.SHELL : return 'SHELL'; + case ProductFeatureType.EMAIL : return 'EMAIL'; + case ProductFeatureType.SHELL_SERVER : return 'SHELL_SERVER'; + case ProductFeatureType.EMAIL_SERVER : return 'EMAIL_SERVER'; + case ProductFeatureType.WEB_SERVER : return 'WEB_SERVER'; + case ProductFeatureType.PHYSICAL_PRODUCT : return 'PHYSICAL_PRODUCT'; + case ProductFeatureType.VIRTUAL_SERVER : return 'VIRTUAL_SERVER'; + case ProductFeatureType.SERVER : return 'SERVER'; + case ProductFeatureType.CABLE : return 'CABLE'; + case ProductFeatureType.DOMAIN : return 'DOMAIN'; + } + throw new TypeError(`Unsupported ProductFeatureType value: ${value}`); +} + +export function parseProductFeatureType (value: any): ProductFeatureType | undefined { + + switch (`${value}`.toUpperCase()) { + + case 'OTHER' : return ProductFeatureType.OTHER; + case 'SHELL' : return ProductFeatureType.SHELL; + case 'EMAIL' : return ProductFeatureType.EMAIL; + + case 'WEBSERVER' : + case 'WEB_SERVER' : + return ProductFeatureType.WEB_SERVER; + + case 'PHYSICAL' : + case 'PHYSICALPRODUCT' : + case 'PHYSICAL_PRODUCT' : + return ProductFeatureType.PHYSICAL_PRODUCT; + + case 'VPS' : + case 'VIRTUALSERVER' : + case 'VIRTUAL_SERVER' : + return ProductFeatureType.VIRTUAL_SERVER; + + case 'SERVER' : return ProductFeatureType.SERVER; + case 'CABLE' : return ProductFeatureType.CABLE; + case 'DOMAIN' : return ProductFeatureType.DOMAIN; + + default : + return undefined; + + } + +} + diff --git a/store/types/product/features/RaidType.ts b/store/types/product/features/RaidType.ts new file mode 100644 index 0000000..4b3ad8a --- /dev/null +++ b/store/types/product/features/RaidType.ts @@ -0,0 +1,6 @@ + +export enum RaidType { + + RAID_MIRROR = "RAID_MIRROR" + +} diff --git a/store/types/product/features/SelectedProductFeatureOptions.ts b/store/types/product/features/SelectedProductFeatureOptions.ts new file mode 100644 index 0000000..18274a4 --- /dev/null +++ b/store/types/product/features/SelectedProductFeatureOptions.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +export interface SelectedProductFeatureOptions { + readonly [key: string]: string; +} + diff --git a/store/types/product/features/VirtualizationType.ts b/store/types/product/features/VirtualizationType.ts new file mode 100644 index 0000000..805f694 --- /dev/null +++ b/store/types/product/features/VirtualizationType.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../../../types/Enum"; + +export enum VirtualizationType { + KVM = "KVM", + LXC = "LXC" +} + +export function isVirtualizationType (value: any): value is VirtualizationType { + switch (value) { + case VirtualizationType.KVM: + case VirtualizationType.LXC: + return true; + default: + return false; + } +} + +export function explainVirtualizationType (value: any): string { + return explainEnum("VirtualizationType", VirtualizationType, isVirtualizationType, value); +} + +export function stringifyVirtualizationType (value: VirtualizationType): string { + switch (value) { + case VirtualizationType.KVM : return 'KVM'; + case VirtualizationType.LXC : return 'LXC'; + } + throw new TypeError(`Unsupported VirtualizationType value: ${value}`); +} + +export function parseVirtualizationType (value: any): VirtualizationType | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toUpperCase()) { + case 'KVM' : return VirtualizationType.KVM; + case 'LXC' : return VirtualizationType.LXC; + default : return undefined; + } +} diff --git a/store/types/task/NewTaskDTO.ts b/store/types/task/NewTaskDTO.ts new file mode 100644 index 0000000..5f0aab2 --- /dev/null +++ b/store/types/task/NewTaskDTO.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainString, isString } from "../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainTaskType, isTaskType, TaskType } from "./TaskType"; +import { explainTaskState, isTaskState, TaskState } from "./TaskState"; +import { explainReadonlyJsonObject, isReadonlyJsonObject, ReadonlyJsonObject } from "../../../Json"; +import { explainBoolean, isBoolean } from "../../../types/Boolean"; + +export interface NewTaskDTO { + readonly parentId : string; + readonly clientId : string; + readonly invoiceId : string; + readonly startDate : string; + readonly finishedDate : string; + readonly deadline : string; + readonly assignee : string; + readonly type : TaskType; + readonly state : TaskState; + readonly options : ReadonlyJsonObject; + readonly onHold : boolean; +} + +export function createNewTaskDTO ( + parentId : string, + clientId : string, + invoiceId : string, + startDate : string, + finishedDate : string, + deadline : string, + assignee : string, + type : TaskType, + state : TaskState, + options : ReadonlyJsonObject, + onHold : boolean, +): NewTaskDTO { + return { + parentId, + clientId, + invoiceId, + startDate, + finishedDate, + deadline, + assignee, + type, + state, + options, + onHold, + }; +} + +export function isNewTaskDTO (value: any): value is NewTaskDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'parentId', + 'clientId', + 'invoiceId', + 'startDate', + 'finishedDate', + 'deadline', + 'assignee', + 'type', + 'state', + 'options', + 'onHold', + ]) + && isString(value?.parentId) + && isString(value?.clientId) + && isString(value?.invoiceId) + && isString(value?.startDate) + && isString(value?.finishedDate) + && isString(value?.deadline) + && isString(value?.assignee) + && isTaskType(value?.type) + && isTaskState(value?.state) + && isReadonlyJsonObject(value?.options) + && isBoolean(value?.onHold) + ); +} + +export function explainNewTaskDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'parentId', + 'clientId', + 'invoiceId', + 'startDate', + 'finishedDate', + 'deadline', + 'assignee', + 'type', + 'state', + 'options', + 'onHold', + ]) + , explainProperty("parentId", explainString(value?.parentId)) + , explainProperty("clientId", explainString(value?.clientId)) + , explainProperty("invoiceId", explainString(value?.invoiceId)) + , explainProperty("startDate", explainString(value?.startDate)) + , explainProperty("finishedDate", explainString(value?.finishedDate)) + , explainProperty("deadline", explainString(value?.deadline)) + , explainProperty("assignee", explainString(value?.assignee)) + , explainProperty("type", explainTaskType(value?.type)) + , explainProperty("state", explainTaskState(value?.state)) + , explainProperty("options", explainReadonlyJsonObject(value?.options)) + , explainProperty("onHold", explainBoolean(value?.onHold)) + ] + ); +} + +export function parseNewTaskDTO (value: any): NewTaskDTO | undefined { + if ( isNewTaskDTO(value) ) return value; + return undefined; +} diff --git a/store/types/task/TaskDTO.ts b/store/types/task/TaskDTO.ts new file mode 100644 index 0000000..e14030c --- /dev/null +++ b/store/types/task/TaskDTO.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "../../../types/String"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainTaskType, isTaskType, TaskType } from "./TaskType"; +import { explainTaskState, isTaskState, TaskState } from "./TaskState"; +import { explainReadonlyJsonObject, isReadonlyJsonObject, ReadonlyJsonObject } from "../../../Json"; +import { explainBoolean, isBoolean } from "../../../types/Boolean"; + +export interface TaskDTO { + readonly taskId : string; + readonly parentId ?: string | undefined; + readonly clientId ?: string | undefined; + readonly invoiceId ?: string | undefined; + readonly created : string; + readonly updated : string; + readonly startDate : string; + readonly finishedDate : string; + readonly deadline : string; + readonly assignee : string; + readonly type : TaskType; + readonly state : TaskState; + readonly options : ReadonlyJsonObject; + readonly onHold : boolean; +} + +export function createTaskDTO ( + taskId : string, + parentId : string | undefined, + clientId : string | undefined, + invoiceId : string | undefined, + created : string, + updated : string, + startDate : string, + finishedDate : string, + deadline : string, + assignee : string, + type : TaskType, + state : TaskState, + options : ReadonlyJsonObject, + onHold : boolean, +): TaskDTO { + return { + taskId, + parentId, + clientId, + invoiceId, + created, + updated, + startDate, + finishedDate, + deadline, + assignee, + type, + state, + options, + onHold, + }; +} + +export function isTaskDTO (value: any): value is TaskDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'taskId', + 'parentId', + 'clientId', + 'invoiceId', + 'created', + 'updated', + 'startDate', + 'finishedDate', + 'deadline', + 'assignee', + 'type', + 'state', + 'options', + 'onHold', + ]) + && isString(value?.taskId) + && isStringOrUndefined(value?.parentId) + && isStringOrUndefined(value?.clientId) + && isStringOrUndefined(value?.invoiceId) + && isString(value?.created) + && isString(value?.updated) + && isString(value?.startDate) + && isString(value?.finishedDate) + && isString(value?.deadline) + && isString(value?.assignee) + && isTaskType(value?.type) + && isTaskState(value?.state) + && isReadonlyJsonObject(value?.options) + && isBoolean(value?.onHold) + ); +} + +export function explainTaskDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'taskId', + 'parentId', + 'clientId', + 'invoiceId', + 'created', + 'updated', + 'startDate', + 'finishedDate', + 'deadline', + 'assignee', + 'type', + 'state', + 'options', + 'onHold', + ]) + , explainProperty("taskId", explainString(value?.taskId)) + , explainProperty("parentId", explainStringOrUndefined(value?.parentId)) + , explainProperty("clientId", explainStringOrUndefined(value?.clientId)) + , explainProperty("invoiceId", explainStringOrUndefined(value?.invoiceId)) + , explainProperty("created", explainString(value?.created)) + , explainProperty("updated", explainString(value?.updated)) + , explainProperty("startDate", explainString(value?.startDate)) + , explainProperty("finishedDate", explainString(value?.finishedDate)) + , explainProperty("deadline", explainString(value?.deadline)) + , explainProperty("assignee", explainString(value?.assignee)) + , explainProperty("type", explainTaskType(value?.type)) + , explainProperty("state", explainTaskState(value?.state)) + , explainProperty("options", explainReadonlyJsonObject(value?.options)) + , explainProperty("onHold", explainBoolean(value?.onHold)) + ] + ); +} + +export function parseTaskDTO (value: any): TaskDTO | undefined { + if ( isTaskDTO(value) ) return value; + return undefined; +} diff --git a/store/types/task/TaskListDTO.ts b/store/types/task/TaskListDTO.ts new file mode 100644 index 0000000..593e408 --- /dev/null +++ b/store/types/task/TaskListDTO.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2020-2022. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { TaskDTO, isTaskDTO } from "./TaskDTO"; +import { isArrayOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication + */ +export interface TaskListDTO { + readonly payload: readonly TaskDTO[]; +} + +export function createTaskListDTO (items: TaskDTO[]): TaskListDTO { + return { + payload: map(items, (item: TaskDTO): TaskDTO => item) + }; +} + +export function isTaskListDTO (value: any): value is TaskListDTO { + return ( + !!value + && isArrayOf(value?.payload, isTaskDTO) + ); +} + +export function parseTaskListDTO (value: any): TaskListDTO | undefined { + if ( isTaskListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/task/TaskState.ts b/store/types/task/TaskState.ts new file mode 100644 index 0000000..e298b49 --- /dev/null +++ b/store/types/task/TaskState.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../types/Enum"; +import { isUndefined } from "../../../types/undefined"; + +export enum TaskState { + NEW = "new", + IN_PROGRESS = "in_progress", + CLOSED = "closed", + FAILED = "failed", + CANCELLED = "cancelled", +} + +export function isTaskState (value: unknown) : value is TaskState { + return isEnum(TaskState, value); +} + +export function explainTaskState (value : unknown) : string { + return explainEnum("TaskState", TaskState, isTaskState, value); +} + +export function stringifyTaskState (value : TaskState) : string { + return stringifyEnum(TaskState, value); +} + +export function parseTaskState (value: any) : TaskState | undefined { + return parseEnum(TaskState, value) as TaskState | undefined; +} + +export function isTaskStateOrUndefined (value: unknown): value is TaskState | undefined { + return isUndefined(value) || isTaskState(value); +} + +export function explainTaskStateOrUndefined (value: unknown): string { + return isTaskStateOrUndefined(value) ? explainOk() : explainNot(explainOr(['TaskState', 'undefined'])); +} diff --git a/store/types/task/TaskType.ts b/store/types/task/TaskType.ts new file mode 100644 index 0000000..014511c --- /dev/null +++ b/store/types/task/TaskType.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../types/Enum"; +import { isUndefined } from "../../../types/undefined"; + +export enum TaskType { + PING = "fi.hg.ping", + VPS_RESTART = "fi.hg.vps.restart", + VPS_START = "fi.hg.vps.start", + VPS_STOP = "fi.hg.vps.stop", + VPS_DELETE = "fi.hg.vps.delete", + VPS_CREATE = "fi.hg.vps.create", +} + +export function isTaskType (value: unknown) : value is TaskType { + return isEnum(TaskType, value); +} + +export function explainTaskType (value : unknown) : string { + return explainEnum("TaskType", TaskType, isTaskType, value); +} + +export function stringifyTaskType (value : TaskType) : string { + return stringifyEnum(TaskType, value); +} + +export function parseTaskType (value: any) : TaskType | undefined { + return parseEnum(TaskType, value) as TaskType | undefined; +} + +export function isTaskTypeOrUndefined (value: unknown): value is TaskType | undefined { + return isUndefined(value) || isTaskType(value); +} + +export function explainTaskTypeOrUndefined (value: unknown): string { + return isTaskTypeOrUndefined(value) ? explainOk() : explainNot(explainOr(['TaskType', 'undefined'])); +} diff --git a/store/types/ticket/NewTicketCommentDTO.ts b/store/types/ticket/NewTicketCommentDTO.ts new file mode 100644 index 0000000..afac2f6 --- /dev/null +++ b/store/types/ticket/NewTicketCommentDTO.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isBoolean } from "../../../types/Boolean"; + +export interface NewTicketCommentDTO { + readonly ticketId ?: string; + readonly ticketUserId ?: string; + readonly date ?: string; + readonly subject ?: string; + readonly description ?: string; + readonly dataJson ?: string; + readonly isPrivate ?: boolean; + readonly onHold ?: boolean; + readonly isTerminated ?: boolean; + readonly isRead ?: boolean; +} + +export function createNewTicketCommentDTO ( + ticketId : string, + ticketUserId : string, + date : string, + subject : string, + description : string, + dataJson : string, + isPrivate : boolean, + onHold : boolean, + isTerminated : boolean, + isRead : boolean, +): NewTicketCommentDTO { + return { + ticketId, + ticketUserId, + date, + subject, + description, + dataJson, + isPrivate, + onHold, + isTerminated, + isRead + }; +} + +export function isNewTicketCommentDTO (value: any): value is NewTicketCommentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ticketId', + 'ticketUserId', + 'date', + 'subject', + 'description', + 'dataJson', + 'isPrivate', + 'onHold', + 'isTerminated', + 'isRead', + ]) + && isString(value?.ticketId) + && isString(value?.ticketUserId) + && isString(value?.date) + && isString(value?.subject) + && isString(value?.description) + && isString(value?.dataJson) + && isBoolean(value?.isPrivate) + && isBoolean(value?.onHold) + && isBoolean(value?.isTerminated) + && isBoolean(value?.isRead) + ); +} + +export function stringifyNewTicketCommentDTO (value: NewTicketCommentDTO): string { + return `NewTicketCommentDTO(${value})`; +} + +export function parseNewTicketCommentDTO (value: any): NewTicketCommentDTO | undefined { + if ( isNewTicketCommentDTO(value) ) return value; + return undefined; +} diff --git a/store/types/ticket/NewTicketCommentReadDTO.ts b/store/types/ticket/NewTicketCommentReadDTO.ts new file mode 100644 index 0000000..ea8eb62 --- /dev/null +++ b/store/types/ticket/NewTicketCommentReadDTO.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, isString } from "../../../types/String"; + +export interface NewTicketCommentReadDTO { + readonly ticketCommentId : string; + readonly ticketUserId : string; +} + +export function createTicketReadDTO ( + ticketCommentId : string, + ticketUserId : string, +) : NewTicketCommentReadDTO { + return { + ticketCommentId, + ticketUserId + }; +} + +export function isNewTicketCommentReadDTO (value: unknown) : value is NewTicketCommentReadDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ticketCommentId', + 'ticketUserId' + ]) + && isString(value?.ticketCommentId) + && isString(value?.ticketUserId) + ); +} + +export function explainNewTicketCommentReadDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'ticketCommentId', + 'ticketUserId', + ]) + , explainProperty("ticketCommentId", explainString(value?.ticketCommentId)) + , explainProperty("ticketUserId", explainString(value?.ticketUserId)) + ] + ); +} + +export function stringifyNewTicketCommentReadDTO (value : NewTicketCommentReadDTO) : string { + return `NewTicketCommentReadDTO(${value})`; +} + +export function parseNewTicketCommentReadDTO (value: unknown) : NewTicketCommentReadDTO | undefined { + if (isNewTicketCommentReadDTO(value)) return value; + return undefined; +} diff --git a/store/types/ticket/NewTicketDTO.ts b/store/types/ticket/NewTicketDTO.ts new file mode 100644 index 0000000..1869494 --- /dev/null +++ b/store/types/ticket/NewTicketDTO.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { isStringOrUndefined } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isStringArrayOrUndefined } from "../../../types/StringArray"; + +export interface NewTicketDTO { + readonly clientId ?: string; + readonly contactId ?: string; + readonly orderId ?: string; + readonly invoiceId ?: string; + readonly purchaseCompanyId ?: string; + readonly purchaseInvoiceId ?: string; + readonly inventoryItemId ?: string; + readonly date ?: string; + readonly dueDate ?: string; + readonly state ?: string; + readonly subject ?: string; + readonly description ?: string; + readonly internalNote ?: string; + readonly tags ?: readonly string[]; + readonly dataJson ?: string; + readonly onHold ?: boolean; + readonly isTerminated ?: boolean; +} + +export function createNewTicketDTO ( + clientId : string | undefined, + contactId : string | undefined, + orderId : string | undefined, + invoiceId : string | undefined, + purchaseCompanyId : string | undefined, + purchaseInvoiceId : string | undefined, + inventoryItemId : string | undefined, + date : string | undefined, + dueDate : string | undefined, + state : string | undefined, + subject : string | undefined, + description : string | undefined, + internalNote : string | undefined, + tags : readonly string[] | undefined, + dataJson : string | undefined, + onHold : boolean | undefined, + isTerminated : boolean | undefined, +): NewTicketDTO { + return { + clientId, + contactId, + orderId, + invoiceId, + purchaseCompanyId, + purchaseInvoiceId, + inventoryItemId, + date, + dueDate, + state, + subject, + description, + internalNote, + tags, + dataJson, + onHold, + isTerminated + }; +} + +export function isNewTicketDTO (value: any): value is NewTicketDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'clientId', + 'contactId', + 'orderId', + 'invoiceId', + 'purchaseCompanyId', + 'purchaseInvoiceId', + 'inventoryItemId', + 'date', + 'dueDate', + 'state', + 'subject', + 'description', + 'internalNote', + 'tags', + 'dataJson', + 'onHold', + 'isTerminated' + ]) + && isStringOrUndefined(value?.clientId) + && isStringOrUndefined(value?.contactId) + && isStringOrUndefined(value?.orderId) + && isStringOrUndefined(value?.invoiceId) + && isStringOrUndefined(value?.purchaseCompanyId) + && isStringOrUndefined(value?.purchaseInvoiceId) + && isStringOrUndefined(value?.inventoryItemId) + && isStringOrUndefined(value?.date) + && isStringOrUndefined(value?.dueDate) + && isStringOrUndefined(value?.state) + && isStringOrUndefined(value?.subject) + && isStringOrUndefined(value?.description) + && isStringOrUndefined(value?.internalNote) + && isStringArrayOrUndefined(value?.tags) + && isStringOrUndefined(value?.dataJson) + && isBooleanOrUndefined(value?.onHold) + && isBooleanOrUndefined(value?.isTerminated) + ); +} + +export function stringifyNewTicketDTO (value: NewTicketDTO): string { + return `NewTicketDTO(${value})`; +} + +export function parseNewTicketDTO (value: any): NewTicketDTO | undefined { + if ( isNewTicketDTO(value) ) return value; + return undefined; +} diff --git a/store/types/ticket/NewTicketMemberDTO.ts b/store/types/ticket/NewTicketMemberDTO.ts new file mode 100644 index 0000000..de949c1 --- /dev/null +++ b/store/types/ticket/NewTicketMemberDTO.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, isString } from "../../../types/String"; + +export interface NewTicketMemberDTO { + readonly ticketId : string; + readonly ticketUserId : string; + readonly isTerminated : boolean; +} + +export function createNewTicketMemberDTO ( + ticketId : string, + ticketUserId : string, + isTerminated : boolean, +) : NewTicketMemberDTO { + return { + ticketId, + ticketUserId, + isTerminated + }; +} + +export function isNewTicketMemberDTO (value: unknown) : value is NewTicketMemberDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ticketId', + 'ticketUserId', + 'isTerminated' + ]) + && isString(value?.ticketId) + && isString(value?.ticketUserId) + && isString(value?.isTerminated) + ); +} + +export function explainNewTicketMemberDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'ticketId', + 'ticketUserId', + 'isTerminated' + ]) + , explainProperty("ticketId", explainString(value?.ticketId)) + , explainProperty("ticketUserId", explainString(value?.ticketUserId)) + , explainProperty("isTerminated", explainString(value?.isTerminated)) + ] + ); +} + +export function stringifyNewTicketMemberDTO (value : NewTicketMemberDTO) : string { + return `NewTicketMemberDTO(${value})`; +} + +export function parseNewTicketMemberDTO (value: unknown) : NewTicketMemberDTO | undefined { + if (isNewTicketMemberDTO(value)) return value; + return undefined; +} diff --git a/store/types/ticket/NewTicketUserDTO.ts b/store/types/ticket/NewTicketUserDTO.ts new file mode 100644 index 0000000..4bc69a6 --- /dev/null +++ b/store/types/ticket/NewTicketUserDTO.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainStringOrUndefined, isStringOrUndefined } from "../../../types/String"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../../types/Boolean"; + +export interface NewTicketUserDTO { + readonly name ?: string; + readonly email ?: string; + readonly tel ?: string; + readonly onHold ?: boolean; + readonly dataJson ?: string; + readonly isTerminated ?: boolean; +} + +export function createNewTicketUserDTO ( + name ?: string | undefined, + email ?: string | undefined, + tel ?: string | undefined, + onHold ?: boolean | undefined, + dataJson ?: string | undefined, + isTerminated ?: boolean | undefined, +) : NewTicketUserDTO { + return { + name, + email, + tel, + onHold, + dataJson, + isTerminated + }; +} + +export function isNewTicketUserDTO (value: unknown) : value is NewTicketUserDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'name', + 'email', + 'tel', + 'onHold', + 'dataJson', + 'isTerminated' + ]) + && isStringOrUndefined(value?.name) + && isStringOrUndefined(value?.email) + && isStringOrUndefined(value?.tel) + && isBooleanOrUndefined(value?.onHold) + && isStringOrUndefined(value?.dataJson) + && isBooleanOrUndefined(value?.isTerminated) + ); +} + +export function explainNewTicketUserDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'name', + 'email', + 'tel', + 'onHold', + 'dataJson', + 'isTerminated' + ]) + , explainProperty("name", explainStringOrUndefined(value?.name)) + , explainProperty("email", explainStringOrUndefined(value?.email)) + , explainProperty("tel", explainStringOrUndefined(value?.tel)) + , explainProperty("onHold", explainBooleanOrUndefined(value?.onHold)) + , explainProperty("dataJson", explainStringOrUndefined(value?.dataJson)) + , explainProperty("isTerminated", explainBooleanOrUndefined(value?.isTerminated)) + ] + ); +} + +export function stringifyNewTicketUserDTO (value : NewTicketUserDTO) : string { + return `NewTicketUserDTO(${value})`; +} + +export function parseNewTicketUserDTO (value: unknown) : NewTicketUserDTO | undefined { + if (isNewTicketUserDTO(value)) return value; + return undefined; +} diff --git a/store/types/ticket/TicketCommentDTO.ts b/store/types/ticket/TicketCommentDTO.ts new file mode 100644 index 0000000..d12987c --- /dev/null +++ b/store/types/ticket/TicketCommentDTO.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isBoolean } from "../../../types/Boolean"; + +export interface TicketCommentDTO { + readonly ticketCommentId : string; + readonly ticketId : string; + readonly ticketUserId : string; + readonly updated : string; + readonly created : string; + readonly date : string; + readonly subject : string; + readonly description : string; + readonly dataJson : string; + readonly isPrivate : boolean; + readonly onHold : boolean; + readonly isTerminated : boolean; + readonly isRead : boolean; +} + +export function createTicketCommentDTO ( + ticketCommentId : string, + ticketId : string, + ticketUserId : string, + updated : string, + created : string, + date : string, + subject : string, + description : string, + dataJson : string, + isPrivate : boolean, + onHold : boolean, + isTerminated : boolean, + isRead : boolean, +): TicketCommentDTO { + return { + ticketCommentId, + ticketId, + ticketUserId, + updated, + created, + date, + subject, + description, + dataJson, + isPrivate, + onHold, + isTerminated, + isRead + }; +} + +export function isTicketCommentDTO (value: any): value is TicketCommentDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ticketCommentId', + 'ticketId', + 'ticketUserId', + 'updated', + 'created', + 'date', + 'subject', + 'description', + 'dataJson', + 'isPrivate', + 'onHold', + 'isTerminated', + 'isRead', + ]) + && isString(value?.ticketCommentId) + && isString(value?.ticketId) + && isString(value?.ticketUserId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.date) + && isString(value?.subject) + && isString(value?.description) + && isString(value?.dataJson) + && isBoolean(value?.isPrivate) + && isBoolean(value?.onHold) + && isBoolean(value?.isTerminated) + && isBoolean(value?.isRead) + ); +} + +export function stringifyTicketCommentDTO (value: TicketCommentDTO): string { + return `TicketCommentDTO(${value})`; +} + +export function parseTicketCommentDTO (value: any): TicketCommentDTO | undefined { + if ( isTicketCommentDTO(value) ) return value; + return undefined; +} diff --git a/store/types/ticket/TicketCommentReadDTO.ts b/store/types/ticket/TicketCommentReadDTO.ts new file mode 100644 index 0000000..6c498bf --- /dev/null +++ b/store/types/ticket/TicketCommentReadDTO.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, isString } from "../../../types/String"; + +export interface TicketCommentReadDTO { + readonly ticketCommentReadId : string; + readonly ticketCommentId : string; + readonly ticketUserId : string; + readonly updated : string; + readonly created : string; +} + +export function createTicketCommentReadDTO ( + ticketCommentReadId : string, + ticketCommentId : string, + ticketUserId : string, + updated : string, + created : string, +) : TicketCommentReadDTO { + return { + ticketCommentReadId, + ticketCommentId, + ticketUserId, + updated, + created + }; +} + +export function isTicketCommentReadDTO (value: unknown) : value is TicketCommentReadDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ticketCommentReadId', + 'ticketCommentId', + 'ticketUserId', + 'updated', + 'created' + ]) + && isString(value?.ticketCommentReadId) + && isString(value?.ticketCommentId) + && isString(value?.ticketUserId) + && isString(value?.updated) + && isString(value?.created) + ); +} + +export function explainTicketCommentReadDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'ticketCommentReadId', + 'ticketCommentId', + 'ticketUserId', + 'updated', + 'created' + ]) + , explainProperty("ticketCommentReadId", explainString(value?.ticketCommentReadId)) + , explainProperty("ticketCommentId", explainString(value?.ticketCommentId)) + , explainProperty("ticketUserId", explainString(value?.ticketUserId)) + , explainProperty("updated", explainString(value?.updated)) + , explainProperty("created", explainString(value?.created)) + ] + ); +} + +export function stringifyTicketCommentReadDTO (value : TicketCommentReadDTO) : string { + return `TicketReadDTO(${value})`; +} + +export function parseTicketCommentReadDTO (value: unknown) : TicketCommentReadDTO | undefined { + if (isTicketCommentReadDTO(value)) return value; + return undefined; +} diff --git a/store/types/ticket/TicketDTO.ts b/store/types/ticket/TicketDTO.ts new file mode 100644 index 0000000..daad2e3 --- /dev/null +++ b/store/types/ticket/TicketDTO.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isTicketCommentDTO, TicketCommentDTO } from "./TicketCommentDTO"; +import { isBooleanOrUndefined } from "../../../types/Boolean"; +import { isString, isStringOrUndefined } from "../../../types/String"; +import { isRegularObject } from "../../../types/RegularObject"; +import { hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { isArrayOfOrUndefined } from "../../../types/Array"; +import { isStringArrayOrUndefined } from "../../../types/StringArray"; +import { isTicketUserDTO, TicketUserDTO } from "./TicketUserDTO"; + +export interface TicketDTO { + readonly ticketId : string; + readonly clientId ?: string; + readonly contactId ?: string; + readonly orderId ?: string; + readonly invoiceId ?: string; + readonly purchaseCompanyId ?: string; + readonly purchaseInvoiceId ?: string; + readonly inventoryItemId ?: string; + readonly updated ?: string; + readonly created ?: string; + readonly date ?: string; + readonly dueDate ?: string; + readonly state ?: string; + readonly subject ?: string; + readonly description ?: string; + readonly internalNote ?: string; + readonly tags ?: readonly string[]; + readonly dataJson ?: string; + readonly onHold ?: boolean; + readonly isTerminated ?: boolean; + readonly comments ?: readonly TicketCommentDTO[]; + readonly members ?: readonly TicketUserDTO[]; +} + +export function createTicketDTO ( + ticketId : string, + clientId : string | undefined, + contactId : string | undefined, + orderId : string | undefined, + invoiceId : string | undefined, + purchaseCompanyId : string | undefined, + purchaseInvoiceId : string | undefined, + inventoryItemId : string | undefined, + updated : string | undefined, + created : string | undefined, + date : string | undefined, + dueDate : string | undefined, + state : string | undefined, + subject : string | undefined, + description : string | undefined, + internalNote : string | undefined, + tags : readonly string[] | undefined, + dataJson : string | undefined, + onHold : boolean | undefined, + isTerminated : boolean | undefined, + comments ?: readonly TicketCommentDTO[], + members ?: readonly TicketUserDTO[] +): TicketDTO { + return { + ticketId, + clientId, + contactId, + orderId, + invoiceId, + purchaseCompanyId, + purchaseInvoiceId, + inventoryItemId, + updated, + created, + date, + dueDate, + state, + subject, + description, + internalNote, + tags, + dataJson, + onHold, + isTerminated, + comments, + members + }; +} + +export function isTicketDTO (value: any): value is TicketDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'clientId', + 'contactId', + 'orderId', + 'invoiceId', + 'purchaseCompanyId', + 'purchaseInvoiceId', + 'inventoryItemId', + 'updated', + 'created', + 'date', + 'dueDate', + 'state', + 'subject', + 'description', + 'internalNote', + 'tags', + 'dataJson', + 'onHold', + 'isTerminated', + 'comments', + 'members', + ]) + && isString(value?.ticketId) + && isStringOrUndefined(value?.clientId) + && isStringOrUndefined(value?.contactId) + && isStringOrUndefined(value?.orderId) + && isStringOrUndefined(value?.invoiceId) + && isStringOrUndefined(value?.purchaseCompanyId) + && isStringOrUndefined(value?.purchaseInvoiceId) + && isStringOrUndefined(value?.inventoryItemId) + && isStringOrUndefined(value?.updated) + && isStringOrUndefined(value?.created) + && isStringOrUndefined(value?.date) + && isStringOrUndefined(value?.dueDate) + && isStringOrUndefined(value?.state) + && isStringOrUndefined(value?.subject) + && isStringOrUndefined(value?.description) + && isStringOrUndefined(value?.internalNote) + && isStringArrayOrUndefined(value?.tags) + && isStringOrUndefined(value?.dataJson) + && isBooleanOrUndefined(value?.onHold) + && isBooleanOrUndefined(value?.isTerminated) + && isArrayOfOrUndefined(value?.comments, isTicketCommentDTO) + && isArrayOfOrUndefined(value?.members, isTicketUserDTO) + ); +} + +export function stringifyTicketDTO (value: TicketDTO): string { + return `TicketDTO(${value})`; +} + +export function parseTicketDTO (value: any): TicketDTO | undefined { + if ( isTicketDTO(value) ) return value; + return undefined; +} diff --git a/store/types/ticket/TicketListDTO.ts b/store/types/ticket/TicketListDTO.ts new file mode 100644 index 0000000..f853c6b --- /dev/null +++ b/store/types/ticket/TicketListDTO.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { map } from "../../../functions/map"; +import { TicketDTO, isTicketDTO } from "./TicketDTO"; +import { isArrayOf } from "../../../types/Array"; + +/** + * The client object used in the REST API communication + */ +export interface TicketListDTO { + readonly payload: readonly TicketDTO[]; +} + +export function createTicketListDTO (items: TicketDTO[] | readonly TicketDTO[]): TicketListDTO { + return { + payload: map(items, (item: TicketDTO): TicketDTO => item) + }; +} + +export function isTicketListDTO (value: any): value is TicketListDTO { + return ( + !!value + && isArrayOf(value?.payload, isTicketDTO) + ); +} + +export function stringifyTicketListDTO (value: TicketListDTO): string { + if ( !isTicketListDTO(value) ) throw new TypeError(`Not TicketListDTO: ${value}`); + return `TicketListDTO(${value})`; +} + +export function parseTicketListDTO (value: any): TicketListDTO | undefined { + if ( isTicketListDTO(value) ) return value; + return undefined; +} diff --git a/store/types/ticket/TicketMemberDTO.ts b/store/types/ticket/TicketMemberDTO.ts new file mode 100644 index 0000000..463f8e5 --- /dev/null +++ b/store/types/ticket/TicketMemberDTO.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, isString } from "../../../types/String"; +import { explainBoolean, isBoolean } from "../../../types/Boolean"; + +export interface TicketMemberDTO { + readonly ticketMemberId : string; + readonly ticketId : string; + readonly ticketUserId : string; + readonly updated : string; + readonly created : string; + readonly isTerminated : boolean; +} + +export function createTicketMemberDTO ( + ticketMemberId : string, + ticketId : string, + ticketUserId : string, + updated : string, + created : string, + isTerminated : boolean, +) : TicketMemberDTO { + return { + ticketMemberId, + ticketId, + ticketUserId, + updated, + created, + isTerminated + }; +} + +export function isTicketMemberDTO (value: unknown) : value is TicketMemberDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ticketMemberId', + 'ticketId', + 'ticketUserId', + 'updated', + 'created', + 'isTerminated' + ]) + && isString(value?.ticketMemberId) + && isString(value?.ticketId) + && isString(value?.ticketUserId) + && isString(value?.updated) + && isString(value?.created) + && isBoolean(value?.isTerminated) + ); +} + +export function explainTicketMemberDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'ticketMemberId', + 'ticketId', + 'ticketUserId', + 'updated', + 'created', + 'isTerminated' + ]) + , explainProperty("ticketMemberId", explainString(value?.ticketMemberId)) + , explainProperty("ticketId", explainString(value?.ticketId)) + , explainProperty("ticketUserId", explainString(value?.ticketUserId)) + , explainProperty("updated", explainString(value?.updated)) + , explainProperty("created", explainString(value?.created)) + , explainProperty("isTerminated", explainBoolean(value?.isTerminated)) + ] + ); +} + +export function stringifyTicketMemberDTO (value : TicketMemberDTO) : string { + return `TicketMemberDTO(${value})`; +} + +export function parseTicketMemberDTO (value: unknown) : TicketMemberDTO | undefined { + if (isTicketMemberDTO(value)) return value; + return undefined; +} diff --git a/store/types/ticket/TicketUserDTO.ts b/store/types/ticket/TicketUserDTO.ts new file mode 100644 index 0000000..962e108 --- /dev/null +++ b/store/types/ticket/TicketUserDTO.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explain, explainProperty } from "../../../types/explain"; +import { explainString, isString } from "../../../types/String"; +import { explainBoolean, isBoolean } from "../../../types/Boolean"; + +export interface TicketUserDTO { + readonly ticketUserId : string; + readonly updated : string; + readonly created : string; + readonly name : string; + readonly email : string; + readonly tel : string; + readonly onHold : boolean; + readonly dataJson : string; + readonly isTerminated : boolean; +} + +export function createTicketUserDTO ( + ticketUserId : string, + updated : string, + created : string, + name : string, + email : string, + tel : string, + onHold : boolean, + dataJson : string, + isTerminated : boolean, +) : TicketUserDTO { + return { + ticketUserId, + updated, + created, + name, + email, + tel, + onHold, + dataJson, + isTerminated + }; +} + +export function isTicketUserDTO (value: unknown) : value is TicketUserDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'ticketUserId', + 'updated', + 'created', + 'name', + 'email', + 'tel', + 'onHold', + 'dataJson', + 'isTerminated' + ]) + && isString(value?.ticketUserId) + && isString(value?.updated) + && isString(value?.created) + && isString(value?.name) + && isString(value?.email) + && isString(value?.tel) + && isBoolean(value?.onHold) + && isString(value?.dataJson) + && isBoolean(value?.isTerminated) + ); +} + +export function explainTicketUserDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'ticketUserId', + 'updated', + 'created', + 'name', + 'email', + 'tel', + 'onHold', + 'dataJson', + 'isTerminated' + ]) + , explainProperty("ticketUserId", explainString(value?.ticketUserId)) + , explainProperty("updated", explainString(value?.updated)) + , explainProperty("created", explainString(value?.created)) + , explainProperty("name", explainString(value?.name)) + , explainProperty("email", explainString(value?.email)) + , explainProperty("tel", explainString(value?.tel)) + , explainProperty("onHold", explainBoolean(value?.onHold)) + , explainProperty("dataJson", explainString(value?.dataJson)) + , explainProperty("isTerminated", explainBoolean(value?.isTerminated)) + ] + ); +} + +export function stringifyTicketUserDTO (value : TicketUserDTO) : string { + return `TicketUserDTO(${value})`; +} + +export function parseTicketUserDTO (value: unknown) : TicketUserDTO | undefined { + if (isTicketUserDTO(value)) return value; + return undefined; +} diff --git a/store/utils/InvoiceUtils.test.ts b/store/utils/InvoiceUtils.test.ts new file mode 100644 index 0000000..d222d39 --- /dev/null +++ b/store/utils/InvoiceUtils.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { InvoiceDTO } from "../types/invoice/InvoiceDTO"; +import { InvoiceUtils } from "./InvoiceUtils"; +import { createInvoiceRowDTO, InvoiceRowDTO } from "../types/invoice/InvoiceRowDTO"; +import { CurrencyUtils } from "../../CurrencyUtils"; + +describe('InvoiceUtils', () => { + + describe('totalCentsIncludingVat', () => { + + it('should return 0 when no rows are provided', () => { + const invoice: InvoiceDTO = { + invoiceId: 'invoiceId', + clientId: 'clientId', + campaignId: 'campaignId', + groupId: 'groupId', + bankAccountId: 'bankAccountId', + wcOrderId: 'wcOrderId', + updated: '2023-07-09', + created: '2023-07-01', + date: '2023-07-09', + dueDate: '2023-08-09', + remindDate: '2023-08-01', + checkoutDate: '2023-08-05', + referenceNumber: 'referenceNumber', + internalNote: 'internalNote', + extraNotice: 'extraNotice', + webSecret: 'webSecret', + checkoutStamp: 'checkoutStamp', + onHold: false, + isReminded: false, + onCollection: false, + isTerminated: false, + buildDocuments: false, + sendDocuments: false, + dueDays: 30, + isPaid: false, + rows: [], + }; + + const result = InvoiceUtils.totalCentsIncludingVat(invoice); + expect(result).toEqual(0); + }); + + it('should return correct sum when single row is provided', () => { + + const row: InvoiceRowDTO = createInvoiceRowDTO( + 'INV1', + 'INV1', + 'PAY1', + 'CAM1', + 'CAM_PAY1', + 'PROD1', + '123456', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-12-31T00:00:00Z', + 'Some Product', + 'Internal note', + 2, + 100, + 0.24, + 0 + ); + + const invoice: InvoiceDTO = { + invoiceId: 'invoiceId', + clientId: 'clientId', + campaignId: 'campaignId', + groupId: 'groupId', + bankAccountId: 'bankAccountId', + wcOrderId: 'wcOrderId', + updated: '2023-07-09', + created: '2023-07-01', + date: '2023-07-09', + dueDate: '2023-08-09', + remindDate: '2023-08-01', + checkoutDate: '2023-08-05', + referenceNumber: 'referenceNumber', + internalNote: 'internalNote', + extraNotice: 'extraNotice', + webSecret: 'webSecret', + checkoutStamp: 'checkoutStamp', + onHold: false, + isReminded: false, + onCollection: false, + isTerminated: false, + buildDocuments: false, + sendDocuments: false, + dueDays: 30, + isPaid: false, + rows: [row], + }; + + const itemSum = CurrencyUtils.getCents( + CurrencyUtils.getSumWithVat( + row.price, + row.amount, + row.vatPercent, + row.discountPercent + ) + ); + + const result = InvoiceUtils.totalCentsIncludingVat(invoice); + expect(result).toEqual(itemSum); + }); + + it('should return correct sum when multiple rows are provided', () => { + const rows: InvoiceRowDTO[] = [ + createInvoiceRowDTO( + 'INV1', + 'INV1', + 'PAY1', + 'CAM1', + 'CAM_PAY1', + 'PROD1', + '123456', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-12-31T00:00:00Z', + 'Some Product', + 'Internal note', + 2, + 100, + 0.24, + 0 + ), + createInvoiceRowDTO( + 'INV1', + 'INV1', + 'PAY1', + 'CAM1', + 'CAM_PAY1', + 'PROD1', + '123456', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-01-01T00:00:00Z', + '2023-12-31T00:00:00Z', + 'Some Product', + 'Internal note', + 3, + 200, + 0.24, + 0 + ), + ]; + + const invoice: InvoiceDTO = { + invoiceId: 'invoiceId', + clientId: 'clientId', + campaignId: 'campaignId', + groupId: 'groupId', + bankAccountId: 'bankAccountId', + wcOrderId: 'wcOrderId', + updated: '2023-07-09', + created: '2023-07-01', + date: '2023-07-09', + dueDate: '2023-08-09', + remindDate: '2023-08-01', + checkoutDate: '2023-08-05', + referenceNumber: 'referenceNumber', + internalNote: 'internalNote', + extraNotice: 'extraNotice', + webSecret: 'webSecret', + checkoutStamp: 'checkoutStamp', + onHold: false, + isReminded: false, + onCollection: false, + isTerminated: false, + buildDocuments: false, + sendDocuments: false, + dueDays: 30, + isPaid: false, + rows: rows, + }; + + + const expectedSum = rows.reduce((prev, item) => { + const itemSum = CurrencyUtils.getCents( + CurrencyUtils.getSumWithVat( + item.price, + item.amount, + item.vatPercent, + item.discountPercent + ) + ); + return prev + itemSum; + }, 0); + + const result = InvoiceUtils.totalCentsIncludingVat(invoice); + expect(result).toEqual(expectedSum); + }); + + }); + +}); diff --git a/store/utils/InvoiceUtils.ts b/store/utils/InvoiceUtils.ts new file mode 100644 index 0000000..95aaeeb --- /dev/null +++ b/store/utils/InvoiceUtils.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { reduce } from "../../functions/reduce"; +import { InvoiceDTO } from "../types/invoice/InvoiceDTO"; +import { InvoiceRowDTO } from "../types/invoice/InvoiceRowDTO"; +import { CurrencyUtils } from "../../CurrencyUtils"; + +export class InvoiceUtils { + + /** + * Returns total sum as cents + * + * @param invoice + */ + public static totalCentsIncludingVat ( + invoice: InvoiceDTO + ) : number { + return reduce( + invoice?.rows ?? [], + (prev: number, item: InvoiceRowDTO): number => { + const itemSum = CurrencyUtils.getCents( + CurrencyUtils.getSumWithVat( + item.price, + item.amount, + item.vatPercent, + item.discountPercent, + ) + ); + if (prev === undefined) { + return itemSum; + } else { + return prev + itemSum; + } + }, + 0 + ); + } + +} diff --git a/store/utils/ProductUtils.test.ts b/store/utils/ProductUtils.test.ts new file mode 100644 index 0000000..4ec460b --- /dev/null +++ b/store/utils/ProductUtils.test.ts @@ -0,0 +1,274 @@ +import { createCompositeProduct, createProduct, Product } from "../types/product/Product"; +import { ProductType } from "../types/product/ProductType"; +import { createProductFeature } from "../types/product/features/ProductFeature"; +import { ProductFeatureCategory } from "../types/product/features/ProductFeatureCategory"; +import { ProductFeatureId } from "../types/product/features/ProductFeatureId"; +import { createProductPrice } from "../types/product/ProductPrice"; +import { ProductPriceType } from "../types/product/ProductPriceType"; +import { ProductUtils } from "./ProductUtils"; +import { LogLevel } from "../../types/LogLevel"; +import { createCompositeProductSelection } from "../types/product/CompositeProductSelection"; +import { CompositeProductOption, createCompositeProductOption } from "../types/product/CompositeProductOption"; + +ProductUtils.setLogLevel(LogLevel.NONE); + +describe('ProductUtils', () => { + + describe('#calculateCompositeProductFromOptions', () => { + + it('can composite non-composable product', () => { + + const item : Product = createProduct( + 'shell-1', + ProductType.SHELL, + 'Shell account', + 'Awesome shell account for irc', + [ + createProductFeature( + ProductFeatureId.DISK_SIZE, + ProductFeatureCategory.DISK, + 10 + ) + ], + [ + createProductPrice( + 5, + 0.24, + ProductPriceType.YEARLY + ) + ] + + ); + + expect( ProductUtils.calculateCompositeProductFromOptions(item, {}, []) ).toStrictEqual(item); + + }); + + it.skip('can composite composable product', () => { + + const shellId = 'shell-1'; + const extraDiskId = 'shell-disk-1'; + + const shellProduct1 : Product = createProduct( + shellId, + ProductType.SHELL, + 'Shell account', + 'Awesome shell account for irc', + [ + createProductFeature( + ProductFeatureId.DISK_SIZE, + ProductFeatureCategory.DISK, + 10 + ) + ], + [ + createProductPrice( + 5, + 0.24, + ProductPriceType.YEARLY + ) + ] + ); + + const extraDiskProduct : Product = createProduct( + extraDiskId, + ProductType.SHELL, + 'Disk space for shell account', + 'Get more disk space for your awesome shell', + [ + createProductFeature( + ProductFeatureId.DISK_SIZE, + ProductFeatureCategory.DISK, + 10 + ) + ], + [ + createProductPrice( + 10, + 0.24, + ProductPriceType.YEARLY + ) + ] + ); + + const item : Product = createCompositeProduct( + 'my-shell-1', + ProductType.SHELL, + 'Shell account with your choices', + 'Awesome shell account for irc -- with your options!', + [ + createCompositeProductSelection( + ProductFeatureId.DISK_SIZE, + 'Disk space', + 'Do you want more disk space?', + [ + createCompositeProductOption(10, [shellId]), + createCompositeProductOption(20, [shellId, extraDiskId]), + createCompositeProductOption(30, [shellId, [2, extraDiskId]]), + createCompositeProductOption(40, [shellId, [3, extraDiskId]]) + ], + 10 + ) + ] + ); + + const preferredOptions = { + [ProductFeatureId.DISK_SIZE]: 20 + }; + + const compositeProduct = ProductUtils.calculateCompositeProductFromOptions( + item, + preferredOptions, + [ + shellProduct1, + extraDiskProduct + ] + ); + + expect( compositeProduct ).not.toStrictEqual(item); + expect( compositeProduct?.prices?.length ).toStrictEqual(1); + expect( compositeProduct?.prices[0]?.sum ).toStrictEqual(15); + expect( compositeProduct?.prices[0]?.type ).toStrictEqual(ProductPriceType.YEARLY); + expect( compositeProduct?.prices[0]?.vatPercent ).toStrictEqual(0.24); + expect( compositeProduct?.features?.length ).toStrictEqual(1); + expect( compositeProduct?.features[0]?.id ).toStrictEqual(ProductFeatureId.DISK_SIZE); + expect( compositeProduct?.features[0]?.value ).toStrictEqual(20); + + }); + + }); + + describe('#sortCompositeProductOptionsByNumericValue', () => { + + it('returns negative for already sorted input', () => { + expect( + ProductUtils.sortCompositeProductOptionsByNumericValue( + createCompositeProductOption(10, []), + createCompositeProductOption(20, []) + ) + ).toStrictEqual(-1); + }); + + it('returns positive for not sorted input', () => { + expect( + ProductUtils.sortCompositeProductOptionsByNumericValue( + createCompositeProductOption(20, []), + createCompositeProductOption(10, []) + ) + ).toStrictEqual(1); + }); + + it('returns equal for equal input', () => { + expect( + ProductUtils.sortCompositeProductOptionsByNumericValue( + createCompositeProductOption(10, []), + createCompositeProductOption(10, []) + ) + ).toStrictEqual(0); + }); + + it('can sort an array', () => { + let list = [ + createCompositeProductOption(20, []), + createCompositeProductOption(10, []), + createCompositeProductOption(10, []), + createCompositeProductOption(5, []), + createCompositeProductOption(30, []) + ]; + list.sort(ProductUtils.sortCompositeProductOptionsByNumericValue); + expect( list[0].value ).toStrictEqual(5); + expect( list[1].value ).toStrictEqual(10); + expect( list[2].value ).toStrictEqual(10); + expect( list[3].value ).toStrictEqual(20); + expect( list[4].value ).toStrictEqual(30); + }); + + }); + + describe('#sortCompositeProductOptionsByNumericValue', () => { + + it('can find exact matching product option', () => { + + let list = [ + createCompositeProductOption(20, ['a']), + createCompositeProductOption(10, ['b']), + createCompositeProductOption(10, ['c']), + createCompositeProductOption(5, ['d']), + createCompositeProductOption(30, ['e']) + ]; + + const option : CompositeProductOption | undefined = ProductUtils.getBestMatchingNumericCompositeProductOption( + 5, + list + ); + + expect( option ).not.toBeUndefined(); + expect( option?.value ).toStrictEqual(5); + expect( option?.products ).toStrictEqual(['d']); + + }); + + it('can find best matching product option', () => { + + let list = [ + createCompositeProductOption(20, ['a']), + createCompositeProductOption(10, ['b']), + createCompositeProductOption(10, ['c']), + createCompositeProductOption(5, ['d']), + createCompositeProductOption(30, ['e']) + ]; + + const option : CompositeProductOption | undefined = ProductUtils.getBestMatchingNumericCompositeProductOption( + 15, + list + ); + + expect( option ).not.toBeUndefined(); + expect( option?.value ).toStrictEqual(20); + expect( option?.products ).toStrictEqual(['a']); + + }); + + it('can find best matching product option from multiple options (first)', () => { + + let list = [ + createCompositeProductOption(20, ['a']), + createCompositeProductOption(10, ['b']), + createCompositeProductOption(10, ['c']), + createCompositeProductOption(5, ['d']), + createCompositeProductOption(30, ['e']) + ]; + + const option : CompositeProductOption | undefined = ProductUtils.getBestMatchingNumericCompositeProductOption( + 10, + list + ); + + expect( option ).not.toBeUndefined(); + expect( option?.value ).toStrictEqual(10); + expect( option?.products ).toStrictEqual(['b']); + + }); + + it('can returns undefined if no match can be found', () => { + + let list = [ + createCompositeProductOption(20, ['a']), + createCompositeProductOption(10, ['b']), + createCompositeProductOption(10, ['c']), + createCompositeProductOption(5, ['d']), + createCompositeProductOption(30, ['e']) + ]; + + const option : CompositeProductOption | undefined = ProductUtils.getBestMatchingNumericCompositeProductOption( + 100, + list + ); + + expect( option ).toBeUndefined(); + + }); + + }); + +}); diff --git a/store/utils/ProductUtils.ts b/store/utils/ProductUtils.ts new file mode 100644 index 0000000..6adb22d --- /dev/null +++ b/store/utils/ProductUtils.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ProductTableItemDataModel } from "../types/product/ProductTableItemDataModel"; +import { filter } from "../../functions/filter"; +import { find } from "../../functions/find"; +import { has } from "../../functions/has"; +import { map } from "../../functions/map"; +import { reduce } from "../../functions/reduce"; +import { uniq } from "../../functions/uniq"; +import { ProductFeatureCategory } from "../types/product/features/ProductFeatureCategory"; +import { ProductFeatureId } from "../types/product/features/ProductFeatureId"; +import { Product } from "../types/product/Product"; +import { ProductTableItemModel } from "../types/product/ProductTableItemModel"; +import { ProductPriceType } from "../types/product/ProductPriceType"; +import { ProductPrice } from "../types/product/ProductPrice"; +import { ProductFeatureCategoryMappingType } from "../types/product/features/ProductFeatureCategoryMappingType"; +import { getProductFeatureCategoryTitleTranslationToken, getProductFeatureTitleTranslationToken } from "../constants/storeTranslation"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { CompositeProductSelection } from "../types/product/CompositeProductSelection"; +import { CompositeProductOption } from "../types/product/CompositeProductOption"; +import { isNumber } from "../../types/Number"; + +const LOG = LogService.createLogger('ProductUtils'); + +export class ProductUtils { + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + public static createProductTableItemsList ( + title: string, + description : string, + selectedPriceType : ProductPriceType, + productList: Product[] + ) : ProductTableItemModel[] { + return [ + { + id: 0, + title: title, + description: description, + gb: 0, + buttonTo: undefined, + price: 0, + priceType: selectedPriceType, + priceVatPercent: 0.24, + priceTypeOptions: [selectedPriceType], + isButton: false, + priceModel: undefined, + productModel: undefined + }, + ...map( + filter(productList, (p) : boolean => { + return map(p?.prices ?? [], pp => pp?.type).includes(selectedPriceType); + }), + (item: Product, index: number): ProductTableItemModel => { + const price : ProductPrice | undefined = find(item?.prices, pp => pp?.type === selectedPriceType); + if (!price) throw new TypeError(`Price was not found for ${selectedPriceType}. This should not happen.`); + return { + id: index + 1, + title: item.title, + description: item.summary, + buttonTo: price.buyUrl, + gb: 0, + price: price.sum, + priceType: price.type, + priceVatPercent: price.vatPercent, + priceTypeOptions: map(item.prices, pp => pp.type), + isButton: true, + priceModel: price, + productModel: item + } + } + ) + ]; + } + + public static createProductTableItemDataModelList ( + categoryList : ProductFeatureCategory[], + featureIdMap : ProductFeatureCategoryMappingType, + productList : Product[] + ) : ProductTableItemDataModel[] { + return map( + categoryList, + (categoryId: ProductFeatureCategory, index: number) : ProductTableItemDataModel => { + const featureIdList : ProductFeatureId[] = has(featureIdMap, categoryId) ? featureIdMap[categoryId] : []; + return { + id: index, + mainTitle: getProductFeatureCategoryTitleTranslationToken(categoryId), + title: map(featureIdList, (id: ProductFeatureId) => getProductFeatureTitleTranslationToken(id)), + product: map( + productList, + (item: Product) : readonly any[] => { + return map(featureIdList, (featureId: ProductFeatureId) => { + return find(item.features, (f: any) => f?.id === featureId) ?? ''; + }); + } + ) + }; + } + ); + } + + public static createUniquePriceTypeList ( + list: readonly ProductTableItemModel[] + ) : ProductPriceType[] { + return reduce( + list, + (a: ProductPriceType[], item: ProductTableItemModel) => uniq([ ...a, ...item.priceTypeOptions ]), + [] + ); + } + + /** + * This will calculate the best product combination from provided + * preferred options. + * + * @param model The composite product model + * @param options Preferred options to use when calculating best combination + * @param products All the available products to use to combine the derived product + */ + public static calculateCompositeProductFromOptions ( + model : Product, + // @ts-ignore + options : {readonly [key: string]: string|number|boolean}, + // @ts-ignore + products : readonly Product[] + ) : Product { + + const compositeSelections : readonly CompositeProductSelection[] | undefined = model?.composite; + + // Check if this is a composite product + if (!compositeSelections) { + LOG.warn(`Warning! This model doesn't seem to be a composite product. Passing the product on without any changes.`); + return model; + } + + // let enabledProductIds : ProductIdListWithAmount = []; + // + // const matchingProductLists : readonly ProductIdListWithAmount[] = map( + // compositeSelections, + // (item: CompositeProductSelection) : ProductIdListWithAmount => { + // const featureId = item.featureId; + // const featureOptions : readonly CompositeProductOption[] = item.options; + // const preferredValue : string|number|boolean | undefined = has(options, featureId) ? options[featureId] : undefined; + // if (!isNumber(preferredValue)) { + // LOG.warn('Warning! calculateCompositeProductFromOptions: Only number values implemented. Ignored selection.'); + // return []; + // } + // const option = ProductUtils.getBestMatchingNumericCompositeProductOption( + // preferredValue, + // featureOptions + // ); + // if (!option) { + // LOG.warn('Warning! calculateCompositeProductFromOptions: No matching options found. Ignored selection'); + // return []; + // } + // return option.products; + // } + // ); + + return model; + } + + public static getBestMatchingNumericCompositeProductOption ( + preferredValue: number, + list: readonly CompositeProductOption[] + ) : CompositeProductOption | undefined { + let suitableOptions : CompositeProductOption[] = filter( + list, + (option: CompositeProductOption) => isNumber(option.value) && preferredValue <= option.value + ); + if (suitableOptions.length === 0) return undefined; + suitableOptions.sort(ProductUtils.sortCompositeProductOptionsByNumericValue); + return suitableOptions[0]; + } + + public static sortCompositeProductOptionsByNumericValue (a: CompositeProductOption, b: CompositeProductOption) : number { + const aNum = isNumber(a.value) ? a.value : -1; + const bNum = isNumber(b.value) ? b.value : -1; + if (aNum === bNum) return 0; + return aNum < bNum ? -1 : 1; + } + +} diff --git a/store/utils/ShoppingCartUtils.ts b/store/utils/ShoppingCartUtils.ts new file mode 100644 index 0000000..566bab6 --- /dev/null +++ b/store/utils/ShoppingCartUtils.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { Product } from "../types/product/Product"; +import { createShoppingCartItem, ShoppingCartItem } from "../types/cart/ShoppingCartItem"; +import { filter } from "../../functions/filter"; +import { find } from "../../functions/find"; +import { map } from "../../functions/map"; +import { reduce } from "../../functions/reduce"; +import { ProductPrice } from "../types/product/ProductPrice"; +import { createShoppingCart, ShoppingCart } from "../types/cart/ShoppingCart"; +import { CurrencyUtils } from "../../CurrencyUtils"; +import { moment } from "../../modules/moment"; + +export class ShoppingCartUtils { + + public static createCartItemId ( + product: Product, + price: ProductPrice + ): string { + return `${product.id}-${price.type}`; + } + + public static findCartItemById ( + items: readonly ShoppingCartItem[], + itemId: string + ): ShoppingCartItem | undefined { + return find( + items, + (item: ShoppingCartItem): boolean => item.id === itemId + ); + } + + public static findCartItemByProductId ( + items: readonly ShoppingCartItem[], + productId: string + ): ShoppingCartItem | undefined { + return find( + items, + (item: ShoppingCartItem): boolean => item.product.id === productId + ); + } + + public static findCartItemByProduct ( + items: readonly ShoppingCartItem[], + product: Product + ): ShoppingCartItem | undefined { + return ShoppingCartUtils.findCartItemByProductId(items, product.id); + } + + public static addItemToCart ( + cart: ShoppingCart, + product: Product, + price: ProductPrice, + amount: number = 1 + ): ShoppingCart { + + const itemId = ShoppingCartUtils.createCartItemId(product, price); + const cartItem: ShoppingCartItem | undefined = ShoppingCartUtils.findCartItemById(cart.items, itemId); + + if ( cartItem === undefined ) { + return createShoppingCart( + [ ...cart.items, createShoppingCartItem(itemId, 1, price, product) ] + ); + } + + return createShoppingCart( + map( + cart.items, + (i: ShoppingCartItem): ShoppingCartItem => { + if ( i.id === cartItem.id ) { + return createShoppingCartItem(i.id, i.amount + amount, price, product); + } else { + return i; + } + } + ) + ); + + } + + public static removeItemFromCart ( + cart: ShoppingCart, + product: Product, + price: ProductPrice, + amount: number = 1 + ): ShoppingCart { + const itemId = ShoppingCartUtils.createCartItemId(product, price); + const cartItem: ShoppingCartItem | undefined = ShoppingCartUtils.findCartItemById(cart.items, itemId); + if ( cartItem === undefined ) return cart; + return createShoppingCart( + filter( + map( + cart.items, + (i: ShoppingCartItem): ShoppingCartItem => { + if ( i.id === cartItem.id ) { + const newAmount = i.amount - amount; + return createShoppingCartItem(i.id, newAmount > 0 ? newAmount : 0, price, product); + } else { + return i; + } + } + ), + (i: ShoppingCartItem): boolean => i.amount > 0 + ) + ); + } + + public static getItemSum ( + item: ShoppingCartItem + ): number { + return CurrencyUtils.getSum(this.getSumFromPrice(item.price), item.amount); + } + + public static getItemVat ( + item: ShoppingCartItem + ): number { + return ShoppingCartUtils.getItemSum(item) - ShoppingCartUtils.getItemSumWithoutVat(item); + } + + public static getItemSumWithoutVat ( + item: ShoppingCartItem + ): number { + return CurrencyUtils.getVatlessSum(ShoppingCartUtils.getItemSum(item), item.price.vatPercent); + } + + public static getTotalSum ( + items: readonly ShoppingCartItem[] + ): number { + return reduce( + items, + (prev: number, item: ShoppingCartItem) : number => { + return prev + ShoppingCartUtils.getItemSum(item); + }, + 0 + ); + } + + public static getTotalSumWithoutVat ( + items: readonly ShoppingCartItem[] + ): number { + return reduce( + items, + (prev: number, item: ShoppingCartItem) : number => { + return prev + ShoppingCartUtils.getItemSumWithoutVat(item); + }, + 0 + ); + } + + public static getTotalVat ( + items: readonly ShoppingCartItem[] + ): number { + return reduce( + items, + (prev: number, item: ShoppingCartItem) : number => { + return prev + ShoppingCartUtils.getItemVat(item); + }, + 0 + ); + } + + public static getVatlessSumFromPrice ( + price: ProductPrice + ) : number { + return CurrencyUtils.getVatlessSum(this.getSumFromPrice(price), price.vatPercent); + } + + public static getSumFromPrice ( + price: ProductPrice + ) : number { + return CurrencyUtils.getSumWithDiscount(price.sum, this.getDiscountPercent(price)); + } + + public static getDiscountPercent ( + price: ProductPrice + ) : number | undefined { + const discountFrom : string | undefined = price?.discountFrom ?? (new Date()).toISOString(); + const discountTo : string | undefined = price?.discountTo; + const discountActive : boolean = discountTo ? moment().isBetween(moment(discountFrom), moment(discountTo)) : moment().isAfter(moment(discountFrom)); + const discountPercent : number | undefined = discountActive ? price?.discountPercent : undefined; + return discountPercent; + } + + public static getDiscountTo ( + price: ProductPrice, + format: string = 'DD.MM.YYYY HH:mm' + ) : string { + const discountPercent = this.getDiscountPercent(price); + return discountPercent && price?.discountTo ? moment(price?.discountTo).format(format) : ''; + } + +} diff --git a/style/README.md b/style/README.md new file mode 100644 index 0000000..fb8770e --- /dev/null +++ b/style/README.md @@ -0,0 +1,6 @@ +# Style system + +### Concepts + + * `*Any*StyleLayout` contains mappings to other items in the `StyleLayout` + * `*Any*Style` is a compiled version of `*Any*StyleLayout`, e.g. mappings removed. diff --git a/style/StyleLayout.ts b/style/StyleLayout.ts new file mode 100644 index 0000000..c718e49 --- /dev/null +++ b/style/StyleLayout.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { Color, isColor } from "./types/Color"; +import { Font, isFont } from "./types/Font"; +import { ColorScheme, isColorScheme } from "./types/ColorScheme"; +import { ComponentStyleLayout, isComponentStyleLayout } from "./layout/ComponentStyleLayout"; +import { isSize, Size } from "./types/Size"; +import { isString } from "../types/String"; +import { isRegularObject } from "../types/RegularObject"; +import { isObjectOf } from "../types/Object"; +import { hasNoOtherKeys } from "../types/OtherKeys"; + +export interface ColorMapping { + readonly [key: string]: Color; +} + +export function isColorMapping (value : any) : value is ColorMapping { + return isObjectOf(value, isString, isColor); +} + +export interface FontMapping { + readonly [key: string]: Font; +} + +export function isFontMapping (value : any) : value is FontMapping { + return isObjectOf(value, isString, isFont); +} + +export interface SizeMapping { + readonly [key: string]: Size; +} + +export function isSizeMapping (value : any) : value is SizeMapping { + return isObjectOf(value, isString, isSize); +} + +export interface ComponentStyleLayoutMapping { + readonly [key: string]: ComponentStyleLayout; +} + +export function isComponentStyleLayoutMapping (value : any) : value is ComponentStyleLayoutMapping { + return isObjectOf(value, isString, isComponentStyleLayout); +} + +export interface StyleLayout { + readonly colorScheme : ColorScheme; + readonly colors : ColorMapping; + readonly fonts : FontMapping; + readonly sizes : SizeMapping; + readonly components : ComponentStyleLayoutMapping; +} + +export function createStyleLayout ( + colorScheme : ColorScheme, + colors : ColorMapping, + fonts : FontMapping, + sizes : SizeMapping, + components : ComponentStyleLayoutMapping +): StyleLayout { + return { + colorScheme, + colors, + fonts, + sizes, + components + }; +} + +export function isStyleLayout (value: any): value is StyleLayout { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'colorScheme', + 'colors', + 'fonts', + 'components' + ]) + && isColorMapping(value?.colors) + && isFontMapping(value?.fonts) + && isComponentStyleLayoutMapping(value?.components) + && isColorScheme(value?.colorScheme) + ); +} + +export function stringifyStyleLayout (value: StyleLayout): string { + return `StyleLayout(${value})`; +} + +export function parseStyleLayout (value: any): StyleLayout | undefined { + if ( isStyleLayout(value) ) return value; + return undefined; +} diff --git a/style/Styles.ts b/style/Styles.ts new file mode 100644 index 0000000..8b17dbf --- /dev/null +++ b/style/Styles.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ComponentStyle, isComponentStyle } from "./compiled/ComponentStyle"; +import { ComponentStyleLayoutMapping } from "./StyleLayout"; +import { isString } from "../types/String"; +import { isObjectOf } from "../types/Object"; + +export interface Styles { + readonly [key: string]: ComponentStyle; +} + +export function createStyles (): Styles { + return {}; +} + +export function isStyles (value : any) : value is ComponentStyleLayoutMapping { + return isObjectOf(value, isString, isComponentStyle); +} diff --git a/style/compiled/BackgroundStyle.ts b/style/compiled/BackgroundStyle.ts new file mode 100644 index 0000000..1c28a7e --- /dev/null +++ b/style/compiled/BackgroundStyle.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { Color, isColor } from "../types/Color"; +import { isUndefined } from "../../types/undefined"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface BackgroundStyle { + readonly color ?: Color; +} + +export function createBackgroundStyle ( + color ?: Color +): BackgroundStyle { + return { + color + }; +} + +export function isBackgroundStyle (value: any): value is BackgroundStyle { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'color' + ]) + && ( isUndefined(value?.color) || isColor(value?.color) ) + ); +} + +export function stringifyBackgroundStyle (value: BackgroundStyle): string { + return `BackgroundStyle(${value})`; +} + +export function parseBackgroundStyle (value: any): BackgroundStyle | undefined { + if ( isBackgroundStyle(value) ) return value; + return undefined; +} diff --git a/style/compiled/BorderStyle.ts b/style/compiled/BorderStyle.ts new file mode 100644 index 0000000..3065ded --- /dev/null +++ b/style/compiled/BorderStyle.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { BorderType, isBorderType } from "../types/BorderType"; +import { Size, isSize } from "../types/Size"; +import { Color, isColor } from "../types/Color"; +import { isUndefined } from "../../types/undefined"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface BorderStyle { + readonly size ?: Size; + readonly type ?: BorderType; + readonly radius ?: Size; + readonly color ?: Color; +} + +export function createBorderStyle ( + size ?: Size, + color ?: Color, + type ?: BorderType, + radius ?: Size, +): BorderStyle { + return { + size, + color, + type, + radius + }; +} + +export function isBorderStyle (value: any): value is BorderStyle { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'size', + 'type', + 'radius', + 'color' + ]) + && ( isUndefined(value?.size) || isSize(value?.size) ) + && ( isUndefined(value?.type) || isBorderType(value?.type) ) + && ( isUndefined(value?.radius) || isSize(value?.radius) ) + && ( isUndefined(value?.color) || isColor(value?.color) ) + ); +} + +export function stringifyBorderStyle (value: BorderStyle): string { + return `BorderStyle(${value})`; +} + +export function parseBorderStyle (value: any): BorderStyle | undefined { + if ( isBorderStyle(value) ) return value; + return undefined; +} diff --git a/style/compiled/ComponentStyle.ts b/style/compiled/ComponentStyle.ts new file mode 100644 index 0000000..6a99005 --- /dev/null +++ b/style/compiled/ComponentStyle.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { TextStyle, isTextStyle } from "./TextStyle"; +import { BorderStyle, isBorderStyle } from "./BorderStyle"; +import { BackgroundStyle, isBackgroundStyle } from "./BackgroundStyle"; +import { Size, isSize } from "../types/Size"; +import { isUndefined } from "../../types/undefined"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface ComponentStyle { + readonly text ?: TextStyle; + readonly border ?: BorderStyle; + readonly background ?: BackgroundStyle; + readonly padding ?: Size; +} + +export function createComponentStyle ( + text ?: TextStyle, + border ?: BorderStyle, + background ?: BackgroundStyle, + padding ?: Size +): ComponentStyle { + return { + background, + padding, + text, + border + }; +} + +export function isComponentStyle (value: any): value is ComponentStyle { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'background', + 'padding', + 'text', + 'border' + ]) + && ( isUndefined(value?.background) || isBackgroundStyle(value?.background) ) + && ( isUndefined(value?.text) || isTextStyle(value?.text) ) + && ( isUndefined(value?.border) || isBorderStyle(value?.border) ) + && ( isUndefined(value?.padding) || isSize(value?.padding) ) + ); +} + +export function stringifyComponentStyle (value: ComponentStyle): string { + return `ComponentStyle(${value})`; +} + +export function parseComponentStyle (value: any): ComponentStyle | undefined { + if ( isComponentStyle(value) ) return value; + return undefined; +} diff --git a/style/compiled/TextStyle.ts b/style/compiled/TextStyle.ts new file mode 100644 index 0000000..24e1f79 --- /dev/null +++ b/style/compiled/TextStyle.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { Font, isFont } from "../types/Font"; +import { Color, isColor } from "../types/Color"; +import { Size, isSize } from "../types/Size"; +import { isUndefined } from "../../types/undefined"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface TextStyle { + readonly font ?: Font; + readonly color ?: Color; + readonly size ?: Size; +} + +export function createTextStyle ( + font ?: Font, + color ?: Color, + size ?: Size +): TextStyle { + return { + font, + color, + size + }; +} + +export function isTextStyle (value: any): value is TextStyle { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'font', + 'color', + 'size' + ]) + && (isUndefined(value?.font) || isFont(value?.font)) + && (isUndefined(value?.color) || isColor(value?.color)) + && (isUndefined(value?.size) || isSize(value?.size)) + ); +} + +export function stringifyTextStyle (value: TextStyle): string { + return `ComponentTextStyle(${value})`; +} + +export function parseTextStyle (value: any): TextStyle | undefined { + if ( isTextStyle(value) ) return value; + return undefined; +} diff --git a/style/layout/BackgroundStyleLayout.ts b/style/layout/BackgroundStyleLayout.ts new file mode 100644 index 0000000..f27a4b8 --- /dev/null +++ b/style/layout/BackgroundStyleLayout.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface BackgroundStyleLayout { + readonly color : string; +} + +export function createBackgroundStyleLayout ( + color: string +): BackgroundStyleLayout { + return { + color + }; +} + +export function isBackgroundStyleLayout (value: any): value is BackgroundStyleLayout { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'color' + ]) + && isString(value?.color) + ); +} + +export function stringifyBackgroundStyleLayout (value: BackgroundStyleLayout): string { + return `BackgroundStyle(${value})`; +} + +export function parseBackgroundStyleLayout (value: any): BackgroundStyleLayout | undefined { + if ( isBackgroundStyleLayout(value) ) return value; + return undefined; +} diff --git a/style/layout/BorderStyleLayout.ts b/style/layout/BorderStyleLayout.ts new file mode 100644 index 0000000..0e95433 --- /dev/null +++ b/style/layout/BorderStyleLayout.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { BorderType, isBorderType } from "../types/BorderType"; +import { isUndefined } from "../../types/undefined"; +import { isString } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface BorderStyleLayout { + readonly size ?: string; + readonly type ?: BorderType; + readonly radius ?: string; + readonly color ?: string; +} + +export function createBorderStyleLayout ( + size ?: string, + color ?: string, + type ?: BorderType, + radius ?: string, +): BorderStyleLayout { + return { + size, + color, + type, + radius + }; +} + +export function isBorderStyleLayout (value: any): value is BorderStyleLayout { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'size', + 'type', + 'radius', + 'color' + ]) + && ( isUndefined(value?.size) || isString(value?.size) ) + && ( isUndefined(value?.type) || isBorderType(value?.type) ) + && ( isUndefined(value?.radius) || isString(value?.radius) ) + && ( isUndefined(value?.color) || isString(value?.color) ) + ); +} + +export function stringifyBorderStyleLayout (value: BorderStyleLayout): string { + return `ComponentBorderStyle(${value})`; +} + +export function parseBorderStyleLayout (value: any): BorderStyleLayout | undefined { + if ( isBorderStyleLayout(value) ) return value; + return undefined; +} diff --git a/style/layout/ComponentStyleLayout.ts b/style/layout/ComponentStyleLayout.ts new file mode 100644 index 0000000..dab12ca --- /dev/null +++ b/style/layout/ComponentStyleLayout.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { TextStyleLayout, isTextStyleLayout } from "./TextStyleLayout"; +import { BorderStyleLayout, isBorderStyleLayout } from "./BorderStyleLayout"; +import { BackgroundStyleLayout, isBackgroundStyleLayout } from "./BackgroundStyleLayout"; +import { isUndefined } from "../../types/undefined"; +import { isString } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface ComponentStyleLayout { + readonly text ?: TextStyleLayout; + readonly border ?: BorderStyleLayout; + readonly background ?: BackgroundStyleLayout; + readonly padding ?: string; +} + +export function createComponentStyleLayout ( + text ?: TextStyleLayout, + border ?: BorderStyleLayout, + background ?: BackgroundStyleLayout, + padding ?: string +): ComponentStyleLayout { + return { + background, + padding, + text, + border + }; +} + +export function isComponentStyleLayout (value: any): value is ComponentStyleLayout { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'background', + 'padding', + 'text', + 'border', + ]) + && ( isUndefined(value?.background) || isBackgroundStyleLayout(value?.background) ) + && ( isUndefined(value?.text) || isTextStyleLayout(value?.text) ) + && ( isUndefined(value?.border) || isBorderStyleLayout(value?.border) ) + && ( isUndefined(value?.padding) || isString(value?.padding) ) + ); +} + +export function stringifyComponentStyleLayout (value: ComponentStyleLayout): string { + return `ComponentStyleLayout(${value})`; +} + +export function parseComponentStyleLayout (value: any): ComponentStyleLayout | undefined { + if ( isComponentStyleLayout(value) ) return value; + return undefined; +} diff --git a/style/layout/TextStyleLayout.ts b/style/layout/TextStyleLayout.ts new file mode 100644 index 0000000..a795bf1 --- /dev/null +++ b/style/layout/TextStyleLayout.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isStringOrUndefined } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface TextStyleLayout { + readonly font ?: string; + readonly color ?: string; + readonly size ?: string; +} + +export function createTextStyleLayout ( + font ?: string, + color ?: string, + size ?: string +): TextStyleLayout { + return { + font, + color, + size + }; +} + +export function isTextStyleLayout (value: any): value is TextStyleLayout { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'font', + 'color', + 'size' + ]) + && isStringOrUndefined(value?.font) + && isStringOrUndefined(value?.color) + && isStringOrUndefined(value?.size) + ); +} + +export function stringifyTextStyleLayout (value: TextStyleLayout): string { + return `ComponentTextStyle(${value})`; +} + +export function parseTextStyleLayout (value: any): TextStyleLayout | undefined { + if ( isTextStyleLayout(value) ) return value; + return undefined; +} diff --git a/style/types/BorderType.ts b/style/types/BorderType.ts new file mode 100644 index 0000000..a3c0288 --- /dev/null +++ b/style/types/BorderType.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export enum BorderType { + DOTTED = "dotted", + DASHED = "dashed", + SOLID = "solid", + DOUBLE = "double", + GROOVE = "groove", + RIDGE = "ridge", + INSET = "inset", + OUTSET = "outset", + NONE = "none", + HIDDEN = "hidden" +} + +export function isBorderType (value: any): value is BorderType { + switch (value) { + case BorderType.DOTTED: + case BorderType.DASHED: + case BorderType.SOLID: + case BorderType.DOUBLE: + case BorderType.GROOVE: + case BorderType.RIDGE: + case BorderType.INSET: + case BorderType.OUTSET: + case BorderType.NONE: + case BorderType.HIDDEN: + return true; + + default: + return false; + + } +} + +export function stringifyBorderType (value: BorderType): string { + switch (value) { + case BorderType.DOTTED : return 'DOTTED'; + case BorderType.DASHED : return 'DASHED'; + case BorderType.SOLID : return 'SOLID'; + case BorderType.DOUBLE : return 'DOUBLE'; + case BorderType.GROOVE : return 'GROOVE'; + case BorderType.RIDGE : return 'RIDGE'; + case BorderType.INSET : return 'INSET'; + case BorderType.OUTSET : return 'OUTSET'; + case BorderType.NONE : return 'NONE'; + case BorderType.HIDDEN : return 'HIDDEN'; + } + throw new TypeError(`Unsupported BorderType value: ${value}`); +} + +export function parseBorderType (value: any): BorderType | undefined { + + switch (`${value}`.toUpperCase()) { + + case 'DOTTED' : return BorderType.DOTTED; + case 'DASHED' : return BorderType.DASHED; + case 'SOLID' : return BorderType.SOLID; + case 'DOUBLE' : return BorderType.DOUBLE; + case 'GROOVE' : return BorderType.GROOVE; + case 'RIDGE' : return BorderType.RIDGE; + case 'INSET' : return BorderType.INSET; + case 'OUTSET' : return BorderType.OUTSET; + case 'NONE' : return BorderType.NONE; + case 'HIDDEN' : return BorderType.HIDDEN; + default : return undefined; + + } + +} diff --git a/style/types/Color.ts b/style/types/Color.ts new file mode 100644 index 0000000..045b218 --- /dev/null +++ b/style/types/Color.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../types/String"; + +export type Color = string; + +export function createColor ( + value: string +): Color { + return value; +} + +export function isColor (value: any): value is Color { + return ( + isString(value) + ); +} + +export function stringifyColor (value: Color): string { + return `Color(${value})`; +} + +export function parseColor (value: any): Color | undefined { + if ( isColor(value) ) return value; + return undefined; +} diff --git a/style/types/ColorScheme.test.ts b/style/types/ColorScheme.test.ts new file mode 100644 index 0000000..cb235f9 --- /dev/null +++ b/style/types/ColorScheme.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2021-2021. Sendanor . All rights reserved. + +import {ColorScheme, isColorScheme, parseColorScheme, stringifyColorScheme} from "./ColorScheme"; + +describe('ColorScheme', () => { + + describe('isColorScheme', () => { + + test('returns true for correct values', () => { + expect( isColorScheme(ColorScheme.DARK) ).toBe(true); + expect( isColorScheme(ColorScheme.LIGHT) ).toBe(true); + }); + + test('returns false for incorrect values', () => { + expect( isColorScheme("DARK") ).toBe(false); + expect( isColorScheme("LIGHT") ).toBe(false); + expect( isColorScheme(-1) ).toBe(false); + expect( isColorScheme(999) ).toBe(false); + expect( isColorScheme(undefined) ).toBe(false); + expect( isColorScheme(null) ).toBe(false); + expect( isColorScheme(false) ).toBe(false); + expect( isColorScheme(true) ).toBe(false); + expect( isColorScheme({}) ).toBe(false); + expect( isColorScheme([]) ).toBe(false); + expect( isColorScheme(NaN) ).toBe(false); + }); + + }); + + describe('parseColorScheme', () => { + + test('can pass through ColorScheme types', () => { + expect( parseColorScheme(ColorScheme.DARK) ).toBe(ColorScheme.DARK); + expect( parseColorScheme(ColorScheme.LIGHT) ).toBe(ColorScheme.LIGHT); + }); + + test('can parse uppercase strings', () => { + expect( parseColorScheme("DARK") ).toBe(ColorScheme.DARK); + expect( parseColorScheme("LIGHT") ).toBe(ColorScheme.LIGHT); + }); + + test('can parse lowercase strings', () => { + expect( parseColorScheme("dark") ).toBe(ColorScheme.DARK); + expect( parseColorScheme("light") ).toBe(ColorScheme.LIGHT); + }); + + test('returns undefined for unknown values', () => { + expect( parseColorScheme("foo") ).toBe(undefined); + expect( parseColorScheme("") ).toBe(undefined); + expect( parseColorScheme(undefined) ).toBe(undefined); + expect( parseColorScheme(null) ).toBe(undefined); + expect( parseColorScheme(NaN) ).toBe(undefined); + expect( parseColorScheme(false) ).toBe(undefined); + expect( parseColorScheme(true) ).toBe(undefined); + expect( parseColorScheme({}) ).toBe(undefined); + expect( parseColorScheme([]) ).toBe(undefined); + }); + + }); + + describe('stringifyColorScheme', () => { + + test('can stringify DARK', () => { + expect( stringifyColorScheme(ColorScheme.DARK) ).toBe('DARK'); + }); + + test('can stringify LIGHT', () => { + expect( stringifyColorScheme(ColorScheme.LIGHT) ).toBe('LIGHT'); + }); + + test('can stringify undefined numbers', () => { + // @ts-ignore + expect( stringifyColorScheme(999) ).toBe('ColorScheme(999)'); + }); + + }); + +}); diff --git a/style/types/ColorScheme.ts b/style/types/ColorScheme.ts new file mode 100644 index 0000000..07fffa1 --- /dev/null +++ b/style/types/ColorScheme.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2021-2021. Sendanor . All rights reserved. + +import { trim } from "../../functions/trim"; +import { isString } from "../../types/String"; + +export enum ColorScheme { + DARK, + LIGHT +} + +/** + * Also available `ColorScheme.test(value) : boolean` + * @param value + */ +export function isColorScheme (value: any): value is ColorScheme { + switch (value) { + + case ColorScheme.DARK: + case ColorScheme.LIGHT: + return true; + + default: + return false; + } +} + +/** + * Also available `ColorScheme.parse(value: any) : ColorScheme | undefined` + * @param value + */ +export function parseColorScheme (value: any): ColorScheme | undefined { + + if (isColorScheme(value)) return value; + + if (isString(value)) { + value = trim(value).toUpperCase(); + switch (value) { + + case '0': + case 'DARK' : return ColorScheme.DARK; + + case '1': + case 'LIGHT' : return ColorScheme.LIGHT; + + default : return undefined; + } + } + + return undefined; + +} + +/** + * Also available `ColorScheme.stringify(value: ColorScheme | undefined) : string` + * @param value + */ +export function stringifyColorScheme (value: ColorScheme | undefined): string { + if (value === undefined) return 'undefined'; + switch (value) { + case ColorScheme.DARK : return 'DARK'; + case ColorScheme.LIGHT : return 'LIGHT'; + default : return `ColorScheme(${value})`; + } +} diff --git a/style/types/Font.ts b/style/types/Font.ts new file mode 100644 index 0000000..2f97af5 --- /dev/null +++ b/style/types/Font.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../types/String"; +import { isRegularObject } from "../../types/RegularObject"; +import { hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface Font { + readonly name : string; + readonly family : string; + readonly weight : string; +} + +export function createFont ( + name : string, + family : string, + weight : string +): Font { + return { + name, + family, + weight + }; +} + +export function isFont (value: any): value is Font { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name', + 'family', + 'weight' + ]) + && isString(value?.name) + && isString(value?.family) + && isString(value?.weight) + ); +} + +export function stringifyFont (value: Font): string { + return `Font(${value})`; +} + +export function parseFont (value: any): Font | undefined { + if ( isFont(value) ) return value; + return undefined; +} diff --git a/style/types/Size.ts b/style/types/Size.ts new file mode 100644 index 0000000..2467bde --- /dev/null +++ b/style/types/Size.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "../../types/String"; + +export type Size = string; + +export function createSize ( + value: string +): Size { + return value; +} + +export function isSize (value: any): value is Size { + return ( + isString(value) + ); +} + +export function stringifySize (value: Size): string { + return `Size(${value})`; +} + +export function parseSize (value: any): Size | undefined { + if ( isSize(value) ) return value; + return undefined; +} diff --git a/test/TestRunner.ts b/test/TestRunner.ts new file mode 100644 index 0000000..df5406c --- /dev/null +++ b/test/TestRunner.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { + lstatSync, + readdirSync +} from 'fs'; +import { + basename as pathBasename, + dirname as pathDirname, + join as pathJoin, + resolve as pathResolve +} from 'path'; +import { forEach } from "../functions/forEach"; +import { map } from "../functions/map"; +import { TestResultState } from "./types/TestResultState"; +import { TestResult } from "./types/TestResult"; +import { isPromise } from "../types/Promise"; + +export class TestRunner { + + private static _testId : number = 0; + private static _excludeDirectories : string[] = ['node_modules']; + private static _testFileEnding : string = 'Test.js'; + private static _testMethodEnding : string = 'Test'; + + protected static _results : readonly TestResult[] = []; + + public static testFileInDir (dir: string, file: string) { + + if (file.startsWith('.')) return; + if (TestRunner._excludeDirectories.includes(file)) return; + + const stat = lstatSync(pathJoin(dir, file)); + const isDir = stat.isDirectory(); + + if (isDir) { + return TestRunner.testDirectory(pathJoin(dir, file)); + } + + if (file.endsWith(TestRunner._testFileEnding)) { + + const test = require(pathResolve(dir, file)); + + const testNames : string[] = Object.keys(test).filter(file => file.endsWith(TestRunner._testMethodEnding)); + + forEach(testNames, (testName : string) => { + + const testClassName = testName; + const testClass = test[testClassName]; + const testMethodNames : string[] = Object.keys(testClass); + + forEach(testMethodNames, (methodName: string) => { + + TestRunner._testId += 1; + + const id = `${TestRunner._testId}`; + + const resolvedFile = pathResolve(dir, file); + + let testResult : TestResult = { + id, + state: TestResultState.RUNNING, + file: resolvedFile, + className: testClassName, + methodName: methodName + }; + + TestRunner._results = [ + ...TestRunner._results, + testResult + ]; + + function updateTestResult (newResult: TestResult) { + TestRunner._results = map( + TestRunner._results, + (item: TestResult) : TestResult => { + if (item.id === newResult.id) { + return newResult; + } else { + return item; + } + } + ); + } + + function testSuccess () { + updateTestResult({ + ...testResult, + state: TestResultState.SUCCESS + }); + } + + function testFailed (err : any) { + updateTestResult({ + ...testResult, + state: TestResultState.FAILED, + result: err + }); + } + + try { + const result = testClass[methodName](); + if (isPromise(result)) { + updateTestResult({ + ...testResult, + promise: result + }); + result.then(testSuccess, testFailed); + } else { + testSuccess(); + } + } catch(err) { + testFailed(err); + } + + }); + + }); + + } + + } + + public static testFile (file : string) { + TestRunner.testFileInDir( pathDirname(file), pathBasename(file) ); + } + + public static testDirectory (dir : string) { + forEach(readdirSync(dir), TestRunner.testFileInDir.bind(undefined, dir) ); + } + + public static printResults () { + + const results = TestRunner._results; + + let testCount = results.length; + let runningCount = 0; + let successCount = 0; + let failedCount = 0; + let errorResults : TestResult[] = [] + let promises : Promise[] = []; + + forEach(results, (result : TestResult) => { + switch(result.state) { + case TestResultState.RUNNING: + runningCount += 1; + if (result.promise) { + promises.push(result.promise); + } else { + console.warn('Warning! Result did not have promise'); + } + return; + case TestResultState.SUCCESS: + successCount += 1; + return; + case TestResultState.FAILED: + failedCount += 1; + errorResults.push(result); + return; + } + }); + + if (promises.length) { + Promise.allSettled(promises).then(TestRunner.printResults).catch(err => { + console.error('ERROR: ', err); + process.exit(1); + }); + return; + } + + if (testCount === 0) { + console.error(`ERROR: No tests found.`); + process.exit(1); + return; + } + + if (failedCount >= 1) { + console.error(`ERROR: ${failedCount} (of ${testCount}) tests failed:\n`); + forEach(errorResults, (testResult : TestResult) => { + console.error(`[${testResult.file}] ${testResult.className}.${testResult.methodName} failed: `, testResult.result, '\n'); + }); + process.exit(1); + return; + } + + console.log(`All ${testCount} tests successfully executed.`); + + } + +} diff --git a/test/types/TestResult.ts b/test/types/TestResult.ts new file mode 100644 index 0000000..2fccb41 --- /dev/null +++ b/test/types/TestResult.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { TestResultState } from "./TestResultState"; + +export interface TestResult { + readonly id : string; + readonly state : TestResultState; + readonly file : string; + readonly className : string; + readonly methodName : string; + readonly result ?: any; + readonly promise ?: Promise; +} diff --git a/test/types/TestResultState.ts b/test/types/TestResultState.ts new file mode 100644 index 0000000..d439abe --- /dev/null +++ b/test/types/TestResultState.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export enum TestResultState { + RUNNING, + SUCCESS, + FAILED +} diff --git a/translations/country-en.json b/translations/country-en.json new file mode 100644 index 0000000..bdf1e9e --- /dev/null +++ b/translations/country-en.json @@ -0,0 +1,503 @@ +{ + + "countryCode.AF.name": "Afghanistan", + "countryCode.AX.name": "Åland Islands", + "countryCode.AL.name": "Albania", + "countryCode.DZ.name": "Algeria", + "countryCode.AS.name": "American Samoa", + "countryCode.AD.name": "Andorra", + "countryCode.AO.name": "Angola", + "countryCode.AI.name": "Anguilla", + "countryCode.AQ.name": "Antarctica", + "countryCode.AG.name": "Antigua and Barbuda", + "countryCode.AR.name": "Argentina", + "countryCode.AM.name": "Armenia", + "countryCode.AW.name": "Aruba", + "countryCode.AU.name": "Australia", + "countryCode.AT.name": "Austria", + "countryCode.AZ.name": "Azerbaijan", + "countryCode.BS.name": "Bahamas (the)", + "countryCode.BH.name": "Bahrain", + "countryCode.BD.name": "Bangladesh", + "countryCode.BB.name": "Barbados", + "countryCode.BY.name": "Belarus", + "countryCode.BE.name": "Belgium", + "countryCode.BZ.name": "Belize", + "countryCode.BJ.name": "Benin", + "countryCode.BM.name": "Bermuda", + "countryCode.BT.name": "Bhutan", + "countryCode.BO.name": "Bolivia (Plurinational State of)", + "countryCode.BQ.name": "Bonaire, Sint Eustatius, Saba", + "countryCode.BA.name": "Bosnia and Herzegovina", + "countryCode.BW.name": "Botswana", + "countryCode.BV.name": "Bouvet Island", + "countryCode.BR.name": "Brazil", + "countryCode.IO.name": "British Indian Ocean Territory (the)", + "countryCode.BN.name": "Brunei Darussalam", + "countryCode.BG.name": "Bulgaria", + "countryCode.BF.name": "Burkina Faso", + "countryCode.BI.name": "Burundi", + "countryCode.CV.name": "Cabo Verde", + "countryCode.KH.name": "Cambodia", + "countryCode.CM.name": "Cameroon", + "countryCode.CA.name": "Canada", + "countryCode.KY.name": "Cayman Islands (the)", + "countryCode.CF.name": "Central African Republic (the)", + "countryCode.TD.name": "Chad", + "countryCode.CL.name": "Chile", + "countryCode.CN.name": "China", + "countryCode.CX.name": "Christmas Island", + "countryCode.CC.name": "Cocos (Keeling) Islands (the)", + "countryCode.CO.name": "Colombia", + "countryCode.KM.name": "Comoros (the)", + "countryCode.CD.name": "Congo (the Democratic Republic of the)", + "countryCode.CG.name": "Congo (the)", + "countryCode.CK.name": "Cook Islands (the)", + "countryCode.CR.name": "Costa Rica", + "countryCode.CI.name": "Côte d'Ivoire", + "countryCode.HR.name": "Croatia", + "countryCode.CU.name": "Cuba", + "countryCode.CW.name": "Curaçao", + "countryCode.CY.name": "Cyprus", + "countryCode.CZ.name": "Czechia", + "countryCode.DK.name": "Denmark", + "countryCode.DJ.name": "Djibouti", + "countryCode.DM.name": "Dominica", + "countryCode.DO.name": "Dominican Republic (the)", + "countryCode.EC.name": "Ecuador", + "countryCode.EG.name": "Egypt", + "countryCode.SV.name": "El Salvador", + "countryCode.GQ.name": "Equatorial Guinea", + "countryCode.ER.name": "Eritrea", + "countryCode.EE.name": "Estonia", + "countryCode.SZ.name": "Eswatini", + "countryCode.ET.name": "Ethiopia", + "countryCode.FK.name": "Falkland Islands (the) [Malvinas]", + "countryCode.FO.name": "Faroe Islands (the)", + "countryCode.FJ.name": "Fiji", + "countryCode.FI.name": "Finland", + "countryCode.FR.name": "France", + "countryCode.GF.name": "French Guiana", + "countryCode.PF.name": "French Polynesia", + "countryCode.TF.name": "French Southern Territories (the)", + "countryCode.GA.name": "Gabon", + "countryCode.GM.name": "Gambia (the)", + "countryCode.GE.name": "Georgia", + "countryCode.DE.name": "Germany", + "countryCode.GH.name": "Ghana", + "countryCode.GI.name": "Gibraltar", + "countryCode.GR.name": "Greece", + "countryCode.GL.name": "Greenland", + "countryCode.GD.name": "Grenada", + "countryCode.GP.name": "Guadeloupe", + "countryCode.GU.name": "Guam", + "countryCode.GT.name": "Guatemala", + "countryCode.GG.name": "Guernsey", + "countryCode.GN.name": "Guinea", + "countryCode.GW.name": "Guinea-Bissau", + "countryCode.GY.name": "Guyana", + "countryCode.HT.name": "Haiti", + "countryCode.HM.name": "Heard Island and McDonald Islands", + "countryCode.VA.name": "Holy See (the)", + "countryCode.HN.name": "Honduras", + "countryCode.HK.name": "Hong Kong", + "countryCode.HU.name": "Hungary", + "countryCode.IS.name": "Iceland", + "countryCode.IN.name": "India", + "countryCode.ID.name": "Indonesia", + "countryCode.IR.name": "Iran (Islamic Republic of)", + "countryCode.IQ.name": "Iraq", + "countryCode.IE.name": "Ireland", + "countryCode.IM.name": "Isle of Man", + "countryCode.IL.name": "Israel", + "countryCode.IT.name": "Italy", + "countryCode.JM.name": "Jamaica", + "countryCode.JP.name": "Japan", + "countryCode.JE.name": "Jersey", + "countryCode.JO.name": "Jordan", + "countryCode.KZ.name": "Kazakhstan", + "countryCode.KE.name": "Kenya", + "countryCode.KI.name": "Kiribati", + "countryCode.KP.name": "Korea (the Democratic People's Republic of)", + "countryCode.KR.name": "Korea (the Republic of)", + "countryCode.KW.name": "Kuwait", + "countryCode.KG.name": "Kyrgyzstan", + "countryCode.LA.name": "Lao People's Democratic Republic (the)", + "countryCode.LV.name": "Latvia", + "countryCode.LB.name": "Lebanon", + "countryCode.LS.name": "Lesotho", + "countryCode.LR.name": "Liberia", + "countryCode.LY.name": "Libya", + "countryCode.LI.name": "Liechtenstein", + "countryCode.LT.name": "Lithuania", + "countryCode.LU.name": "Luxembourg", + "countryCode.MO.name": "Macao", + "countryCode.MK.name": "North Macedonia", + "countryCode.MG.name": "Madagascar", + "countryCode.MW.name": "Malawi", + "countryCode.MY.name": "Malaysia", + "countryCode.MV.name": "Maldives", + "countryCode.ML.name": "Mali", + "countryCode.MT.name": "Malta", + "countryCode.MH.name": "Marshall Islands (the)", + "countryCode.MQ.name": "Martinique", + "countryCode.MR.name": "Mauritania", + "countryCode.MU.name": "Mauritius", + "countryCode.YT.name": "Mayotte", + "countryCode.MX.name": "Mexico", + "countryCode.FM.name": "Micronesia (Federated States of)", + "countryCode.MD.name": "Moldova (the Republic of)", + "countryCode.MC.name": "Monaco", + "countryCode.MN.name": "Mongolia", + "countryCode.ME.name": "Montenegro", + "countryCode.MS.name": "Montserrat", + "countryCode.MA.name": "Morocco", + "countryCode.MZ.name": "Mozambique", + "countryCode.MM.name": "Myanmar", + "countryCode.NA.name": "Namibia", + "countryCode.NR.name": "Nauru", + "countryCode.NP.name": "Nepal", + "countryCode.NL.name": "Netherlands (the)", + "countryCode.NC.name": "New Caledonia", + "countryCode.NZ.name": "New Zealand", + "countryCode.NI.name": "Nicaragua", + "countryCode.NE.name": "Niger (the)", + "countryCode.NG.name": "Nigeria", + "countryCode.NU.name": "Niue", + "countryCode.NF.name": "Norfolk Island", + "countryCode.MP.name": "Northern Mariana Islands (the)", + "countryCode.NO.name": "Norway", + "countryCode.OM.name": "Oman", + "countryCode.PK.name": "Pakistan", + "countryCode.PW.name": "Palau", + "countryCode.PS.name": "Palestine, State of", + "countryCode.PA.name": "Panama", + "countryCode.PG.name": "Papua New Guinea", + "countryCode.PY.name": "Paraguay", + "countryCode.PE.name": "Peru", + "countryCode.PH.name": "Philippines (the)", + "countryCode.PN.name": "Pitcairn", + "countryCode.PL.name": "Poland", + "countryCode.PT.name": "Portugal", + "countryCode.PR.name": "Puerto Rico", + "countryCode.QA.name": "Qatar", + "countryCode.RE.name": "Réunion", + "countryCode.RO.name": "Romania", + "countryCode.RU.name": "Russian Federation (the)", + "countryCode.RW.name": "Rwanda", + "countryCode.BL.name": "Saint Barthélemy", + "countryCode.SH.name": "Saint Helena Ascension Island Tristan da Cunha", + "countryCode.KN.name": "Saint Kitts and Nevis", + "countryCode.LC.name": "Saint Lucia", + "countryCode.MF.name": "Saint Martin (French part)", + "countryCode.PM.name": "Saint Pierre and Miquelon", + "countryCode.VC.name": "Saint Vincent and the Grenadines", + "countryCode.WS.name": "Samoa", + "countryCode.SM.name": "San Marino", + "countryCode.ST.name": "Sao Tome and Principe", + "countryCode.SA.name": "Saudi Arabia", + "countryCode.SN.name": "Senegal", + "countryCode.RS.name": "Serbia", + "countryCode.SC.name": "Seychelles", + "countryCode.SL.name": "Sierra Leone", + "countryCode.SG.name": "Singapore", + "countryCode.SX.name": "Sint Maarten (Dutch part)", + "countryCode.SK.name": "Slovakia", + "countryCode.SI.name": "Slovenia", + "countryCode.SB.name": "Solomon Islands", + "countryCode.SO.name": "Somalia", + "countryCode.ZA.name": "South Africa", + "countryCode.GS.name": "South Georgia and the South Sandwich Islands", + "countryCode.SS.name": "South Sudan", + "countryCode.ES.name": "Spain", + "countryCode.LK.name": "Sri Lanka", + "countryCode.SD.name": "Sudan (the)", + "countryCode.SR.name": "Suriname", + "countryCode.SJ.name": "Svalbard, Jan Mayen", + "countryCode.SE.name": "Sweden", + "countryCode.CH.name": "Switzerland", + "countryCode.SY.name": "Syrian Arab Republic (the)", + "countryCode.TW.name": "Taiwan (Province of China)", + "countryCode.TJ.name": "Tajikistan", + "countryCode.TZ.name": "Tanzania, the United Republic of", + "countryCode.TH.name": "Thailand", + "countryCode.TL.name": "Timor-Leste [aa]", + "countryCode.TG.name": "Togo", + "countryCode.TK.name": "Tokelau", + "countryCode.TO.name": "Tonga", + "countryCode.TT.name": "Trinidad and Tobago", + "countryCode.TN.name": "Tunisia", + "countryCode.TR.name": "Turkey", + "countryCode.TM.name": "Turkmenistan", + "countryCode.TC.name": "Turks and Caicos Islands (the)", + "countryCode.TV.name": "Tuvalu", + "countryCode.UG.name": "Uganda", + "countryCode.UA.name": "Ukraine", + "countryCode.AE.name": "United Arab Emirates (the)", + "countryCode.GB.name": "United Kingdom of Great Britain and Northern Ireland (the)", + "countryCode.UM.name": "United States Minor Outlying Islands (the)", + "countryCode.US.name": "United States of America (the)", + "countryCode.UY.name": "Uruguay", + "countryCode.UZ.name": "Uzbekistan", + "countryCode.VU.name": "Vanuatu", + "countryCode.VE.name": "Venezuela (Bolivarian Republic of)", + "countryCode.VN.name": "Viet Nam [ae]", + "countryCode.VG.name": "Virgin Islands (British)", + "countryCode.VI.name": "Virgin Islands (U.S.)", + "countryCode.WF.name": "Wallis and Futuna", + "countryCode.EH.name": "Western Sahara", + "countryCode.YE.name": "Yemen", + "countryCode.ZM.name": "Zambia", + "countryCode.ZW.name": "Zimbabwe", + + "countryCode.AF.officialName": "The Islamic Republic of Afghanistan", + "countryCode.AX.officialName": "Åland", + "countryCode.AL.officialName": "The Republic of Albania", + "countryCode.DZ.officialName": "The People's Democratic Republic of Algeria", + "countryCode.AS.officialName": "The Territory of American Samoa", + "countryCode.AD.officialName": "The Principality of Andorra", + "countryCode.AO.officialName": "The Republic of Angola", + "countryCode.AI.officialName": "Anguilla", + "countryCode.AQ.officialName": "All land and ice shelves south of the 60th parallel south", + "countryCode.AG.officialName": "Antigua and Barbuda", + "countryCode.AR.officialName": "The Argentine Republic", + "countryCode.AM.officialName": "The Republic of Armenia", + "countryCode.AW.officialName": "Aruba", + "countryCode.AU.officialName": "The Commonwealth of Australia", + "countryCode.AT.officialName": "The Republic of Austria", + "countryCode.AZ.officialName": "The Republic of Azerbaijan", + "countryCode.BS.officialName": "The Commonwealth of The Bahamas", + "countryCode.BH.officialName": "The Kingdom of Bahrain", + "countryCode.BD.officialName": "The People's Republic of Bangladesh", + "countryCode.BB.officialName": "Barbados", + "countryCode.BY.officialName": "The Republic of Belarus", + "countryCode.BE.officialName": "The Kingdom of Belgium", + "countryCode.BZ.officialName": "Belize", + "countryCode.BJ.officialName": "The Republic of Benin", + "countryCode.BM.officialName": "Bermuda", + "countryCode.BT.officialName": "The Kingdom of Bhutan", + "countryCode.BO.officialName": "The Plurinational State of Bolivia", + "countryCode.BQ.officialName": "Bonaire, Sint Eustatius and Saba", + "countryCode.BA.officialName": "Bosnia and Herzegovina", + "countryCode.BW.officialName": "The Republic of Botswana", + "countryCode.BV.officialName": "Bouvet Island", + "countryCode.BR.officialName": "The Federative Republic of Brazil", + "countryCode.IO.officialName": "The British Indian Ocean Territory", + "countryCode.BN.officialName": "The Nation of Brunei, the Abode of Peace", + "countryCode.BG.officialName": "The Republic of Bulgaria", + "countryCode.BF.officialName": "Burkina Faso", + "countryCode.BI.officialName": "The Republic of Burundi", + "countryCode.CV.officialName": "The Republic of Cabo Verde", + "countryCode.KH.officialName": "The Kingdom of Cambodia", + "countryCode.CM.officialName": "The Republic of Cameroon", + "countryCode.CA.officialName": "Canada", + "countryCode.KY.officialName": "The Cayman Islands", + "countryCode.CF.officialName": "The Central African Republic", + "countryCode.TD.officialName": "The Republic of Chad", + "countryCode.CL.officialName": "The Republic of Chile", + "countryCode.CN.officialName": "The People's Republic of China", + "countryCode.CX.officialName": "The Territory of Christmas Island", + "countryCode.CC.officialName": "The Territory of Cocos (Keeling) Islands", + "countryCode.CO.officialName": "The Republic of Colombia", + "countryCode.KM.officialName": "The Union of the Comoros", + "countryCode.CD.officialName": "The Democratic Republic of the Congo", + "countryCode.CG.officialName": "The Republic of the Congo", + "countryCode.CK.officialName": "The Cook Islands", + "countryCode.CR.officialName": "The Republic of Costa Rica", + "countryCode.CI.officialName": "The Republic of Côte d'Ivoire", + "countryCode.HR.officialName": "The Republic of Croatia", + "countryCode.CU.officialName": "The Republic of Cuba", + "countryCode.CW.officialName": "The Country of Curaçao", + "countryCode.CY.officialName": "The Republic of Cyprus", + "countryCode.CZ.officialName": "The Czech Republic", + "countryCode.DK.officialName": "The Kingdom of Denmark", + "countryCode.DJ.officialName": "The Republic of Djibouti", + "countryCode.DM.officialName": "The Commonwealth of Dominica", + "countryCode.DO.officialName": "The Dominican Republic", + "countryCode.EC.officialName": "The Republic of Ecuador", + "countryCode.EG.officialName": "The Arab Republic of Egypt", + "countryCode.SV.officialName": "The Republic of El Salvador", + "countryCode.GQ.officialName": "The Republic of Equatorial Guinea", + "countryCode.ER.officialName": "The State of Eritrea", + "countryCode.EE.officialName": "The Republic of Estonia", + "countryCode.SZ.officialName": "The Kingdom of Eswatini", + "countryCode.ET.officialName": "The Federal Democratic Republic of Ethiopia", + "countryCode.FK.officialName": "The Falkland Islands", + "countryCode.FO.officialName": "The Faroe Islands", + "countryCode.FJ.officialName": "The Republic of Fiji", + "countryCode.FI.officialName": "The Republic of Finland", + "countryCode.FR.officialName": "The French Republic", + "countryCode.GF.officialName": "Guyane", + "countryCode.PF.officialName": "French Polynesia", + "countryCode.TF.officialName": "The French Southern and Antarctic Lands", + "countryCode.GA.officialName": "The Gabonese Republic", + "countryCode.GM.officialName": "The Republic of The Gambia", + "countryCode.GE.officialName": "Georgia", + "countryCode.DE.officialName": "The Federal Republic of Germany", + "countryCode.GH.officialName": "The Republic of Ghana", + "countryCode.GI.officialName": "Gibraltar", + "countryCode.GR.officialName": "The Hellenic Republic", + "countryCode.GL.officialName": "Kalaallit Nunaat", + "countryCode.GD.officialName": "Grenada", + "countryCode.GP.officialName": "Guadeloupe", + "countryCode.GU.officialName": "The Territory of Guam", + "countryCode.GT.officialName": "The Republic of Guatemala", + "countryCode.GG.officialName": "The Bailiwick of Guernsey", + "countryCode.GN.officialName": "The Republic of Guinea", + "countryCode.GW.officialName": "The Republic of Guinea-Bissau", + "countryCode.GY.officialName": "The Co-operative Republic of Guyana", + "countryCode.HT.officialName": "The Republic of Haiti", + "countryCode.HM.officialName": "The Territory of Heard Island and McDonald Islands", + "countryCode.VA.officialName": "The Holy See", + "countryCode.HN.officialName": "The Republic of Honduras", + "countryCode.HK.officialName": "The Hong Kong Special Administrative Region of China", + "countryCode.HU.officialName": "Hungary", + "countryCode.IS.officialName": "Iceland", + "countryCode.IN.officialName": "The Republic of India", + "countryCode.ID.officialName": "The Republic of Indonesia", + "countryCode.IR.officialName": "The Islamic Republic of Iran", + "countryCode.IQ.officialName": "The Republic of Iraq", + "countryCode.IE.officialName": "Ireland", + "countryCode.IM.officialName": "The Isle of Man", + "countryCode.IL.officialName": "The State of Israel", + "countryCode.IT.officialName": "The Italian Republic", + "countryCode.JM.officialName": "Jamaica", + "countryCode.JP.officialName": "Japan", + "countryCode.JE.officialName": "The Bailiwick of Jersey", + "countryCode.JO.officialName": "The Hashemite Kingdom of Jordan", + "countryCode.KZ.officialName": "The Republic of Kazakhstan", + "countryCode.KE.officialName": "The Republic of Kenya", + "countryCode.KI.officialName": "The Republic of Kiribati", + "countryCode.KP.officialName": "The Democratic People's Republic of Korea", + "countryCode.KR.officialName": "The Republic of Korea", + "countryCode.KW.officialName": "The State of Kuwait", + "countryCode.KG.officialName": "The Kyrgyz Republic", + "countryCode.LA.officialName": "The Lao People's Democratic Republic", + "countryCode.LV.officialName": "The Republic of Latvia", + "countryCode.LB.officialName": "The Lebanese Republic", + "countryCode.LS.officialName": "The Kingdom of Lesotho", + "countryCode.LR.officialName": "The Republic of Liberia", + "countryCode.LY.officialName": "The State of Libya", + "countryCode.LI.officialName": "The Principality of Liechtenstein", + "countryCode.LT.officialName": "The Republic of Lithuania", + "countryCode.LU.officialName": "The Grand Duchy of Luxembourg", + "countryCode.MO.officialName": "Macao Special Administrative Region of China", + "countryCode.MK.officialName": "Republic of North Macedonia", + "countryCode.MG.officialName": "The Republic of Madagascar", + "countryCode.MW.officialName": "The Republic of Malawi", + "countryCode.MY.officialName": "Malaysia", + "countryCode.MV.officialName": "The Republic of Maldives", + "countryCode.ML.officialName": "The Republic of Mali", + "countryCode.MT.officialName": "The Republic of Malta", + "countryCode.MH.officialName": "The Republic of the Marshall Islands", + "countryCode.MQ.officialName": "Martinique", + "countryCode.MR.officialName": "The Islamic Republic of Mauritania", + "countryCode.MU.officialName": "The Republic of Mauritius", + "countryCode.YT.officialName": "The Department of Mayotte", + "countryCode.MX.officialName": "The United Mexican States", + "countryCode.FM.officialName": "The Federated States of Micronesia", + "countryCode.MD.officialName": "The Republic of Moldova", + "countryCode.MC.officialName": "The Principality of Monaco", + "countryCode.MN.officialName": "The State of Mongolia", + "countryCode.ME.officialName": "Montenegro", + "countryCode.MS.officialName": "Montserrat", + "countryCode.MA.officialName": "The Kingdom of Morocco", + "countryCode.MZ.officialName": "The Republic of Mozambique", + "countryCode.MM.officialName": "The Republic of the Union of Myanmar", + "countryCode.NA.officialName": "The Republic of Namibia", + "countryCode.NR.officialName": "The Republic of Nauru", + "countryCode.NP.officialName": "The Federal Democratic Republic of Nepal", + "countryCode.NL.officialName": "The Kingdom of the Netherlands", + "countryCode.NC.officialName": "New Caledonia", + "countryCode.NZ.officialName": "New Zealand", + "countryCode.NI.officialName": "The Republic of Nicaragua", + "countryCode.NE.officialName": "The Republic of the Niger", + "countryCode.NG.officialName": "The Federal Republic of Nigeria", + "countryCode.NU.officialName": "Niue", + "countryCode.NF.officialName": "The Territory of Norfolk Island", + "countryCode.MP.officialName": "The Commonwealth of the Northern Mariana Islands", + "countryCode.NO.officialName": "The Kingdom of Norway", + "countryCode.OM.officialName": "The Sultanate of Oman", + "countryCode.PK.officialName": "The Islamic Republic of Pakistan", + "countryCode.PW.officialName": "The Republic of Palau", + "countryCode.PS.officialName": "The State of Palestine", + "countryCode.PA.officialName": "The Republic of Panamá", + "countryCode.PG.officialName": "The Independent State of Papua New Guinea", + "countryCode.PY.officialName": "The Republic of Paraguay", + "countryCode.PE.officialName": "The Republic of Perú", + "countryCode.PH.officialName": "The Republic of the Philippines", + "countryCode.PN.officialName": "The Pitcairn, Henderson, Ducie and Oeno Islands", + "countryCode.PL.officialName": "The Republic of Poland", + "countryCode.PT.officialName": "The Portuguese Republic", + "countryCode.PR.officialName": "The Commonwealth of Puerto Rico", + "countryCode.QA.officialName": "The State of Qatar", + "countryCode.RE.officialName": "Réunion", + "countryCode.RO.officialName": "Romania", + "countryCode.RU.officialName": "The Russian Federation", + "countryCode.RW.officialName": "The Republic of Rwanda", + "countryCode.BL.officialName": "The Collectivity of Saint-Barthélemy", + "countryCode.SH.officialName": "Saint Helena, Ascension and Tristan da Cunha", + "countryCode.KN.officialName": "Saint Kitts and Nevis", + "countryCode.LC.officialName": "Saint Lucia", + "countryCode.MF.officialName": "The Collectivity of Saint-Martin", + "countryCode.PM.officialName": "The Overseas Collectivity of Saint-Pierre and Miquelon", + "countryCode.VC.officialName": "Saint Vincent and the Grenadines", + "countryCode.WS.officialName": "The Independent State of Samoa", + "countryCode.SM.officialName": "The Republic of San Marino", + "countryCode.ST.officialName": "The Democratic Republic of São Tomé and Príncipe", + "countryCode.SA.officialName": "The Kingdom of Saudi Arabia", + "countryCode.SN.officialName": "The Republic of Senegal", + "countryCode.RS.officialName": "The Republic of Serbia", + "countryCode.SC.officialName": "The Republic of Seychelles", + "countryCode.SL.officialName": "The Republic of Sierra Leone", + "countryCode.SG.officialName": "The Republic of Singapore", + "countryCode.SX.officialName": "Sint Maarten", + "countryCode.SK.officialName": "The Slovak Republic", + "countryCode.SI.officialName": "The Republic of Slovenia", + "countryCode.SB.officialName": "The Solomon Islands", + "countryCode.SO.officialName": "The Federal Republic of Somalia", + "countryCode.ZA.officialName": "The Republic of South Africa", + "countryCode.GS.officialName": "South Georgia and the South Sandwich Islands", + "countryCode.SS.officialName": "The Republic of South Sudan", + "countryCode.ES.officialName": "The Kingdom of Spain", + "countryCode.LK.officialName": "The Democratic Socialist Republic of Sri Lanka", + "countryCode.SD.officialName": "The Republic of the Sudan", + "countryCode.SR.officialName": "The Republic of Suriname", + "countryCode.SJ.officialName": "Svalbard and Jan Mayen", + "countryCode.SE.officialName": "The Kingdom of Sweden", + "countryCode.CH.officialName": "The Swiss Confederation", + "countryCode.SY.officialName": "The Syrian Arab Republic", + "countryCode.TW.officialName": "The Republic of China", + "countryCode.TJ.officialName": "The Republic of Tajikistan", + "countryCode.TZ.officialName": "The United Republic of Tanzania", + "countryCode.TH.officialName": "The Kingdom of Thailand", + "countryCode.TL.officialName": "The Democratic Republic of Timor-Leste", + "countryCode.TG.officialName": "The Togolese Republic", + "countryCode.TK.officialName": "Tokelau", + "countryCode.TO.officialName": "The Kingdom of Tonga", + "countryCode.TT.officialName": "The Republic of Trinidad and Tobago", + "countryCode.TN.officialName": "The Republic of Tunisia", + "countryCode.TR.officialName": "The Republic of Turkey", + "countryCode.TM.officialName": "Turkmenistan", + "countryCode.TC.officialName": "The Turks and Caicos Islands", + "countryCode.TV.officialName": "Tuvalu", + "countryCode.UG.officialName": "The Republic of Uganda", + "countryCode.UA.officialName": "Ukraine", + "countryCode.AE.officialName": "The United Arab Emirates", + "countryCode.GB.officialName": "The United Kingdom of Great Britain and Northern Ireland", + "countryCode.UM.officialName": "Baker Island, Howland Island, Jarvis Island, Johnston Atoll, Kingman Reef, Midway Atoll, Navassa Island, Palmyra Atoll, and Wake Island", + "countryCode.US.officialName": "The United States of America", + "countryCode.UY.officialName": "The Oriental Republic of Uruguay", + "countryCode.UZ.officialName": "The Republic of Uzbekistan", + "countryCode.VU.officialName": "The Republic of Vanuatu", + "countryCode.VE.officialName": "The Bolivarian Republic of Venezuela", + "countryCode.VN.officialName": "The Socialist Republic of Viet Nam", + "countryCode.VG.officialName": "The Virgin Islands", + "countryCode.VI.officialName": "The Virgin Islands of the United States", + "countryCode.WF.officialName": "The Territory of the Wallis and Futuna Islands", + "countryCode.EH.officialName": "The Sahrawi Arab Democratic Republic", + "countryCode.YE.officialName": "The Republic of Yemen", + "countryCode.ZM.officialName": "The Republic of Zambia", + "countryCode.ZW.officialName": "The Republic of Zimbabwe" + +} diff --git a/translations/country-fi.json b/translations/country-fi.json new file mode 100644 index 0000000..eb10515 --- /dev/null +++ b/translations/country-fi.json @@ -0,0 +1,502 @@ +{ + "countryCode.AF.name": "Afghanistan", + "countryCode.AX.name": "Ahvenanmaa", + "countryCode.AL.name": "Albania", + "countryCode.DZ.name": "Algeria", + "countryCode.AS.name": "American Samoa", + "countryCode.AD.name": "Andorra", + "countryCode.AO.name": "Angola", + "countryCode.AI.name": "Anguilla", + "countryCode.AQ.name": "Antarktis", + "countryCode.AG.name": "Antigua ja Barbuda", + "countryCode.AR.name": "Argentina", + "countryCode.AM.name": "Armenia", + "countryCode.AW.name": "Aruba", + "countryCode.AU.name": "Australia", + "countryCode.AT.name": "Itävalta", + "countryCode.AZ.name": "Azerbaidžan", + "countryCode.BS.name": "Bahama", + "countryCode.BH.name": "Bahrain", + "countryCode.BD.name": "Bangladesh", + "countryCode.BB.name": "Barbados", + "countryCode.BY.name": "Valko-Venäjä", + "countryCode.BE.name": "Belgia", + "countryCode.BZ.name": "Belize", + "countryCode.BJ.name": "Benin", + "countryCode.BM.name": "Bermuda", + "countryCode.BT.name": "Bhutan", + "countryCode.BO.name": "Bolivia", + "countryCode.BQ.name": "Bonaire, Sint Eustatius, Saba", + "countryCode.BA.name": "Bosnia ja Hertsegovina", + "countryCode.BW.name": "Botswana", + "countryCode.BV.name": "Bouvet Island", + "countryCode.BR.name": "Brasilia", + "countryCode.IO.name": "British Indian Ocean Territory", + "countryCode.BN.name": "Brunei Darussalam", + "countryCode.BG.name": "Bulgaria", + "countryCode.BF.name": "Burkina Faso", + "countryCode.BI.name": "Burundi", + "countryCode.CV.name": "Cabo Verde", + "countryCode.KH.name": "Cambodia", + "countryCode.CM.name": "Cameron", + "countryCode.CA.name": "Canada", + "countryCode.KY.name": "Caymansaaret", + "countryCode.CF.name": "Keski-Afrikan tasavalta", + "countryCode.TD.name": "Tšad", + "countryCode.CL.name": "Chile", + "countryCode.CN.name": "China", + "countryCode.CX.name": "Christmas Island", + "countryCode.CC.name": "Cocos (Keeling) Islands", + "countryCode.CO.name": "Kolumbia", + "countryCode.KM.name": "Komorit", + "countryCode.CD.name": "Kongo (Demokraattinen tasavalta)", + "countryCode.CG.name": "Kongo", + "countryCode.CK.name": "Cookinsaaret", + "countryCode.CR.name": "Costa Rica", + "countryCode.CI.name": "Norsunluurannikko", + "countryCode.HR.name": "Kroatia", + "countryCode.CU.name": "Cuba", + "countryCode.CW.name": "Curaçao", + "countryCode.CY.name": "Kypros", + "countryCode.CZ.name": "Czechia", + "countryCode.DK.name": "Danmark", + "countryCode.DJ.name": "Djibouti", + "countryCode.DM.name": "Dominica", + "countryCode.DO.name": "Dominikaaninen tasavalta", + "countryCode.EC.name": "Ecuador", + "countryCode.EG.name": "Egypt", + "countryCode.SV.name": "El Salvador", + "countryCode.GQ.name": "Equatorial Guinea", + "countryCode.ER.name": "Eritrea", + "countryCode.EE.name": "Estonia", + "countryCode.SZ.name": "Eswatini", + "countryCode.ET.name": "Etiopia", + "countryCode.FK.name": "Falklandinsaaret", + "countryCode.FO.name": "Färsaaret nds", + "countryCode.FJ.name": "Fiji", + "countryCode.FI.name": "Suomi", + "countryCode.FR.name": "Ranska", + "countryCode.GF.name": "Ranskan Guyana", + "countryCode.PF.name": "Ranskan Polynesia", + "countryCode.TF.name": "Ranskan eteläiset alueet", + "countryCode.GA.name": "Gabon", + "countryCode.GM.name": "Gambia", + "countryCode.GE.name": "Georgia", + "countryCode.DE.name": "Saksa", + "countryCode.GH.name": "Ghana", + "countryCode.GI.name": "Gibraltar", + "countryCode.GR.name": "Kreikka", + "countryCode.GL.name": "Grönlanti", + "countryCode.GD.name": "Grenada", + "countryCode.GP.name": "Guadeloupe", + "countryCode.GU.name": "Guam", + "countryCode.GT.name": "Guatemala", + "countryCode.GG.name": "Guernsey", + "countryCode.GN.name": "Guinea", + "countryCode.GW.name": "Guinea-Bissau", + "countryCode.GY.name": "Guyana", + "countryCode.HT.name": "Haiti", + "countryCode.HM.name": "Heard Island ja McDonald Islands", + "countryCode.VA.name": "Vatikaani", + "countryCode.HN.name": "Honduras", + "countryCode.HK.name": "Hong Kong", + "countryCode.HU.name": "Unkari", + "countryCode.IS.name": "Islanti", + "countryCode.IN.name": "Intia", + "countryCode.ID.name": "Indonesia", + "countryCode.IR.name": "Iran", + "countryCode.IQ.name": "Irak", + "countryCode.IE.name": "Ireland", + "countryCode.IM.name": "Isle of Man", + "countryCode.IL.name": "Israel", + "countryCode.IT.name": "Italia", + "countryCode.JM.name": "Jamaika", + "countryCode.JP.name": "Japan", + "countryCode.JE.name": "Jersey", + "countryCode.JO.name": "Jordania", + "countryCode.KZ.name": "Kazakhstan", + "countryCode.KE.name": "Kenya", + "countryCode.KI.name": "Kiribati", + "countryCode.KP.name": "Korea (Demokraattinen kansantasavalta)", + "countryCode.KR.name": "Korea (tasavalta)", + "countryCode.KW.name": "Kuwait", + "countryCode.KG.name": "Kyrgyzstan", + "countryCode.LA.name": "Laon demokraattinen kansantasavalta", + "countryCode.LV.name": "Latvia", + "countryCode.LB.name": "Libanon", + "countryCode.LS.name": "Lesotho", + "countryCode.LR.name": "Liberia", + "countryCode.LY.name": "Libya", + "countryCode.LI.name": "Liechtenstein", + "countryCode.LT.name": "Liettua", + "countryCode.LU.name": "Luxemburg", + "countryCode.MO.name": "Macao", + "countryCode.MK.name": "Pohjois-Makedonia", + "countryCode.MG.name": "Madagaskar", + "countryCode.MW.name": "Malawi", + "countryCode.MY.name": "Malesia", + "countryCode.MV.name": "Malediivit", + "countryCode.ML.name": "Mali", + "countryCode.MT.name": "Malta", + "countryCode.MH.name": "Marshallinsaaret", + "countryCode.MQ.name": "Martinique", + "countryCode.MR.name": "Mauritania", + "countryCode.MU.name": "Mauritius", + "countryCode.YT.name": "Mayotte", + "countryCode.MX.name": "Meksiko", + "countryCode.FM.name": "Mikronesia (liittovaltiot)", + "countryCode.MD.name": "Moldova (tasavalta)", + "countryCode.MC.name": "Monaco", + "countryCode.MN.name": "Mongolia", + "countryCode.ME.name": "Mo ntenegro", + "countryCode.MS.name": "Montserrat", + "countryCode.MA.name": "Morocco", + "countryCode.MZ.name": "Mosambique", + "countryCode.MM.name": "Myanmar", + "countryCode.NA.name": "Namibia", + "countryCode.NR.name": "Nauru", + "countryCode.NP.name": "Nepal", + "countryCode.NL.name": "Alankomaat", + "countryCode.NC.name": "New Caledonia", + "countryCode.NZ.name": "New Zealand", + "countryCode.NI.name": "Nicaragua", + "countryCode.NE.name": "Niger", + "countryCode.NG.name": "Nigeria", + "countryCode.NU.name": "Niue", + "countryCode.NF.name": "Norfolk Island", + "countryCode.MP.name": "Pohjois-Mariaanit", + "countryCode.NO.name": "Norja", + "countryCode.OM.name": "Oman", + "countryCode.PK.name": "Pakistan", + "countryCode.PW.name": "Palau", + "countryCode.PS.name": "Palestine, State of", + "countryCode.PA.name": "Panama", + "countryCode.PG.name": "Papua-Uusi-Guinea", + "countryCode.PY.name": "Paraguay", + "countryCode.PE.name": "Peru", + "countryCode.PH.name": "Filippiinit", + "countryCode.PN.name": "Pitcairn", + "countryCode.PL.name": "Puola", + "countryCode.PT.name": "Portugali", + "countryCode.PR.name": "Puerto Rico", + "countryCode.QA.name": "Qatar", + "countryCode.RE.name": "Réunion", + "countryCode.RO.name": "Romania", + "countryCode.RU.name": "Venäjän federaatio", + "countryCode.RW.name": "Rwanda", + "countryCode.BL.name": "Saint Barthélemy", + "countryCode.SH.name": "Saint Helena Ascension Island Tristan da Cunha", + "countryCode.KN.name": "Saint Kitts and Nevis", + "countryCode.LC.name": "Saint Lucia", + "countryCode.MF.name": "Saint Martin (ranskalainen osa)", + "countryCode.PM.name": "Saint Pierre ja Miquelon", + "countryCode.VC.name": "Saint Vincent ja Grenadiinit", + "countryCode.WS.name": "Samoa", + "countryCode.SM.name": "San Marino", + "countryCode.ST.name": "Sao Tome ja Principe", + "countryCode.SA.name": "Saudi Arabia", + "countryCode.SN.name": "Senegal", + "countryCode.RS.name": "Serbia", + "countryCode.SC.name": "Seychellit", + "countryCode.SL.name": "Sierra Leone", + "countryCode.SG.name": "Singapore", + "countryCode.SX.name": "Sint Maarten (hollantilainen osa)", + "countryCode.SK.name": "Slovakia", + "countryCode.SI.name": "Slovenia", + "countryCode.SB.name": "Salomonsaaret", + "countryCode.SO.name": "Somalia", + "countryCode.ZA.name": "Etelä-Afrikka", + "countryCode.GS.name": "Etelä-Georgia ja Etelä-Sandwichsaaret", + "countryCode.SS.name": "South Sudan", + "countryCode.ES.name": "Espanja", + "countryCode.LK.name": "Sri Lanka", + "countryCode.SD.name": "Sudan", + "countryCode.SR.name": "Suriname", + "countryCode.SJ.name": "Svalbard, Jan Mayen", + "countryCode.SE.name": "Ruotsi", + "countryCode.CH.name": "Sveitsi", + "countryCode.SY.name": "Syyrian arabitasavalta", + "countryCode.TW.name": "Taiwan", + "countryCode.TJ.name": "Tajikistan", + "countryCode.TZ.name": "Tantsania, yhdysvaltalainen tasavalta", + "countryCode.TH.name": "Thaimaa", + "countryCode.TL.name": "Itä-Timor", + "countryCode.TG.name": "Togo", + "countryCode.TK.name": "Tokelau", + "countryCode.TO.name": "Tonga", + "countryCode.TT.name": "Trinidad ja Tobago", + "countryCode.TN.name": "Tunisia", + "countryCode.TR.name": "Turkey", + "countryCode.TM.name": "Turkmenistan", + "countryCode.TC.name": "Turks- ja Caicossaaret", + "countryCode.TV.name": "Tuvalu", + "countryCode.UG.name": "Uganda", + "countryCode.UA.name": "Ukraina", + "countryCode.AE.name": "Arabiemiirikunnat", + "countryCode.GB.name": "Ison-Britannian ja Pohjois-Irlannin Yhdistynyt kuningaskunta", + "countryCode.UM.name": "Yhdysvaltain pienet syrjäiset saaret", + "countryCode.US.name": "Yhdysvallat", + "countryCode.UY.name": "Uruguay", + "countryCode.UZ.name": "Uzbekistan", + "countryCode.VU.name": "Vanuatu", + "countryCode.VE.name": "Venezuela (Bolivarian tasavalta)", + "countryCode.VN.name": "Vietnam", + "countryCode.VG.name": "Neitsytsaaret (British)", + "countryCode.VI.name": "Neitsytsaaret (US)", + "countryCode.WF.name": "Wallis ja Futuna", + "countryCode.EH.name": "Länsi-Sahara", + "countryCode.YE.name": "Jemen", + "countryCode.ZM.name": "Sambia", + "countryCode.ZW.name": "Zimbabwe", + + "countryCode.AF.officialName": "Afganistanin islamilainen tasavalta", + "countryCode.AX.officialName": "Ahvenanmaa", + "countryCode.AL.officialName": "Albanian tasavalta", + "countryCode.DZ.officialName": "Algerian demokraattinen kansantasavalta", + "countryCode.AS.officialName": "Albanian alue American Samoa", + "countryCode.AD.officialName": "Andorran ruhtinaskunta", + "countryCode.AO.officialName": "Angolan tasavalta", + "countryCode.AI.officialName": "Anguilla", + "countryCode.AQ.officialName": "Kaikki maa- ja jäähyllyt 60. leveyden eteläpuolella", + "countryCode.AG.officialName": "Antigua ja Barbuda", + "countryCode.AR.officialName": "Argentiinan tasavalta", + "countryCode.AM.officialName": "Armenian tasavalta", + "countryCode.AW.officialName": "Aruba", + "countryCode.AU.officialName": "Australia", + "countryCode.AT.officialName": "Itävallan tasavalta", + "countryCode.AZ.officialName": "Azerbaidžanin tasavalta", + "countryCode.BS.officialName": "The Commonwealth of the Bahama", + "countryCode.BH.officialName": "The Kingdom of Bahrain", + "countryCode.BD.officialName": "The People's Republic ofBangladesh", + "countryCode.BB.officialName": "Barbados", + "countryCode.BY.officialName": "Valko-Venäjän tasavalta", + "countryCode.BE.officialName": "Belgian kuningaskunta", + "countryCode.BZ.officialName": "Belize", + "countryCode.BJ.officialName": "Beninin tasavalta", + "countryCode.BM.officialName": "Bermuda", + "countryCode.BT.officialName": "Bhutanin kuningaskunta", + "countryCode.BO.officialName": "Bolivian monikansallinen osavaltio", + "countryCode.BQ.officialName": "Bonaire, Sint Eustatius ja Saba", + "countryCode.BA.officialName": "Bosnia ja Hertsegovina", + "countryCode.BW.officialName": "Botswanan tasavalta", + "countryCode.BV.officialName": "Bouvet Island", + "countryCode.BR.officialName": "Brasilian liittotasavalta", + "countryCode.IO.officialName": "The British Indian Ocean Territory", + "countryCode.BN.officialName": "The Nation of Brunein, the Abode of Peace", + "countryCode.BG.officialName": "The Republic of Bulgaria", + "countryCode.BF.officialName": "Burkina Faso", + "countryCode.BI.officialName": "Burundin tasavalta", + "countryCode.CV.officialName": "Cabo Verden tasavalta", + "countryCode.KH.officialName": "The Kingdom of Kambodža", + "countryCode.CM.officialName": "Kamerunin tasavalta", + "countryCode.CA.officialName": "Kanada", + "countryCode.KY.officialName": "Caymansaaret", + "countryCode.CF.officialName": "Keski-Afrikan tasavalta", + "countryCode.TD.officialName": "Tšadin tasavalta", + "countryCode.CL.officialName": "Chilen tasavalta", + "countryCode.CN.officialName": "Ihmiset ' s Kiinan tasavalta", + "countryCode.CX.officialName": "The Territory of Christmas Island", + "countryCode.CC.officialName": "The Territory of Cocos (Keeling) Islands", + "countryCode.CO.officialName": "Kolumbian tasavalta", + "countryCode.KM.officialName": "The Union of the Comoris", + "countryCode.CD.officialName": "Kongon demokraattinen tasavalta", + "countryCode.CG.officialName": "Tasavalta Kongo", + "countryCode.CK.officialName": "Cookinsaaret", + "countryCode.CR.officialName": "Costa Rican tasavalta", + "countryCode.CI.officialName": "Côte d':n tasavalta Norsunluurannikko", + "countryCode.HR.officialName": "Kroatian tasavalta", + "countryCode.CU.officialName": "Kuuban tasavalta", + "countryCode.CW.officialName": "The Country of Curaçao", + "countryCode.CY.officialName": "Kyproksen tasavalta", + "countryCode.CZ.officialName": "Tšekki", + "countryCode.DK.officialName": "Tanskan kuningaskunta", + "countryCode.DJ.officialName": "Tasavalta lic of Djibouti", + "countryCode.DM.officialName": "The Commonwealth of Dominica", + "countryCode.DO.officialName": "Dominikaaninen tasavalta", + "countryCode.EC.officialName": "Ecuadorin tasavalta", + "countryCode.EG.officialName": "Egyptin arabitasavalta", + "countryCode.SV.officialName": "El Salvadorin tasavalta", + "countryCode.GQ.officialName": "Equatorial Guinean tasavalta", + "countryCode.ER.officialName": "Eritrean osavaltio", + "countryCode.EE.officialName": "Viron tasavalta", + "countryCode.SZ.officialName": "Eswatinin kuningaskunta", + "countryCode.ET.officialName": "Etiopian demokraattinen liittotasavalta", + "countryCode.FK.officialName": "Falklandinsaaret", + "countryCode.FO.officialName": "Färsaaret", + "countryCode.FJ.officialName": "Fidžin tasavalta", + "countryCode.FI.officialName": "Suomen tasavalta", + "countryCode.FR.officialName": "Ranskan tasavalta", + "countryCode.GF.officialName": "Guyane", + "countryCode.PF.OfficialName": "Ranskan Polynesia", + "countryCode.TF.officialName": "Ranskan etelä- ja Etelämannermaat", + "countryCode.GA.officialName": "Gabonin tasavalta", + "countryCode.GM.officialName": "Republic of the Gambia", + "countryCode.GE.officialName": "Georgia", + "countryCode.DE.officialName": "Saksan liittotasavalta", + "countryCode.GH.officialName": "Ghanan tasavalta", + "countryCode.GI.officialName": "Gibraltar", + "countryCode.GR.officialName": "Hellenic Republic", + "countryCode.GL.officialName": "Kalaallit Nunaat", + "countryCode.GD.officialName": "Grenada", + "countryCode.GP.officialName": "Guadeloupe", + "countryCode.GU.officialName": "The Territory of Guamin", + "countryCode.GT.officialName": "Guatemalan tasavalta", + "countryCode.GG.officialName": "The Bailiwick of Guernsey", + "countryCode.GN.officialName": "Guinean tasavalta", + "countryCode.GW.officialName": "Guinea-Bissaun tasavalta", + "countryCode.GY.officialName": "The Co- o perative Guyanan tasavalta", + "countryCode.HT.officialName": "Haitin tasavalta", + "countryCode.HM.officialName": "The Territory of Heard Island ja McDonald Islands", + "countryCode.VA.officialName": "The Holy See", + "countryCode.HN.officialName": "Hondurasin tasavalta", + "countryCode.HK.officialName": "Kiinan Hongkongin erityishallintoalue", + "countryCode.HU.officialName": "Unkari", + "countryCode.IS.officialName": "Islanti", + "countryCode.IN.officialName": "Intian tasavalta", + "countryCode.ID.officialName": "Indonesian tasavalta", + "countryCode.IR.officialName": "Iranin islamilainen tasavalta", + "countryCode.IQ.officialName": "Irakin tasavalta", + "countryCode.IE.officialName": "Irlanti", + "countryCode.IM.officialName": "Mansaari", + "countryCode.IL.officialName": "Israelin valtio", + "countryCode.IT.officialName": "Italian tasavalta", + "countryCode.JM.officialName": "Jamaika", + "countryCode.JP.officialName": "Japani", + "countryCode.JE.officialName": "The Bailiwick of Jersey", + "countryCode.JO.officialName": "Jordanin hašemiittien kuningaskunta", + "countryCode.KZ.officialName": "Kazakstanin tasavalta", + "countryCode.KE.officialName": "Kenian tasavalta", + "countryCode.KI.officialName": "Kiribatin tasavalta", + "countryCode.KP.officialName": "Korean demokraattinen kansantasavalta", + "countryCode.KR.officialName": "Korean tasavalta", + "countryCode.KW.officialName": "Kuwaitin osavaltio", + "countryCode.KG.officialName": "Kirgisian tasavalta", + "countryCode.LA.officialName": "Laosin kansandemokraattinen Tasavalta", + "countryCode.LV.officialName": "Latvian tasavalta", + "countryCode.LB.officialName": "Libanonin tasavalta", + "countryCode.LS.officialName": "Lesothon kuningaskunta", + "countryCode.LR.officialName": "Liberian tasavalta", + "countryCode.LY.officialName": "Libyan valtio", + "countryCode.LI.officialName": "Liechtensteinin ruhtinaskunta", + "countryCode.LT.officialName": "Liettuan tasavalta", + "countryCode.LU.officialName": "Luxemburgin suurherttuakunta", + "countryCode.MO.officialName": "Kiinan Macaon erityishallintoalue", + "countryCode.MK.officialName": "Pohjois-Makedonian tasavalta", + "countryCode.MG.officialName": "Madagaskarin tasavalta", + "countryCode.MW.officialName": "Malain tasavalta", + "countryCode.MY.officialName": "Malesia", + "countryCode.MV.officialName": "The Republic of Maledives", + "countryCode.ML.officialName": "The Republic of Mali", + "countryCode.MT.officialName": "The Republic of Malta", + "countryCode.MH.officialName": "Marshallinsaarten tasavalta", + "countryCode.MQ.officialName": "Martinique", + "countryCode.MR.officialName": "Mauritanian islamilainen tasavalta", + "countryCode.MU.officialName": "Mauritiuksen tasavalta", + "countryCode.YT.officialName": "Department of Mayotte", + "countryCode.MX.officialName": "Meksikon Yhdysvallat", + "countryCode.FM.officialName": "Mikronesian liittovaltiot", + "countryCode.MD.officialName": "Moldovan tasavalta", + "countryCode.MC.officialName": "Monacon ruhtinaskunta", + "countryCode.MN.officialName": "Mongolian osavaltio", + "countryCode.ME.officialName": "Montenegro", + "countryCode.MS.officialName": "Montserrat", + "countryCode.MA.officialName": "The Kingdom of Marocco", + "countryCode.MZ.officialName": "Mosambikin tasavalta", + "countryCode.MM.officialName": "Myanmarin liiton tasavalta", + "countryCode.NA.officialName": "Namibian tasavalta", + "countryCode.NR.officialName": "Naurun tasavalta", + "countryCode.NP.officialName": "Nepalin demokraattinen liittotasavalta", + "countryCode.NL.officialName": "Alankomaiden kuningaskunta", + "countryCode.NC.officialName": "Uusi-Kaledonia", + "countryCode.NZ.officialName": "Uusi-Seelanti", + "countryCode.NI.officialName": "Nicaraguan tasavalta", + "countryCode.NE.officialName": "The tasavalta Niger", + "countryCode.NG.officialName": "Nigerian liittotasavalta", + "countryCode.NU.officialName": "Niue", + "countryCode.NF.officialName": "The Territory of Norfolk Island", + "countryCode.MP.officialName": "Pohjoisten Mariaanien", + "countryCode.NO.officialName": "Norjan kuningaskunta", + "countryCode.OM.officialName": "Omanin sulttaanikunta", + "countryCode.PK.officialName": "Pakistanin islamilainen tasavalta", + "countryCode.PW.officialName": "Palaun tasavalta", + "countryCode.PS.officialName": "Palestiinan valtio", + "countryCode.PA.officialName": "Panaman tasavalta", + "countryCode.PG.officialName": "Papua-Uuden-Guinean itsenäinen osavaltio", + "countryCode.PY.officialName": "Paraguayn tasavalta", + "countryCode.PE.officialName": "Perun Tasavalta", + "countryCode.PH.officialName": "Filippiinin tasavalta", + "countryCode.PN.officialName": "Pitcairn-, Henderson-, Ducie- ja Oenosaaret", + "countryCode.PL.officialName": "Puolan tasavalta", + "countryCode.PT.officialName": "Portugalin tasavalta", + "countryCode.PR.officialName": "Puerto Ricon liitto", + "countryCode.QA.officialName": "The Qatarin osavaltio", + "countryCode.RE.officialName": "Réunion", + "countryCode.RO.officialName": "Romania", + "countryCode.RU.officialName": "Venäjän federaatio", + "countryCode.RW.officialName": "Ruandan tasavalta", + "countryCode.BL.officialName": "The Collectivity of Saint-Barthélemy", + "countryCode.SH.officialName": "Saint Helena, Ascension and Tristan da Cunha", + "countryCode.KN.officialName": "Saint Kitts ja Nevis", + "countryCode.LC.officialName": "Saint Lucia", + "countryCode.MF.officialName": "The Collectivity of Saint-Martin", + "countryCode.PM.officialName": "The Overseas Saint-Pierren ja Miquelonin yhteisö", + "countryCode.VC.officialName": "Saint Vincent ja Grenadiinit", + "countryCode.WS.officialName": "The Independent State of Samoa", + "countryCode.SM.officialName": "San Marinon tasavalta", + "countryCode.ST.officialName": "São Tomén ja Príncipen demokraattinen tasavalta", + "countryCode.SA.officialName": "Saudi-Arabian kuningaskunta", + "countryCode.SN.officialName": "Senegalin tasavalta", + "countryCode.RS.officialName": "Serbian tasavalta", + "countryCode.SC.officialName": "Seychellien tasavalta", + "countryCode.SL.officialName": "Sierra Leonen tasavalta", + "countryCode.SG.officialName": "Singaporen tasavalta", + "countryCode.SX.officialName": "Sint Maarten", + "countryCode.SK.officialName": "Slovakian tasavalta", + "countryCode.SI.officialName": "Slovenian tasavalta", + "countryCode.SB.officialName": "Salomonsaaret", + "countryCode.SO.officialName": "Somalian liittotasavalta", + "countryCode.ZA.officialName": "Etelä-Afrikan tasavalta", + "countryCode.GS.officialName": "Etelä-Georgia ja Etelä-Sandwichsaaret", + "countryCode.SS.officialName": "Etelä-Sudin tasavalta", + "countryCode.ES.officialName": "Espanjan kuningaskunta", + "countryCode.LK.officialName": "Sri Lankan demokraattinen sosialistinen tasavalta", + "countryCode.SD.officialName": "Sudanin tasavalta", + "countryCode.SR.officialName": "Surinamen tasavalta", + "countryCode.SJ.officialName": "Svalbard and Jan Mayen", + "countryCode.SE.officialName": "Ruotsin kuningaskunta", + "countryCode.CH.officialName": "Sveitsin valaliitto", + "countryCode.SY.officialName": "Syyrian arabitasavalta", + "countryCode.TW.officialName": "Kiinan tasavalta", + "countryCode.TJ.officialName": "Tadžikistanin tasavalta", + "countryCode.TZ.officialName": "Tansanian yhdistynyt tasavalta", + "countryCode.TH.officialName": "Thaimaan kuningaskunta", + "countryCode.TL.officialName": "Demokraattinen tasavalta Itä-Timor", + "countryCode.TG.officialName": "Togon tasavalta", + "countryCode.TK.officialName": "Tokelau", + "countryCode.TO.officialName": "Tongan kuningaskunta", + "countryCode.TT.officialName": "Trinidadin ja Tobagon tasavalta", + "countryCode.TN.officialName": "Tunisian tasavalta", + "countryCode.TR.officialName": "Turkin tasavalta", + "countryCode.TM.officialName": "Turkmenistan", + "countryCode.TC.officialName": "Turks- ja Caicossaaret", + "countryCode.TV.officialName": "Tuvalu", + "countryCode.UG.officialName": "Ugandan tasavalta", + "countryCode.UA.officialName": "Ukraina", + "countryCode.AE.officialName": "Arabiemiirikunnat", + "countryCode.GB.officialName": "Ison-Britannian ja Pohjois-Irlannin yhdistynyt kuningaskunta", + "countryCode.UM.officialName": "Baker Island, Howland Island, Jarvis Island, Johnston Atoll, Kingman Reef, Midway Atoll, Navassa Island, Palmyra Atoll ja Wake Island", + "countryCode.US.officialName": "Amerikan Yhdysvallat", + "countryCode.UY.officialName": "Uruguayn itäinen tasavalta", + "countryCode.UZ.officialName": "Uzbekistanin tasavalta", + "countryCode.VU.officialName": "The Vanuatun tasavalta", + "countryCode.VE.officialName": "Venezuelan Bolivarinen tasavalta", + "countryCode.VN.officialName": "Vietnamin sosialistinen tasavalta", + "countryCode.VG.officialName": "Neitsytsaaret", + "countryCode.VI.officialName": "Yhdysvaltojen Neitsytsaaret", + "countryCode.WF.officialName": "The Territory of the Wallis and Futuna Islands", + "countryCode.EH.officialName": "The Sahrawi Demokraattinen arabitasavalta", + "countryCode.YE.officialName": "Jemenin tasavalta", + "countryCode.ZM.officialName": "Sambian tasavalta", + "countryCode.ZW.officialName": "Zimbabwen tasavalta" + +} diff --git a/translations/country-translation.ts b/translations/country-translation.ts new file mode 100644 index 0000000..d78799a --- /dev/null +++ b/translations/country-translation.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { CountryCode } from "../types/CountryCode"; + +export function getCountryNameTranslationKey (code: CountryCode) : string { + return `countryCode.${code}.name`; +} diff --git a/translations/index.ts b/translations/index.ts new file mode 100644 index 0000000..6c18413 --- /dev/null +++ b/translations/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +import { TranslationResourceObject } from "../types/TranslationResourceObject"; +import en from "./country-en.json"; +import fi from "./country-fi.json"; + +export const TRANSLATIONS : TranslationResourceObject ={ + en, + fi +}; diff --git a/twilio/TwilioMessageClient.ts b/twilio/TwilioMessageClient.ts new file mode 100644 index 0000000..03c5a1c --- /dev/null +++ b/twilio/TwilioMessageClient.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { TwilioMessageDTO } from "./dto/TwilioMessageDTO"; +import { TwilioCreateMessageContent } from "./dto/types/TwilioCreateMessageContent"; +import { TwilioCreateMessageRecipient } from "./dto/types/TwilioCreateMessageRecipient"; +import { TwilioCreateMessageSender } from "./dto/types/TwilioCreateMessageSender"; + +/** + * Twilio Messages API Client + * @see https://www.twilio.com/docs/sms/api/message-resource#create-a-message-resource + */ +export interface TwilioMessageClient { + + /** + * Initiates sending of SMS message + * + * @param content The intended content of the message + * @param recipient The intended recipient + * @param sender The intended sender of the message. If not specified, the implementation should use a configurable sender. + * @see https://op-developer.fi/products/banking/docs/op-corporate-payment-api#operation/payment + */ + sendSms ( + content : string | TwilioCreateMessageContent, + recipient : string | TwilioCreateMessageRecipient, + sender ?: string | TwilioCreateMessageSender, + ): Promise; + +} diff --git a/twilio/TwilioMessageClientImpl.system.test.ts b/twilio/TwilioMessageClientImpl.system.test.ts new file mode 100644 index 0000000..a30f9fc --- /dev/null +++ b/twilio/TwilioMessageClientImpl.system.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ProcessUtils } from "../ProcessUtils"; + +ProcessUtils.initEnvFromDefaultFiles(); + +// @ts-ignore +import { HgNode } from "../../node/HgNode"; +// @ts-ignore +import { NodeRequestClient } from "../../node/requestClient/node/NodeRequestClient"; +import { TwilioMessageClient } from "./TwilioMessageClient"; +import { TwilioMessageDTO } from "./dto/TwilioMessageDTO"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { Headers } from "../request/types/Headers"; +import { TwilioMessageClientImpl } from "./TwilioMessageClientImpl"; +import { LogLevel } from "../types/LogLevel"; + +const ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID ?? ''; +const AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN ?? ''; + +// const UNAVAIlABLE_PHONE_NUMBER = '+15005550000'; +// const INVALID_PHONE_NUMBER = '+15005550001'; +// const AVAILABLE_PHONE_NUMBER = '+15005550006'; + +const FROM_TEST_NUMBER_VALID = '+15005550006'; +// const FROM_TEST_NUMBER_SMS_QUEUE_FULL = '+15005550008'; +// const FROM_TEST_NUMBER_NOT_OWNED_BY_ACCOUNT_OR_NOT_SMS_CAPABLE = '+15005550007'; +// const FROM_TEST_NUMBER_INVALID = '+15005550001'; +// +// const TO_TEST_NUMBER_CANNOT_ROUTE = '+15005550002'; +// const TO_TEST_NUMBER_INVALID = '+15005550001'; +// const TO_TEST_NUMBER_NO_INTERNATIONAL_PERMISSION = '+15005550003'; +// const TO_TEST_NUMBER_BLOCKED = '+15005550004'; +// const TO_TEST_NUMBER_INCAPABLE_TO_RECEIVE = '+15005550009'; +const TO_TEST_NUMBER_VALID = '+358407099704'; + +/** + * To run these tests, create `.env` file like this: + * ``` + * TWILIO_API_SERVER=https://api.twilio.com + * TWILIO_ACCOUNT_SID=yourAccountSid + * TWILIO_AUTH_TOKEN=yourAuthToken + * ``` + * + * @see https://www.twilio.com/docs/sms/api/message-resource#create-a-message-resource + */ +describe('system', () => { + + (ACCOUNT_SID ? describe : describe.skip)('TwilioMessageClientImpl', () => { + let client : TwilioMessageClient; + + beforeAll(() => { + Headers.setLogLevel(LogLevel.NONE); + RequestClientImpl.setLogLevel(LogLevel.NONE); + NodeRequestClient.setLogLevel(LogLevel.NONE); + TwilioMessageClientImpl.setLogLevel(LogLevel.NONE); + HgNode.initialize(); + }); + + beforeEach(async () => { + client = TwilioMessageClientImpl.create( + ACCOUNT_SID, + AUTH_TOKEN, + ); + }); + + describe('#sendSms', () => { + + it('should return a successful response with valid input using plain strings', async () => { + + const smsResponse : TwilioMessageDTO = await client.sendSms( + "Test SMS", + TO_TEST_NUMBER_VALID, + FROM_TEST_NUMBER_VALID, + ); + + expect(smsResponse).toBeDefined(); + expect(smsResponse.sid).toBeDefined(); + expect(smsResponse.status).toBeDefined(); + expect(smsResponse.body).toBe("Test SMS"); + + }); + + }); + + }); + +}); diff --git a/twilio/TwilioMessageClientImpl.test.ts b/twilio/TwilioMessageClientImpl.test.ts new file mode 100644 index 0000000..0bcf64c --- /dev/null +++ b/twilio/TwilioMessageClientImpl.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { jest } from '@jest/globals'; +import { ReadonlyJsonObject } from "../Json"; +import { RequestClient } from "../RequestClient"; +import { LogLevel } from "../types/LogLevel"; +import { parseTwilioMessageDTO, TwilioMessageDTO } from "./dto/TwilioMessageDTO"; +import { TwilioCreateMessageBody } from "./dto/types/TwilioCreateMessageBody"; +import { TwilioCreateMessageRecipient } from "./dto/types/TwilioCreateMessageRecipient"; +import { TwilioCreateMessageSender } from "./dto/types/TwilioCreateMessageSender"; +import { TwilioMessageDirection } from "./dto/types/TwilioMessageDirection"; +import { TwilioMessageStatus } from "./dto/types/TwilioMessageStatus"; +import { TwilioMessageClientImpl } from "./TwilioMessageClientImpl"; + +const mockMessageBodyString: string = "This is a message body"; +const mockRecipientString: string = "+123456789"; +const mockSenderString: string = "+987654321"; + +// const mockMediaUrl: TwilioCreateMessageMediaUrl = { MediaUrl: "http://example.com/image.jpg" }; +// const mockMessageContentSid: TwilioCreateMessageContentSid = { ContentSid: "SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" }; +const mockMessageBody: TwilioCreateMessageBody = { Body: "This is a message body" }; +const mockRecipient: TwilioCreateMessageRecipient = { To: "+123456789" }; +const mockSender: TwilioCreateMessageSender = { From: "+987654321" }; +// const mockUndefined: unknown = undefined; +// const mockInvalid: unknown = 42; // this is an invalid type to pass to these functions +// const mockTwilioCreateMessageDTO = { +// To: "+123456789", +// From: "+987654321", +// Body: "This is a message body" +// }; +const mockTwilioCreateMessageBody = 'To=%2B123456789&From=%2B987654321&Body=This+is+a+message+body'; + +// Assuming a mock ReadonlyJsonObject that fits your requirements +const mockSubresourceUris: ReadonlyJsonObject = { media: "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Media.json" }; + +const mockTwilioMessage: TwilioMessageDTO = { + account_sid: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + api_version: "2010-04-01", + body: "Hi there", + date_created: "Thu, 30 Jul 2015 20:12:31 +0000", + date_sent: "Thu, 30 Jul 2015 20:12:33 +0000", + date_updated: "Thu, 30 Jul 2015 20:12:33 +0000", + direction: TwilioMessageDirection.OUTBOUND_API, // Assuming a correct value here + error_code: null, + error_message: null, + from: "+15557122661", + messaging_service_sid: "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + num_media: "0", + num_segments: "1", + price: null, + price_unit: null, + sid: "SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + status: TwilioMessageStatus.SENT, // Assuming a correct value here + subresource_uris: mockSubresourceUris, + to: "+15558675310", + uri: "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json" +}; + +class MockRequestClient { + postText = jest.fn<() => Promise>(); + + getClient() { + throw new Error("Method not implemented."); + } +} + +beforeAll(() => { + TwilioMessageClientImpl.setLogLevel(LogLevel.NONE); +}) + +describe('TwilioMessageClientImpl', () => { + let mockRequestClient: MockRequestClient; + let twilioClient: TwilioMessageClientImpl; + + const accountSid = 'TestAccountSid'; + const authToken = 'TestAuthToken'; + + beforeEach(() => { + mockRequestClient = new MockRequestClient(); + twilioClient = TwilioMessageClientImpl.create( + accountSid, + authToken, + mockSender, + mockRequestClient as unknown as RequestClient, + ); + }); + + it('should create new instance correctly', () => { + expect(twilioClient).toBeInstanceOf(TwilioMessageClientImpl); + }); + + describe('sendSms', () => { + // let mockDto: TwilioCreateMessageDTO; + // + // beforeEach(() => { + // mockDto = mockTwilioCreateMessageDTO; + // }); + + it('should send SMS successfully using DTOs', async () => { + const mockResponse: TwilioMessageDTO = mockTwilioMessage; + const responseJson = JSON.stringify(mockResponse); + + mockRequestClient.postText.mockResolvedValue(responseJson); + + const result = await twilioClient.sendSms( + mockMessageBody, + mockRecipient, + mockSender, + ); + + expect(mockRequestClient.postText).toBeCalledTimes(1); + expect(mockRequestClient.postText).toBeCalledWith( + `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, + mockTwilioCreateMessageBody, + { + 'Authorization': 'Basic VGVzdEFjY291bnRTaWQ6VGVzdEF1dGhUb2tlbg==', + 'Content-Type': 'application/x-www-form-urlencoded' + } + ); + expect(parseTwilioMessageDTO(result)).toStrictEqual(mockResponse); + }); + + it('should send SMS successfully using strings', async () => { + const mockResponse: TwilioMessageDTO = mockTwilioMessage; + const responseJson = JSON.stringify(mockResponse); + + mockRequestClient.postText.mockResolvedValue(responseJson); + + const result = await twilioClient.sendSms( + mockMessageBodyString, + mockRecipientString, + mockSenderString, + ); + + expect(mockRequestClient.postText).toBeCalledTimes(1); + expect(mockRequestClient.postText).toBeCalledWith( + `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, + mockTwilioCreateMessageBody, + { + 'Authorization': 'Basic VGVzdEFjY291bnRTaWQ6VGVzdEF1dGhUb2tlbg==', + 'Content-Type': 'application/x-www-form-urlencoded' + } + ); + expect(parseTwilioMessageDTO(result)).toStrictEqual(mockResponse); + }); + + it('should send SMS successfully using strings with default sender', async () => { + const mockResponse: TwilioMessageDTO = mockTwilioMessage; + const responseJson = JSON.stringify(mockResponse); + + mockRequestClient.postText.mockResolvedValue(responseJson); + + const result = await twilioClient.sendSms( + mockMessageBodyString, + mockRecipientString, + ); + + expect(mockRequestClient.postText).toBeCalledTimes(1); + expect(mockRequestClient.postText).toBeCalledWith( + `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, + mockTwilioCreateMessageBody, + { + 'Authorization': 'Basic VGVzdEFjY291bnRTaWQ6VGVzdEF1dGhUb2tlbg==', + 'Content-Type': 'application/x-www-form-urlencoded' + } + ); + expect(parseTwilioMessageDTO(result)).toStrictEqual(mockResponse); + }); + + it('should throw an error when the response is not TwilioMessageDTO', async () => { + mockRequestClient.postText.mockResolvedValue('{"invalid": "response"}'); + + await expect(twilioClient.sendSms( + mockMessageBody, + mockRecipient, + mockSender, + )).rejects.toThrow(TypeError); + expect(mockRequestClient.postText).toBeCalledTimes(1); + }); + + }); + +}); diff --git a/twilio/TwilioMessageClientImpl.ts b/twilio/TwilioMessageClientImpl.ts new file mode 100644 index 0000000..78cf2b9 --- /dev/null +++ b/twilio/TwilioMessageClientImpl.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { AuthorizationUtils } from "../AuthorizationUtils"; +import { JsonAny, parseJson } from "../Json"; +import { LogService } from "../LogService"; +import { QueryParams, QueryParamUtils } from "../QueryParamUtils"; +import { RequestClient } from "../RequestClient"; +import { RequestClientImpl } from "../RequestClientImpl"; +import { LogLevel } from "../types/LogLevel"; +import { isString } from "../types/String"; +import { createTwilioCreateMessageDTO } from "./dto/TwilioCreateMessageDTO"; +import { explainTwilioMessageDTO, isTwilioMessageDTO, TwilioMessageDTO } from "./dto/TwilioMessageDTO"; +import { TwilioCreateMessageContent } from "./dto/types/TwilioCreateMessageContent"; +import { TwilioCreateMessageRecipient } from "./dto/types/TwilioCreateMessageRecipient"; +import { TwilioCreateMessageSender } from "./dto/types/TwilioCreateMessageSender"; +import { TWILIO_CREATE_MESSAGE_PATH } from "./twilio-constants"; +import { TwilioMessageClient } from "./TwilioMessageClient"; + +const LOG = LogService.createLogger( 'TwilioMessageClientImpl' ); + +/** + * Minimal Twilio SMS message API client + */ +export class TwilioMessageClientImpl implements TwilioMessageClient { + + private readonly _client : RequestClient; + private readonly _accountSid : string; + private readonly _sender ?: string | TwilioCreateMessageSender | undefined; + + /** + * @fixme Not ideal to save secrets like this, however not sure what would + * be better way to do it right now. Maybe hide the full HTTP request + * behind another API that has the secret referenced from outside. + * @todo We could use Twilio API and maybe a microservice to + * create new token for each session. Then, deleting a session API + * key would make it possible to log out of the session securely. + * @private + */ + private readonly _token: string; + + public static setLogLevel (level: LogLevel) { + LOG.setLogLevel(level); + } + + private constructor ( + client : RequestClient, + accountSid : string, + token : string, + sender ?: string | TwilioCreateMessageSender | undefined, + ) { + this._client = client; + this._accountSid = accountSid; + this._token = token; + this._sender = sender; + } + + /** + * @param client + * @param accountSid + * @param authToken + * @param sender + */ + public static create ( + accountSid : string, + authToken : string, + sender ?: string | TwilioCreateMessageSender | undefined, + client ?: RequestClient, + ) : TwilioMessageClientImpl { + return new TwilioMessageClientImpl( + client ?? RequestClientImpl, + accountSid, + AuthorizationUtils.createBasicHeaderTokenWithUserAndPassword(accountSid, authToken), + sender, + ); + } + + /** + * @inheritDoc + */ + public async sendSms ( + content : string | TwilioCreateMessageContent, + recipient : string | TwilioCreateMessageRecipient, + sender ?: string | TwilioCreateMessageSender | undefined, + ): Promise { + const verifiedSender = sender ?? this._sender; + if (!verifiedSender) throw new TypeError('There was no sender configured.'); + const body = createTwilioCreateMessageDTO( + isString(recipient) ? { To: recipient } : recipient, + isString(verifiedSender) ? {From: verifiedSender} : verifiedSender, + isString(content) ? { Body: content } : content, + ); + const bodyString : string = QueryParamUtils.stringifyQueryParamsOnly(body as unknown as QueryParams); + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': AuthorizationUtils.createBasicHeader(this._token), + }; + const resultString : string | undefined = await this._client.postText( + TWILIO_CREATE_MESSAGE_PATH(this._accountSid), + bodyString, + headers + ); + const dto : JsonAny | undefined = parseJson(resultString!); + if (!isTwilioMessageDTO(dto)) { + LOG.debug(`sendSms: Response = `, dto); + throw new TypeError(`sendSms: Response was not TwilioMessageDTO: ${explainTwilioMessageDTO(dto)}`); + } + return dto; + } + +} diff --git a/twilio/dto/TwilioCreateMessageDTO.test.ts b/twilio/dto/TwilioCreateMessageDTO.test.ts new file mode 100644 index 0000000..cf2403e --- /dev/null +++ b/twilio/dto/TwilioCreateMessageDTO.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { createTwilioCreateMessageDTO, explainTwilioCreateMessageDTO, explainTwilioCreateMessageDTOOrUndefined, isTwilioCreateMessageDTO, isTwilioCreateMessageDTOOrUndefined, parseTwilioCreateMessageDTO } from "./TwilioCreateMessageDTO"; +import { TwilioCreateMessageMediaUrl } from "./types/TwilioCreateMessageMediaUrl"; +import { TwilioCreateMessageRecipient } from "./types/TwilioCreateMessageRecipient"; +import { TwilioCreateMessageSender } from "./types/TwilioCreateMessageSender"; + +const mockMediaUrl: TwilioCreateMessageMediaUrl = { MediaUrl: "http://example.com/image.jpg" }; +// const mockMessageBody: TwilioCreateMessageBody = { Body: "This is a message body" }; +// const mockMessageContentSid: TwilioCreateMessageContentSid = { ContentSid: "SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" }; +const mockRecipient: TwilioCreateMessageRecipient = { To: "+123456789" }; +const mockSender: TwilioCreateMessageSender = { From: "+987654321" }; +const mockUndefined: unknown = undefined; +const mockInvalid: unknown = 42; // this is an invalid type to pass to these functions +const mockTwilioCreateMessageDTO = { ...mockRecipient, ...mockSender, ...mockMediaUrl }; + +describe('TwilioCreateMessageDTO', () => { + + describe('createTwilioCreateMessageDTO', () => { + it('returns the correct DTO', () => { + expect(createTwilioCreateMessageDTO(mockRecipient, mockSender, mockMediaUrl)).toEqual(mockTwilioCreateMessageDTO); + }); + }); + + describe('isTwilioCreateMessageDTO', () => { + + it('returns true for valid DTOs', () => { + expect(isTwilioCreateMessageDTO({ ...mockRecipient, ...mockSender, ...mockMediaUrl })).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isTwilioCreateMessageDTO(mockInvalid)).toBe(false); + }); + + }); + + describe('explainTwilioCreateMessageDTO', () => { + it('returns a correct explanation for valid and invalid DTOs', () => { + expect(explainTwilioCreateMessageDTO({ ...mockRecipient, ...mockSender, ...mockMediaUrl })).toEqual(explainOk()); + expect(explainTwilioCreateMessageDTO(mockInvalid)).toEqual(explainNot('TwilioCreateMessageDTO')); + }); + }); + + describe('parseTwilioCreateMessageDTO', () => { + it('returns the DTO for valid DTOs and undefined for invalid values', () => { + expect(parseTwilioCreateMessageDTO({ ...mockRecipient, ...mockSender, ...mockMediaUrl })).toEqual({ ...mockRecipient, ...mockSender, ...mockMediaUrl }); + expect(parseTwilioCreateMessageDTO(mockInvalid)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageDTOOrUndefined', () => { + it('returns true for valid DTOs and undefined, false for invalid values', () => { + expect(isTwilioCreateMessageDTOOrUndefined({ ...mockRecipient, ...mockSender, ...mockMediaUrl })).toBe(true); + expect(isTwilioCreateMessageDTOOrUndefined(mockUndefined)).toBe(true); + expect(isTwilioCreateMessageDTOOrUndefined(mockInvalid)).toBe(false); + }); + }); + + describe('explainTwilioCreateMessageDTOOrUndefined', () => { + + it('returns a correct explanation for valid DTOs', () => { + expect(explainTwilioCreateMessageDTOOrUndefined({ ...mockRecipient, ...mockSender, ...mockMediaUrl })).toEqual(explainOk()); + }); + + it('returns a correct explanation for undefined', () => { + expect(explainTwilioCreateMessageDTOOrUndefined(mockUndefined)).toEqual(explainOk()); + }); + + it('returns a correct explanation for invalid values', () => { + expect(explainTwilioCreateMessageDTOOrUndefined(mockInvalid)).toEqual(explainNot(explainOr(['TwilioCreateMessageDTO', 'undefined']))); + }); + + }); + +}); diff --git a/twilio/dto/TwilioCreateMessageDTO.ts b/twilio/dto/TwilioCreateMessageDTO.ts new file mode 100644 index 0000000..b4f0f2d --- /dev/null +++ b/twilio/dto/TwilioCreateMessageDTO.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { isUndefined } from "../../types/undefined"; +import { isTwilioCreateMessageContent, TwilioCreateMessageContent } from "./types/TwilioCreateMessageContent"; +import { isTwilioCreateMessageRecipient, TwilioCreateMessageRecipient } from "./types/TwilioCreateMessageRecipient"; +import { isTwilioCreateMessageSender, TwilioCreateMessageSender } from "./types/TwilioCreateMessageSender"; + +export type TwilioCreateMessageDTO = ( + TwilioCreateMessageRecipient + & TwilioCreateMessageSender + & TwilioCreateMessageContent +); + +export function createTwilioCreateMessageDTO ( + recipient : TwilioCreateMessageRecipient, + sender : TwilioCreateMessageSender, + content : TwilioCreateMessageContent +) : TwilioCreateMessageDTO { + return { + ...recipient, + ...sender, + ...content + }; +} + +export function isTwilioCreateMessageDTO (value: unknown) : value is TwilioCreateMessageDTO { + return ( + isTwilioCreateMessageRecipient(value) + && isTwilioCreateMessageSender(value) + && isTwilioCreateMessageContent(value) + ); +} + +export function explainTwilioCreateMessageDTO (value: any) : string { + return isTwilioCreateMessageDTO(value) ? explainOk() : explainNot('TwilioCreateMessageDTO'); +} + +export function parseTwilioCreateMessageDTO (value: unknown) : TwilioCreateMessageDTO | undefined { + if (isTwilioCreateMessageDTO(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageDTOOrUndefined (value: unknown): value is TwilioCreateMessageDTO | undefined { + return isUndefined(value) || isTwilioCreateMessageDTO(value); +} + +export function explainTwilioCreateMessageDTOOrUndefined (value: unknown): string { + return isTwilioCreateMessageDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageDTO', 'undefined'])); +} + diff --git a/twilio/dto/TwilioMessageDTO.test.ts b/twilio/dto/TwilioMessageDTO.test.ts new file mode 100644 index 0000000..be4e24a --- /dev/null +++ b/twilio/dto/TwilioMessageDTO.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { ReadonlyJsonObject } from "../../Json"; +import { explainNot, explainOk, explainOr } from "../../types/explain"; +import { TwilioMessageDTO } from "./TwilioMessageDTO"; +import { TwilioMessageDirection } from "./types/TwilioMessageDirection"; +import { TwilioMessageStatus } from "./types/TwilioMessageStatus"; +import { createTwilioMessageDTO, explainTwilioMessageDTO, explainTwilioMessageDTOOrUndefined, isTwilioMessageDTO, isTwilioMessageDTOOrUndefined, parseTwilioMessageDTO } from './TwilioMessageDTO'; + +// Assuming a mock ReadonlyJsonObject that fits your requirements +const mockSubresourceUris: ReadonlyJsonObject = { media: "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Media.json" }; + +const mockTwilioMessage: TwilioMessageDTO = { + account_sid: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + api_version: "2010-04-01", + body: "Hi there", + date_created: "Thu, 30 Jul 2015 20:12:31 +0000", + date_sent: "Thu, 30 Jul 2015 20:12:33 +0000", + date_updated: "Thu, 30 Jul 2015 20:12:33 +0000", + direction: TwilioMessageDirection.OUTBOUND_API, // Assuming a correct value here + error_code: null, + error_message: null, + from: "+15557122661", + messaging_service_sid: "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + num_media: "0", + num_segments: "1", + price: null, + price_unit: null, + sid: "SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + status: TwilioMessageStatus.SENT, // Assuming a correct value here + subresource_uris: mockSubresourceUris, + to: "+15558675310", + uri: "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json" +}; + +const mockInvalid: unknown = 42; // this is an invalid type to pass to these functions + +describe('TwilioMessageDTO', () => { + describe('createTwilioMessageDTO function', () => { + it('returns the correct DTO', () => { + expect(createTwilioMessageDTO( + mockTwilioMessage.account_sid, + mockTwilioMessage.api_version, + mockTwilioMessage.body, + mockTwilioMessage.date_created, + mockTwilioMessage.date_sent, + mockTwilioMessage.date_updated, + mockTwilioMessage.direction, + mockTwilioMessage.error_code, + mockTwilioMessage.error_message, + mockTwilioMessage.from, + mockTwilioMessage.messaging_service_sid, + mockTwilioMessage.num_media, + mockTwilioMessage.num_segments, + mockTwilioMessage.price, + mockTwilioMessage.price_unit, + mockTwilioMessage.sid, + mockTwilioMessage.status, + mockTwilioMessage.subresource_uris, + mockTwilioMessage.to, + mockTwilioMessage.uri + )).toEqual(mockTwilioMessage); + }); + }); + + describe('isTwilioMessageDTO function', () => { + it('returns true for valid DTOs and false for invalid values', () => { + expect(isTwilioMessageDTO(mockTwilioMessage)).toBe(true); + expect(isTwilioMessageDTO(mockInvalid)).toBe(false); + }); + }); + + describe('explainTwilioMessageDTO function', () => { + it('returns a correct explanation for valid and invalid DTOs', () => { + expect(explainTwilioMessageDTO(mockTwilioMessage)).toEqual(explainOk()); + expect(explainTwilioMessageDTO(mockInvalid)).toContain('property "account_sid" not string'); + }); + }); + + describe('parseTwilioMessageDTO function', () => { + it('returns the DTO for valid DTOs and undefined for invalid values', () => { + expect(parseTwilioMessageDTO(mockTwilioMessage)).toEqual(mockTwilioMessage); + expect(parseTwilioMessageDTO(mockInvalid)).toBeUndefined(); + }); + }); + + describe('isTwilioMessageDTOOrUndefined function', () => { + it('returns true for valid DTOs and undefined, false for invalid values', () => { + expect(isTwilioMessageDTOOrUndefined(mockTwilioMessage)).toBe(true); + expect(isTwilioMessageDTOOrUndefined(undefined)).toBe(true); + expect(isTwilioMessageDTOOrUndefined(mockInvalid)).toBe(false); + }); + }); + + describe('explainTwilioMessageDTOOrUndefined function', () => { + it('returns a correct explanation for valid DTOs, undefined and invalid values', () => { + // Assuming explainOk and explainNot return predictable results + expect(explainTwilioMessageDTOOrUndefined(mockTwilioMessage)).toEqual(explainOk()); + expect(explainTwilioMessageDTOOrUndefined(undefined)).toEqual(explainOk()); + expect(explainTwilioMessageDTOOrUndefined(mockInvalid)).toEqual(explainNot(explainOr(['TwilioMessageDTO', 'undefined']))); + }); + }); + +}); diff --git a/twilio/dto/TwilioMessageDTO.ts b/twilio/dto/TwilioMessageDTO.ts new file mode 100644 index 0000000..77ab2f1 --- /dev/null +++ b/twilio/dto/TwilioMessageDTO.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainReadonlyJsonObject, isReadonlyJsonObject, ReadonlyJsonObject } from "../../Json"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainNumberOrNull, isNumberOrNull } from "../../types/Number"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainString, explainStringOrNull, isString, isStringOrNull } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; +import { explainTwilioMessageDirection, isTwilioMessageDirection, TwilioMessageDirection } from "./types/TwilioMessageDirection"; +import { explainTwilioMessageStatus, isTwilioMessageStatus, TwilioMessageStatus } from "./types/TwilioMessageStatus"; + +/** + * @example + * { + * "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + * "api_version": "2010-04-01", + * "body": "Hi there", + * "date_created": "Thu, 30 Jul 2015 20:12:31 +0000", + * "date_sent": "Thu, 30 Jul 2015 20:12:33 +0000", + * "date_updated": "Thu, 30 Jul 2015 20:12:33 +0000", + * "direction": "outbound-api", + * "error_code": null, + * "error_message": null, + * "from": "+15557122661", + * "messaging_service_sid": "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + * "num_media": "0", + * "num_segments": "1", + * "price": null, + * "price_unit": null, + * "sid": "SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + * "status": "sent", + * "subresource_uris": { + * "media": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Media.json" + * }, + * "to": "+15558675310", + * "uri": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json" + * } + */ +export interface TwilioMessageDTO { + readonly account_sid: string; + readonly api_version: string; + readonly body: string; + readonly date_created: string; + readonly date_sent: string | null; + readonly date_updated: string; + readonly direction: TwilioMessageDirection; + readonly error_code: number | null; + readonly error_message: string | null; + readonly from: string; + readonly messaging_service_sid: string | null; + readonly num_media: string; + readonly num_segments: string; + readonly price: string | null; + readonly price_unit: string | null; + readonly sid: string; + readonly status: TwilioMessageStatus; + readonly subresource_uris: ReadonlyJsonObject; + readonly to: string; + readonly uri: string; +} + +export function createTwilioMessageDTO ( + account_sid: string, + api_version: string, + body: string, + date_created: string, + date_sent: string | null, + date_updated: string, + direction: TwilioMessageDirection, + error_code: number | null, + error_message: string | null, + from: string, + messaging_service_sid: string | null, + num_media: string, + num_segments: string, + price: string | null, + price_unit: string | null, + sid: string, + status: TwilioMessageStatus, + subresource_uris: ReadonlyJsonObject, + to: string, + uri: string, +) : TwilioMessageDTO { + return { + account_sid, + api_version, + body, + date_created, + date_sent, + date_updated, + direction, + error_code, + error_message, + from, + messaging_service_sid, + num_media, + num_segments, + price, + price_unit, + sid, + status, + subresource_uris, + to, + uri + }; +} + +export function isTwilioMessageDTO (value: unknown) : value is TwilioMessageDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'account_sid', + 'api_version', + 'body', + 'date_created', + 'date_sent', + 'date_updated', + 'direction', + 'error_code', + 'error_message', + 'from', + 'messaging_service_sid', + 'num_media', + 'num_segments', + 'price', + 'price_unit', + 'sid', + 'status', + 'subresource_uris', + 'to', + 'uri' + ]) + && isString(value?.account_sid) + && isString(value?.api_version) + && isString(value?.body) + && isString(value?.date_created) + && isStringOrNull(value?.date_sent) + && isString(value?.date_updated) + && isTwilioMessageDirection(value?.direction) + && isNumberOrNull(value?.error_code) + && isStringOrNull(value?.error_message) + && isString(value?.from) + && isStringOrNull(value?.messaging_service_sid) + && isString(value?.num_media) + && isString(value?.num_segments) + && isStringOrNull(value?.price) + && isStringOrNull(value?.price_unit) + && isString(value?.sid) + && isTwilioMessageStatus(value?.status) + && isReadonlyJsonObject(value?.subresource_uris) + && isString(value?.to) + && isString(value?.uri) + ); +} + +export function explainTwilioMessageDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'account_sid', + 'api_version', + 'body', + 'date_created', + 'date_sent', + 'date_updated', + 'direction', + 'error_code', + 'error_message', + 'from', + 'messaging_service_sid', + 'num_media', + 'num_segments', + 'price', + 'price_unit', + 'sid', + 'status', + 'subresource_uris', + 'to', + 'uri' + ]) + , explainProperty("account_sid", explainString(value?.account_sid)) + , explainProperty("api_version", explainString(value?.api_version)) + , explainProperty("body", explainString(value?.body)) + , explainProperty("date_created", explainString(value?.date_created)) + , explainProperty("date_sent", explainStringOrNull(value?.date_sent)) + , explainProperty("date_updated", explainString(value?.date_updated)) + , explainProperty("direction", explainTwilioMessageDirection(value?.direction)) + , explainProperty("error_code", explainNumberOrNull(value?.error_code)) + , explainProperty("error_message", explainStringOrNull(value?.error_message)) + , explainProperty("from", explainString(value?.from)) + , explainProperty("messaging_service_sid", explainStringOrNull(value?.messaging_service_sid)) + , explainProperty("num_media", explainString(value?.num_media)) + , explainProperty("num_segments", explainString(value?.num_segments)) + , explainProperty("price", explainStringOrNull(value?.price)) + , explainProperty("price_unit", explainStringOrNull(value?.price_unit)) + , explainProperty("sid", explainString(value?.sid)) + , explainProperty("status", explainTwilioMessageStatus(value?.status)) + , explainProperty("subresource_uris", explainReadonlyJsonObject(value?.subresource_uris)) + , explainProperty("to", explainString(value?.to)) + , explainProperty("uri", explainString(value?.uri)) + ] + ); +} + +export function parseTwilioMessageDTO (value: unknown) : TwilioMessageDTO | undefined { + if (isTwilioMessageDTO(value)) return value; + return undefined; +} + +export function isTwilioMessageDTOOrUndefined (value: unknown): value is TwilioMessageDTO | undefined { + return isUndefined(value) || isTwilioMessageDTO(value); +} + +export function explainTwilioMessageDTOOrUndefined (value: unknown): string { + return isTwilioMessageDTOOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioMessageDTO', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioCreateMessageBody.test.ts b/twilio/dto/types/TwilioCreateMessageBody.test.ts new file mode 100644 index 0000000..27c1557 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageBody.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createTwilioCreateMessageBody, + isTwilioCreateMessageBody, + explainTwilioCreateMessageBody, + parseTwilioCreateMessageBody, + isTwilioCreateMessageBodyOrUndefined, + explainTwilioCreateMessageBodyOrUndefined +} from './TwilioCreateMessageBody'; // Assumed your functions are in this file + +const VALID_VALUE = 'Hi there'; +const INVALID_VALUE = 1235; // An invalid value (Add more invalid cases as per your requirements) + +describe('TwilioCreateMessageBody', () => { + + describe('createTwilioCreateMessageBody', () => { + it('should create a valid object', () => { + const result = createTwilioCreateMessageBody(VALID_VALUE); + expect(result).toEqual({ Body: VALID_VALUE }); + }); + }); + + describe('isTwilioCreateMessageBody', () => { + it('should return true for valid input', () => { + const input = { Body: VALID_VALUE }; + expect(isTwilioCreateMessageBody(input)).toBeTruthy(); + }); + + it('should return false for invalid input', () => { + const input = { Body: INVALID_VALUE }; + expect(isTwilioCreateMessageBody(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageBody', () => { + it('should return OK for valid input', () => { + const input = { Body: VALID_VALUE }; + expect(explainTwilioCreateMessageBody(input)).toBe('OK'); + }); + + it('should return error string for invalid input', () => { + const input = { Body: INVALID_VALUE }; + expect(explainTwilioCreateMessageBody(input)).not.toBe('OK'); + }); + }); + + describe('parseTwilioCreateMessageBody', () => { + it('should return value for valid input', () => { + const input = { Body: VALID_VALUE }; + expect(parseTwilioCreateMessageBody(input)).toEqual(input); + }); + + it('should return undefined for invalid input', () => { + const input = { Body: INVALID_VALUE }; + expect(parseTwilioCreateMessageBody(input)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageBodyOrUndefined', () => { + it('should return true for valid input', () => { + const input = { Body: VALID_VALUE }; + expect(isTwilioCreateMessageBodyOrUndefined(input)).toBeTruthy(); + }); + + it('should return true for undefined', () => { + const input = undefined; + expect(isTwilioCreateMessageBodyOrUndefined(input)).toBeTruthy(); + }); + + it('should return false for invalid non-undefined input', () => { + const input = { Body: INVALID_VALUE }; + expect(isTwilioCreateMessageBodyOrUndefined(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageBodyOrUndefined', () => { + it('should return OK for valid input', () => { + const input = { Body: VALID_VALUE }; + expect(explainTwilioCreateMessageBodyOrUndefined(input)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + const input = undefined; + expect(explainTwilioCreateMessageBodyOrUndefined(input)).toBe('OK'); + }); + + it('should return error string for invalid non-undefined input', () => { + const input = { Body: INVALID_VALUE }; + expect(explainTwilioCreateMessageBodyOrUndefined(input)).not.toBe('OK'); + }); + }); + +}); diff --git a/twilio/dto/types/TwilioCreateMessageBody.ts b/twilio/dto/types/TwilioCreateMessageBody.ts new file mode 100644 index 0000000..c38ec6a --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageBody.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainString, isString } from "../../../types/String"; +import { isUndefined } from "../../../types/undefined"; + +/** + * @Example + * {"Body": "Hi there"} + */ +export interface TwilioCreateMessageBody { + readonly Body: string; +} + +export function createTwilioCreateMessageBody ( + Body : string +) : TwilioCreateMessageBody { + return { + Body + }; +} + +export function isTwilioCreateMessageBody (value: unknown) : value is TwilioCreateMessageBody { + return ( + isRegularObject(value) + && isString(value?.Body) + ); +} + +export function explainTwilioCreateMessageBody (value: any) : string { + return explain( + [ + explainRegularObject(value) + , explainProperty("Body", explainString(value?.Body)) + ] + ); +} + +export function parseTwilioCreateMessageBody (value: unknown) : TwilioCreateMessageBody | undefined { + if (isTwilioCreateMessageBody(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageBodyOrUndefined (value: unknown): value is TwilioCreateMessageBody | undefined { + return isUndefined(value) || isTwilioCreateMessageBody(value); +} + +export function explainTwilioCreateMessageBodyOrUndefined (value: unknown): string { + return isTwilioCreateMessageBodyOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageBody', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioCreateMessageContent.test.ts b/twilio/dto/types/TwilioCreateMessageContent.test.ts new file mode 100644 index 0000000..e0eabc4 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageContent.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { TwilioCreateMessageBody } from "./TwilioCreateMessageBody"; +import { TwilioCreateMessageContentSid } from "./TwilioCreateMessageContentSid"; +import { TwilioCreateMessageMediaUrl } from "./TwilioCreateMessageMediaUrl"; +import { createTwilioCreateMessageContent, isTwilioCreateMessageContent, explainTwilioCreateMessageContent, parseTwilioCreateMessageContent, isTwilioCreateMessageContentOrUndefined, explainTwilioCreateMessageContentOrUndefined } from './TwilioCreateMessageContent'; + +const mockMediaUrl: TwilioCreateMessageMediaUrl = { MediaUrl: "http://example.com/image.jpg" }; +const mockMessageBody: TwilioCreateMessageBody = { Body: "This is a message body" }; +const mockMessageContentSid: TwilioCreateMessageContentSid = { ContentSid: "SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" }; +const mockUndefined: unknown = undefined; +const mockInvalid: unknown = 42; // this is an invalid type to pass to these functions + +describe('TwilioCreateMessageContent', () => { + + describe('createTwilioCreateMessageContent function', () => { + it('returns the same content that was passed in', () => { + expect(createTwilioCreateMessageContent(mockMediaUrl)).toEqual(mockMediaUrl); + expect(createTwilioCreateMessageContent(mockMessageBody)).toEqual(mockMessageBody); + expect(createTwilioCreateMessageContent(mockMessageContentSid)).toEqual(mockMessageContentSid); + }); + }); + + describe('isTwilioCreateMessageContent function', () => { + it('returns true for valid message content and false for invalid content', () => { + expect(isTwilioCreateMessageContent(mockMediaUrl)).toBe(true); + expect(isTwilioCreateMessageContent(mockMessageBody)).toBe(true); + expect(isTwilioCreateMessageContent(mockMessageContentSid)).toBe(true); + expect(isTwilioCreateMessageContent(mockInvalid)).toBe(false); + }); + }); + + describe('explainTwilioCreateMessageContent function', () => { + it('returns a correct explanation for valid and invalid content', () => { + expect(explainTwilioCreateMessageContent(mockMediaUrl)).toEqual(explainOk()); + expect(explainTwilioCreateMessageContent(mockInvalid)).toEqual(explainNot('TwilioCreateMessageContent')); + }); + }); + + describe('parseTwilioCreateMessageContent function', () => { + it('returns the content for valid content and undefined for invalid content', () => { + expect(parseTwilioCreateMessageContent(mockMediaUrl)).toEqual(mockMediaUrl); + expect(parseTwilioCreateMessageContent(mockInvalid)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageContentOrUndefined function', () => { + it('returns true for valid content and undefined, false for invalid content', () => { + expect(isTwilioCreateMessageContentOrUndefined(mockMediaUrl)).toBe(true); + expect(isTwilioCreateMessageContentOrUndefined(mockUndefined)).toBe(true); + expect(isTwilioCreateMessageContentOrUndefined(mockInvalid)).toBe(false); + }); + }); + + describe('explainTwilioCreateMessageContentOrUndefined function', () => { + it('returns a correct explanation for valid content, undefined and invalid content', () => { + expect(explainTwilioCreateMessageContentOrUndefined(mockMediaUrl)).toEqual(explainOk()); + expect(explainTwilioCreateMessageContentOrUndefined(mockUndefined)).toEqual(explainOk()); + expect(explainTwilioCreateMessageContentOrUndefined(mockInvalid)).toEqual(explainNot(explainOr(['TwilioCreateMessageContent', 'undefined']))); + }); + }); + +}); diff --git a/twilio/dto/types/TwilioCreateMessageContent.ts b/twilio/dto/types/TwilioCreateMessageContent.ts new file mode 100644 index 0000000..2b95708 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageContent.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; +import { isTwilioCreateMessageBody, TwilioCreateMessageBody } from "./TwilioCreateMessageBody"; +import { isTwilioCreateMessageContentSid, TwilioCreateMessageContentSid } from "./TwilioCreateMessageContentSid"; +import { isTwilioCreateMessageMediaUrl, TwilioCreateMessageMediaUrl } from "./TwilioCreateMessageMediaUrl"; + +export type TwilioCreateMessageContent = TwilioCreateMessageMediaUrl | TwilioCreateMessageBody | TwilioCreateMessageContentSid; + +export function createTwilioCreateMessageContent ( + content : TwilioCreateMessageMediaUrl | TwilioCreateMessageBody | TwilioCreateMessageContentSid +) : TwilioCreateMessageContent { + return { + ...content + }; +} + +export function isTwilioCreateMessageContent (value: unknown) : value is TwilioCreateMessageContent { + return ( + isTwilioCreateMessageMediaUrl(value) + || isTwilioCreateMessageBody(value) + || isTwilioCreateMessageContentSid(value) + ); +} + +export function explainTwilioCreateMessageContent (value: any) : string { + return isTwilioCreateMessageContent(value) ? explainOk() : explainNot('TwilioCreateMessageContent'); +} + +export function parseTwilioCreateMessageContent (value: unknown) : TwilioCreateMessageContent | undefined { + if (isTwilioCreateMessageContent(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageContentOrUndefined (value: unknown): value is TwilioCreateMessageContent | undefined { + return isUndefined(value) || isTwilioCreateMessageContent(value); +} + +export function explainTwilioCreateMessageContentOrUndefined (value: unknown): string { + return isTwilioCreateMessageContentOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageContent', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioCreateMessageContentSid.test.ts b/twilio/dto/types/TwilioCreateMessageContentSid.test.ts new file mode 100644 index 0000000..14470ce --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageContentSid.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createTwilioCreateMessageContentSid, + isTwilioCreateMessageContentSid, + explainTwilioCreateMessageContentSid, + parseTwilioCreateMessageContentSid, + isTwilioCreateMessageContentSidOrUndefined, + explainTwilioCreateMessageContentSidOrUndefined +} from './TwilioCreateMessageContentSid'; // Assumed your functions are in this file + +const VALID_VALUE = 'Hi there'; +const INVALID_VALUE = 1235; // An invalid value (Add more invalid cases as per your requirements) + +describe('TwilioCreateMessageContentSid', () => { + + describe('createTwilioCreateMessageContentSid', () => { + it('should create a valid object', () => { + const result = createTwilioCreateMessageContentSid(VALID_VALUE); + expect(result).toEqual({ ContentSid: VALID_VALUE }); + }); + }); + + describe('isTwilioCreateMessageContentSid', () => { + it('should return true for valid input', () => { + const input = { ContentSid: VALID_VALUE }; + expect(isTwilioCreateMessageContentSid(input)).toBeTruthy(); + }); + + it('should return false for invalid input', () => { + const input = { ContentSid: INVALID_VALUE }; + expect(isTwilioCreateMessageContentSid(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageContentSid', () => { + it('should return OK for valid input', () => { + const input = { ContentSid: VALID_VALUE }; + expect(explainTwilioCreateMessageContentSid(input)).toBe('OK'); + }); + + it('should return error string for invalid input', () => { + const input = { ContentSid: INVALID_VALUE }; + expect(explainTwilioCreateMessageContentSid(input)).not.toBe('OK'); + }); + }); + + describe('parseTwilioCreateMessageContentSid', () => { + it('should return value for valid input', () => { + const input = { ContentSid: VALID_VALUE }; + expect(parseTwilioCreateMessageContentSid(input)).toEqual(input); + }); + + it('should return undefined for invalid input', () => { + const input = { ContentSid: INVALID_VALUE }; + expect(parseTwilioCreateMessageContentSid(input)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageContentSidOrUndefined', () => { + it('should return true for valid input', () => { + const input = { ContentSid: VALID_VALUE }; + expect(isTwilioCreateMessageContentSidOrUndefined(input)).toBeTruthy(); + }); + + it('should return true for undefined', () => { + const input = undefined; + expect(isTwilioCreateMessageContentSidOrUndefined(input)).toBeTruthy(); + }); + + it('should return false for invalid non-undefined input', () => { + const input = { ContentSid: INVALID_VALUE }; + expect(isTwilioCreateMessageContentSidOrUndefined(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageContentSidOrUndefined', () => { + it('should return OK for valid input', () => { + const input = { ContentSid: VALID_VALUE }; + expect(explainTwilioCreateMessageContentSidOrUndefined(input)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + const input = undefined; + expect(explainTwilioCreateMessageContentSidOrUndefined(input)).toBe('OK'); + }); + + it('should return error string for invalid non-undefined input', () => { + const input = { ContentSid: INVALID_VALUE }; + expect(explainTwilioCreateMessageContentSidOrUndefined(input)).not.toBe('OK'); + }); + }); + +}); diff --git a/twilio/dto/types/TwilioCreateMessageContentSid.ts b/twilio/dto/types/TwilioCreateMessageContentSid.ts new file mode 100644 index 0000000..95c1009 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageContentSid.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainString, isString } from "../../../types/String"; +import { isUndefined } from "../../../types/undefined"; + +export interface TwilioCreateMessageContentSid { + readonly ContentSid: string; +} + +export function createTwilioCreateMessageContentSid ( + ContentSid : string +) : TwilioCreateMessageContentSid { + return { + ContentSid + }; +} + +export function isTwilioCreateMessageContentSid (value: unknown) : value is TwilioCreateMessageContentSid { + return ( + isRegularObject(value) + && isString(value?.ContentSid) + ); +} + +export function explainTwilioCreateMessageContentSid (value: any) : string { + return explain( + [ + explainRegularObject(value) + , explainProperty("ContentSid", explainString(value?.ContentSid)) + ] + ); +} + +export function parseTwilioCreateMessageContentSid (value: unknown) : TwilioCreateMessageContentSid | undefined { + if (isTwilioCreateMessageContentSid(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageContentSidOrUndefined (value: unknown): value is TwilioCreateMessageContentSid | undefined { + return isUndefined(value) || isTwilioCreateMessageContentSid(value); +} + +export function explainTwilioCreateMessageContentSidOrUndefined (value: unknown): string { + return isTwilioCreateMessageContentSidOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageContentSid', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioCreateMessageFrom.test.ts b/twilio/dto/types/TwilioCreateMessageFrom.test.ts new file mode 100644 index 0000000..8e35734 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageFrom.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createTwilioCreateMessageFrom, + isTwilioCreateMessageFrom, + explainTwilioCreateMessageFrom, + parseTwilioCreateMessageFrom, + isTwilioCreateMessageFromOrUndefined, + explainTwilioCreateMessageFromOrUndefined +} from './TwilioCreateMessageFrom'; // Assumed your functions are in this file + +const VALID_FROM = '+15557122661'; +const INVALID_FROM = 1235; // An invalid value (Add more invalid cases as per your requirements) + +describe('TwilioCreateMessageFrom', () => { + + describe('createTwilioCreateMessageFrom', () => { + it('should create a valid object', () => { + const result = createTwilioCreateMessageFrom(VALID_FROM); + expect(result).toEqual({ From: VALID_FROM }); + }); + }); + + describe('isTwilioCreateMessageFrom', () => { + it('should return true for valid input', () => { + const input = { From: VALID_FROM }; + expect(isTwilioCreateMessageFrom(input)).toBeTruthy(); + }); + + it('should return false for invalid input', () => { + const input = { From: INVALID_FROM }; + expect(isTwilioCreateMessageFrom(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageFrom', () => { + it('should return OK for valid input', () => { + const input = { From: VALID_FROM }; + expect(explainTwilioCreateMessageFrom(input)).toBe('OK'); + }); + + it('should return error string for invalid input', () => { + const input = { From: INVALID_FROM }; + expect(explainTwilioCreateMessageFrom(input)).not.toBe('OK'); + }); + }); + + describe('parseTwilioCreateMessageFrom', () => { + it('should return value for valid input', () => { + const input = { From: VALID_FROM }; + expect(parseTwilioCreateMessageFrom(input)).toEqual(input); + }); + + it('should return undefined for invalid input', () => { + const input = { From: INVALID_FROM }; + expect(parseTwilioCreateMessageFrom(input)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageFromOrUndefined', () => { + it('should return true for valid input', () => { + const input = { From: VALID_FROM }; + expect(isTwilioCreateMessageFromOrUndefined(input)).toBeTruthy(); + }); + + it('should return true for undefined', () => { + const input = undefined; + expect(isTwilioCreateMessageFromOrUndefined(input)).toBeTruthy(); + }); + + it('should return false for invalid non-undefined input', () => { + const input = { From: INVALID_FROM }; + expect(isTwilioCreateMessageFromOrUndefined(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageFromOrUndefined', () => { + it('should return OK for valid input', () => { + const input = { From: VALID_FROM }; + expect(explainTwilioCreateMessageFromOrUndefined(input)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + const input = undefined; + expect(explainTwilioCreateMessageFromOrUndefined(input)).toBe('OK'); + }); + + it('should return error string for invalid non-undefined input', () => { + const input = { From: INVALID_FROM }; + expect(explainTwilioCreateMessageFromOrUndefined(input)).not.toBe('OK'); + }); + }); + +}); diff --git a/twilio/dto/types/TwilioCreateMessageFrom.ts b/twilio/dto/types/TwilioCreateMessageFrom.ts new file mode 100644 index 0000000..739dca8 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageFrom.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainString, isString } from "../../../types/String"; +import { isUndefined } from "../../../types/undefined"; + +/** + * @Example + * {"From": "+15557122661"} + */ +export interface TwilioCreateMessageFrom { + readonly From: string; +} + +export function createTwilioCreateMessageFrom ( + From : string +) : TwilioCreateMessageFrom { + return { + From + }; +} + +export function isTwilioCreateMessageFrom (value: unknown) : value is TwilioCreateMessageFrom { + return ( + isRegularObject(value) + && isString(value?.From) + ); +} + +export function explainTwilioCreateMessageFrom (value: any) : string { + return explain( + [ + explainRegularObject(value) + , explainProperty("From", explainString(value?.From)) + ] + ); +} + +export function parseTwilioCreateMessageFrom (value: unknown) : TwilioCreateMessageFrom | undefined { + if (isTwilioCreateMessageFrom(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageFromOrUndefined (value: unknown): value is TwilioCreateMessageFrom | undefined { + return isUndefined(value) || isTwilioCreateMessageFrom(value); +} + +export function explainTwilioCreateMessageFromOrUndefined (value: unknown): string { + return isTwilioCreateMessageFromOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageFrom', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioCreateMessageMediaUrl.test.ts b/twilio/dto/types/TwilioCreateMessageMediaUrl.test.ts new file mode 100644 index 0000000..2c63f36 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageMediaUrl.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createTwilioCreateMessageMediaUrl, + isTwilioCreateMessageMediaUrl, + explainTwilioCreateMessageMediaUrl, + parseTwilioCreateMessageMediaUrl, + isTwilioCreateMessageMediaUrlOrUndefined, + explainTwilioCreateMessageMediaUrlOrUndefined +} from './TwilioCreateMessageMediaUrl'; // Assumed your functions are in this file + +const VALID_VALUE = 'Hi there'; +const INVALID_VALUE = 1235; // An invalid value (Add more invalid cases as per your requirements) + +describe('TwilioCreateMessageMediaUrl', () => { + + describe('createTwilioCreateMessageMediaUrl', () => { + it('should create a valid object', () => { + const result = createTwilioCreateMessageMediaUrl(VALID_VALUE); + expect(result).toEqual({ MediaUrl: VALID_VALUE }); + }); + }); + + describe('isTwilioCreateMessageMediaUrl', () => { + it('should return true for valid input', () => { + const input = { MediaUrl: VALID_VALUE }; + expect(isTwilioCreateMessageMediaUrl(input)).toBeTruthy(); + }); + + it('should return false for invalid input', () => { + const input = { MediaUrl: INVALID_VALUE }; + expect(isTwilioCreateMessageMediaUrl(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageMediaUrl', () => { + it('should return OK for valid input', () => { + const input = { MediaUrl: VALID_VALUE }; + expect(explainTwilioCreateMessageMediaUrl(input)).toBe('OK'); + }); + + it('should return error string for invalid input', () => { + const input = { MediaUrl: INVALID_VALUE }; + expect(explainTwilioCreateMessageMediaUrl(input)).not.toBe('OK'); + }); + }); + + describe('parseTwilioCreateMessageMediaUrl', () => { + it('should return value for valid input', () => { + const input = { MediaUrl: VALID_VALUE }; + expect(parseTwilioCreateMessageMediaUrl(input)).toEqual(input); + }); + + it('should return undefined for invalid input', () => { + const input = { MediaUrl: INVALID_VALUE }; + expect(parseTwilioCreateMessageMediaUrl(input)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageMediaUrlOrUndefined', () => { + it('should return true for valid input', () => { + const input = { MediaUrl: VALID_VALUE }; + expect(isTwilioCreateMessageMediaUrlOrUndefined(input)).toBeTruthy(); + }); + + it('should return true for undefined', () => { + const input = undefined; + expect(isTwilioCreateMessageMediaUrlOrUndefined(input)).toBeTruthy(); + }); + + it('should return false for invalid non-undefined input', () => { + const input = { MediaUrl: INVALID_VALUE }; + expect(isTwilioCreateMessageMediaUrlOrUndefined(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageMediaUrlOrUndefined', () => { + it('should return OK for valid input', () => { + const input = { MediaUrl: VALID_VALUE }; + expect(explainTwilioCreateMessageMediaUrlOrUndefined(input)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + const input = undefined; + expect(explainTwilioCreateMessageMediaUrlOrUndefined(input)).toBe('OK'); + }); + + it('should return error string for invalid non-undefined input', () => { + const input = { MediaUrl: INVALID_VALUE }; + expect(explainTwilioCreateMessageMediaUrlOrUndefined(input)).not.toBe('OK'); + }); + }); + +}); diff --git a/twilio/dto/types/TwilioCreateMessageMediaUrl.ts b/twilio/dto/types/TwilioCreateMessageMediaUrl.ts new file mode 100644 index 0000000..e30c1a0 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageMediaUrl.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainString, isString } from "../../../types/String"; +import { isUndefined } from "../../../types/undefined"; + +export interface TwilioCreateMessageMediaUrl { + readonly MediaUrl: string; +} + +export function createTwilioCreateMessageMediaUrl ( + MediaUrl : string +) : TwilioCreateMessageMediaUrl { + return { + MediaUrl + }; +} + +export function isTwilioCreateMessageMediaUrl (value: unknown) : value is TwilioCreateMessageMediaUrl { + return ( + isRegularObject(value) + && isString(value?.MediaUrl) + ); +} + +export function explainTwilioCreateMessageMediaUrl (value: any) : string { + return explain( + [ + explainRegularObject(value) + , explainProperty("MediaUrl", explainString(value?.MediaUrl)) + ] + ); +} + +export function parseTwilioCreateMessageMediaUrl (value: unknown) : TwilioCreateMessageMediaUrl | undefined { + if (isTwilioCreateMessageMediaUrl(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageMediaUrlOrUndefined (value: unknown): value is TwilioCreateMessageMediaUrl | undefined { + return isUndefined(value) || isTwilioCreateMessageMediaUrl(value); +} + +export function explainTwilioCreateMessageMediaUrlOrUndefined (value: unknown): string { + return isTwilioCreateMessageMediaUrlOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageMediaUrl', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioCreateMessageMessagingServiceSid.test.ts b/twilio/dto/types/TwilioCreateMessageMessagingServiceSid.test.ts new file mode 100644 index 0000000..c90969a --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageMessagingServiceSid.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createTwilioCreateMessageMessagingServiceSid, + isTwilioCreateMessageMessagingServiceSid, + explainTwilioCreateMessageMessagingServiceSid, + parseTwilioCreateMessageMessagingServiceSid, + isTwilioCreateMessageMessagingServiceSidOrUndefined, + explainTwilioCreateMessageMessagingServiceSidOrUndefined +} from './TwilioCreateMessageMessagingServiceSid'; // Assumed your functions are in this file + +const VALID_VALUE = 'MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; +const INVALID_VALUE = 1235; // An invalid value (Add more invalid cases as per your requirements) + +describe('TwilioCreateMessageMessagingServiceSid', () => { + + describe('createTwilioCreateMessageMessagingServiceSid', () => { + it('should create a valid object', () => { + const result = createTwilioCreateMessageMessagingServiceSid(VALID_VALUE); + expect(result).toEqual({ MessagingServiceSid: VALID_VALUE }); + }); + }); + + describe('isTwilioCreateMessageMessagingServiceSid', () => { + it('should return true for valid input', () => { + const input = { MessagingServiceSid: VALID_VALUE }; + expect(isTwilioCreateMessageMessagingServiceSid(input)).toBeTruthy(); + }); + + it('should return false for invalid input', () => { + const input = { MessagingServiceSid: INVALID_VALUE }; + expect(isTwilioCreateMessageMessagingServiceSid(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageMessagingServiceSid', () => { + it('should return OK for valid input', () => { + const input = { MessagingServiceSid: VALID_VALUE }; + expect(explainTwilioCreateMessageMessagingServiceSid(input)).toBe('OK'); + }); + + it('should return error string for invalid input', () => { + const input = { MessagingServiceSid: INVALID_VALUE }; + expect(explainTwilioCreateMessageMessagingServiceSid(input)).not.toBe('OK'); + }); + }); + + describe('parseTwilioCreateMessageMessagingServiceSid', () => { + it('should return value for valid input', () => { + const input = { MessagingServiceSid: VALID_VALUE }; + expect(parseTwilioCreateMessageMessagingServiceSid(input)).toEqual(input); + }); + + it('should return undefined for invalid input', () => { + const input = { MessagingServiceSid: INVALID_VALUE }; + expect(parseTwilioCreateMessageMessagingServiceSid(input)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageMessagingServiceSidOrUndefined', () => { + it('should return true for valid input', () => { + const input = { MessagingServiceSid: VALID_VALUE }; + expect(isTwilioCreateMessageMessagingServiceSidOrUndefined(input)).toBeTruthy(); + }); + + it('should return true for undefined', () => { + const input = undefined; + expect(isTwilioCreateMessageMessagingServiceSidOrUndefined(input)).toBeTruthy(); + }); + + it('should return false for invalid non-undefined input', () => { + const input = { MessagingServiceSid: INVALID_VALUE }; + expect(isTwilioCreateMessageMessagingServiceSidOrUndefined(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageMessagingServiceSidOrUndefined', () => { + it('should return OK for valid input', () => { + const input = { MessagingServiceSid: VALID_VALUE }; + expect(explainTwilioCreateMessageMessagingServiceSidOrUndefined(input)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + const input = undefined; + expect(explainTwilioCreateMessageMessagingServiceSidOrUndefined(input)).toBe('OK'); + }); + + it('should return error string for invalid non-undefined input', () => { + const input = { MessagingServiceSid: INVALID_VALUE }; + expect(explainTwilioCreateMessageMessagingServiceSidOrUndefined(input)).not.toBe('OK'); + }); + }); + +}); diff --git a/twilio/dto/types/TwilioCreateMessageMessagingServiceSid.ts b/twilio/dto/types/TwilioCreateMessageMessagingServiceSid.ts new file mode 100644 index 0000000..a2360a2 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageMessagingServiceSid.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainString, isString } from "../../../types/String"; +import { isUndefined } from "../../../types/undefined"; + +/** + * @Example + * {"MessagingServiceSid": "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"} + */ +export interface TwilioCreateMessageMessagingServiceSid { + readonly MessagingServiceSid: string; +} + +export function createTwilioCreateMessageMessagingServiceSid ( + MessagingServiceSid : string +) : TwilioCreateMessageMessagingServiceSid { + return { + MessagingServiceSid + }; +} + +export function isTwilioCreateMessageMessagingServiceSid (value: unknown) : value is TwilioCreateMessageMessagingServiceSid { + return ( + isRegularObject(value) + && isString(value?.MessagingServiceSid) + ); +} + +export function explainTwilioCreateMessageMessagingServiceSid (value: any) : string { + return explain( + [ + explainRegularObject(value) + , explainProperty("MessagingServiceSid", explainString(value?.MessagingServiceSid)) + ] + ); +} + +export function parseTwilioCreateMessageMessagingServiceSid (value: unknown) : TwilioCreateMessageMessagingServiceSid | undefined { + if (isTwilioCreateMessageMessagingServiceSid(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageMessagingServiceSidOrUndefined (value: unknown): value is TwilioCreateMessageMessagingServiceSid | undefined { + return isUndefined(value) || isTwilioCreateMessageMessagingServiceSid(value); +} + +export function explainTwilioCreateMessageMessagingServiceSidOrUndefined (value: unknown): string { + return isTwilioCreateMessageMessagingServiceSidOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageMessagingServiceSid', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioCreateMessageRecipient.test.ts b/twilio/dto/types/TwilioCreateMessageRecipient.test.ts new file mode 100644 index 0000000..959dd1f --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageRecipient.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { + createTwilioCreateMessageRecipient, + isTwilioCreateMessageRecipient, + explainTwilioCreateMessageRecipient, + parseTwilioCreateMessageRecipient, + isTwilioCreateMessageRecipientOrUndefined, + explainTwilioCreateMessageRecipientOrUndefined +} from './TwilioCreateMessageRecipient'; // Assumed your functions are in this file + +const VALID_VALUE = '+15558675310'; +const INVALID_VALUE = 1235; // An invalid value (Add more invalid cases as per your requirements) + +describe('TwilioCreateMessageRecipient', () => { + + describe('createTwilioCreateMessageRecipient', () => { + it('should create a valid object', () => { + const result = createTwilioCreateMessageRecipient(VALID_VALUE); + expect(result).toEqual({ To: VALID_VALUE }); + }); + }); + + describe('isTwilioCreateMessageRecipient', () => { + it('should return true for valid input', () => { + const input = { To: VALID_VALUE }; + expect(isTwilioCreateMessageRecipient(input)).toBeTruthy(); + }); + + it('should return false for invalid input', () => { + const input = { To: INVALID_VALUE }; + expect(isTwilioCreateMessageRecipient(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageRecipient', () => { + it('should return OK for valid input', () => { + const input = { To: VALID_VALUE }; + expect(explainTwilioCreateMessageRecipient(input)).toBe('OK'); + }); + + it('should return error string for invalid input', () => { + const input = { To: INVALID_VALUE }; + expect(explainTwilioCreateMessageRecipient(input)).not.toBe('OK'); + }); + }); + + describe('parseTwilioCreateMessageRecipient', () => { + it('should return value for valid input', () => { + const input = { To: VALID_VALUE }; + expect(parseTwilioCreateMessageRecipient(input)).toEqual(input); + }); + + it('should return undefined for invalid input', () => { + const input = { To: INVALID_VALUE }; + expect(parseTwilioCreateMessageRecipient(input)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageRecipientOrUndefined', () => { + it('should return true for valid input', () => { + const input = { To: VALID_VALUE }; + expect(isTwilioCreateMessageRecipientOrUndefined(input)).toBeTruthy(); + }); + + it('should return true for undefined', () => { + const input = undefined; + expect(isTwilioCreateMessageRecipientOrUndefined(input)).toBeTruthy(); + }); + + it('should return false for invalid non-undefined input', () => { + const input = { To: INVALID_VALUE }; + expect(isTwilioCreateMessageRecipientOrUndefined(input)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageRecipientOrUndefined', () => { + it('should return OK for valid input', () => { + const input = { To: VALID_VALUE }; + expect(explainTwilioCreateMessageRecipientOrUndefined(input)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + const input = undefined; + expect(explainTwilioCreateMessageRecipientOrUndefined(input)).toBe('OK'); + }); + + it('should return error string for invalid non-undefined input', () => { + const input = { To: INVALID_VALUE }; + expect(explainTwilioCreateMessageRecipientOrUndefined(input)).not.toBe('OK'); + }); + }); + +}); diff --git a/twilio/dto/types/TwilioCreateMessageRecipient.ts b/twilio/dto/types/TwilioCreateMessageRecipient.ts new file mode 100644 index 0000000..9d246f7 --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageRecipient.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../../types/explain"; +import { explainRegularObject, isRegularObject } from "../../../types/RegularObject"; +import { explainString, isString } from "../../../types/String"; +import { isUndefined } from "../../../types/undefined"; + +/** + * @Example + * {"To": "+15558675310"} + */ +export interface TwilioCreateMessageRecipient { + readonly To: string; +} + +export function createTwilioCreateMessageRecipient ( + To : string +) : TwilioCreateMessageRecipient { + return { + To + }; +} + +export function isTwilioCreateMessageRecipient (value: unknown) : value is TwilioCreateMessageRecipient { + return ( + isRegularObject(value) + && isString(value?.To) + ); +} + +export function explainTwilioCreateMessageRecipient (value: any) : string { + return explain( + [ + explainRegularObject(value) + , explainProperty("To", explainString(value?.To)) + ] + ); +} + +export function parseTwilioCreateMessageRecipient (value: unknown) : TwilioCreateMessageRecipient | undefined { + if (isTwilioCreateMessageRecipient(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageRecipientOrUndefined (value: unknown): value is TwilioCreateMessageRecipient | undefined { + return isUndefined(value) || isTwilioCreateMessageRecipient(value); +} + +export function explainTwilioCreateMessageRecipientOrUndefined (value: unknown): string { + return isTwilioCreateMessageRecipientOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageRecipient', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioCreateMessageSender.test.ts b/twilio/dto/types/TwilioCreateMessageSender.test.ts new file mode 100644 index 0000000..56e8f2a --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageSender.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createTwilioCreateMessageSender, explainTwilioCreateMessageSender, explainTwilioCreateMessageSenderOrUndefined, isTwilioCreateMessageSender, isTwilioCreateMessageSenderOrUndefined, parseTwilioCreateMessageSender } from "./TwilioCreateMessageSender"; + +const VALID_FROM = { From: '+15557122661' }; +const VALID_MESSAGING_SERVICE_SID = { MessagingServiceSid: 'MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' }; +const INVALID_SENDER = { InvalidKey: 'invalidValue' }; // Add more invalid cases as per your requirements + +describe('TwilioCreateMessageSender', () => { + + describe('createTwilioCreateMessageSender', () => { + it('should create a valid object for From', () => { + const result = createTwilioCreateMessageSender(VALID_FROM); + expect(result).toEqual(VALID_FROM); + }); + + it('should create a valid object for MessagingServiceSid', () => { + const result = createTwilioCreateMessageSender(VALID_MESSAGING_SERVICE_SID); + expect(result).toEqual(VALID_MESSAGING_SERVICE_SID); + }); + }); + + describe('isTwilioCreateMessageSender', () => { + it('should return true for valid From input', () => { + expect(isTwilioCreateMessageSender(VALID_FROM)).toBeTruthy(); + }); + + it('should return true for valid MessagingServiceSid input', () => { + expect(isTwilioCreateMessageSender(VALID_MESSAGING_SERVICE_SID)).toBeTruthy(); + }); + + it('should return false for invalid input', () => { + expect(isTwilioCreateMessageSender(INVALID_SENDER)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageSender', () => { + it('should return OK for valid From input', () => { + expect(explainTwilioCreateMessageSender(VALID_FROM)).toBe('OK'); + }); + + it('should return OK for valid MessagingServiceSid input', () => { + expect(explainTwilioCreateMessageSender(VALID_MESSAGING_SERVICE_SID)).toBe('OK'); + }); + + it('should return error string for invalid input', () => { + expect(explainTwilioCreateMessageSender(INVALID_SENDER)).not.toBe('OK'); + }); + }); + + describe('parseTwilioCreateMessageSender', () => { + it('should return value for valid From input', () => { + expect(parseTwilioCreateMessageSender(VALID_FROM)).toEqual(VALID_FROM); + }); + + it('should return value for valid MessagingServiceSid input', () => { + expect(parseTwilioCreateMessageSender(VALID_MESSAGING_SERVICE_SID)).toEqual(VALID_MESSAGING_SERVICE_SID); + }); + + it('should return undefined for invalid input', () => { + expect(parseTwilioCreateMessageSender(INVALID_SENDER)).toBeUndefined(); + }); + }); + + describe('isTwilioCreateMessageSenderOrUndefined', () => { + it('should return true for valid From input', () => { + expect(isTwilioCreateMessageSenderOrUndefined(VALID_FROM)).toBeTruthy(); + }); + + it('should return true for valid MessagingServiceSid input', () => { + expect(isTwilioCreateMessageSenderOrUndefined(VALID_MESSAGING_SERVICE_SID)).toBeTruthy(); + }); + + it('should return true for undefined', () => { + expect(isTwilioCreateMessageSenderOrUndefined(undefined)).toBeTruthy(); + }); + + it('should return false for invalid non-undefined input', () => { + expect(isTwilioCreateMessageSenderOrUndefined(INVALID_SENDER)).toBeFalsy(); + }); + }); + + describe('explainTwilioCreateMessageSenderOrUndefined', () => { + it('should return OK for valid From input', () => { + expect(explainTwilioCreateMessageSenderOrUndefined(VALID_FROM)).toBe('OK'); + }); + + it('should return OK for valid MessagingServiceSid input', () => { + expect(explainTwilioCreateMessageSenderOrUndefined(VALID_MESSAGING_SERVICE_SID)).toBe('OK'); + }); + + it('should return OK for undefined', () => { + expect(explainTwilioCreateMessageSenderOrUndefined(undefined)).toBe('OK'); + }); + + it('should return error string for invalid non-undefined input', () => { + expect(explainTwilioCreateMessageSenderOrUndefined(INVALID_SENDER)).not.toBe('OK'); + }); + }); + +}); diff --git a/twilio/dto/types/TwilioCreateMessageSender.ts b/twilio/dto/types/TwilioCreateMessageSender.ts new file mode 100644 index 0000000..a8b5efb --- /dev/null +++ b/twilio/dto/types/TwilioCreateMessageSender.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; +import { isTwilioCreateMessageFrom, TwilioCreateMessageFrom } from "./TwilioCreateMessageFrom"; +import { isTwilioCreateMessageMessagingServiceSid, TwilioCreateMessageMessagingServiceSid } from "./TwilioCreateMessageMessagingServiceSid"; + +export type TwilioCreateMessageSender = ( + TwilioCreateMessageFrom + | TwilioCreateMessageMessagingServiceSid +); + +export function createTwilioCreateMessageSender ( + sender : TwilioCreateMessageFrom | TwilioCreateMessageMessagingServiceSid +) : TwilioCreateMessageSender { + return { + ...sender + }; +} + +export function isTwilioCreateMessageSender (value: unknown) : value is TwilioCreateMessageSender { + return ( + isTwilioCreateMessageFrom(value) + || isTwilioCreateMessageMessagingServiceSid(value) + ); +} + +export function explainTwilioCreateMessageSender (value: any) : string { + return isTwilioCreateMessageSender(value) ? explainOk() : explainNot('TwilioCreateMessageSender'); +} + +export function parseTwilioCreateMessageSender (value: unknown) : TwilioCreateMessageSender | undefined { + if (isTwilioCreateMessageSender(value)) return value; + return undefined; +} + +export function isTwilioCreateMessageSenderOrUndefined (value: unknown): value is TwilioCreateMessageSender | undefined { + return isUndefined(value) || isTwilioCreateMessageSender(value); +} + +export function explainTwilioCreateMessageSenderOrUndefined (value: unknown): string { + return isTwilioCreateMessageSenderOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioCreateMessageSender', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioMessageDirection.ts b/twilio/dto/types/TwilioMessageDirection.ts new file mode 100644 index 0000000..7d5bb57 --- /dev/null +++ b/twilio/dto/types/TwilioMessageDirection.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; + +export enum TwilioMessageDirection { + + /** + * `"inbound"` for incoming messages + */ + INBOUND = "inbound", + + /** + * `"outbound-api"` for messages created by the REST API + */ + OUTBOUND_API = "outbound-api", + + /** + * `"outbound-call"` for messages created during a call + */ + OUTBOUND_CALL = "outbound-call", + + /** + * `"outbound-reply"` for messages created in response to an incoming message + */ + OUTBOUND_REPLY = "outbound-reply", + +} + +export function isTwilioMessageDirection (value: unknown) : value is TwilioMessageDirection { + return isEnum(TwilioMessageDirection, value); +} + +export function explainTwilioMessageDirection (value : unknown) : string { + return explainEnum("TwilioMessageDirection", TwilioMessageDirection, isTwilioMessageDirection, value); +} + +export function stringifyTwilioMessageDirection (value : TwilioMessageDirection) : string { + return stringifyEnum(TwilioMessageDirection, value); +} + +export function parseTwilioMessageDirection (value: any) : TwilioMessageDirection | undefined { + return parseEnum(TwilioMessageDirection, value) as TwilioMessageDirection | undefined; +} + +export function isTwilioMessageDirectionOrUndefined (value: unknown): value is TwilioMessageDirection | undefined { + return isUndefined(value) || isTwilioMessageDirection(value); +} + +export function explainTwilioMessageDirectionOrUndefined (value: unknown): string { + return isTwilioMessageDirectionOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioMessageDirection', 'undefined'])); +} diff --git a/twilio/dto/types/TwilioMessageStatus.ts b/twilio/dto/types/TwilioMessageStatus.ts new file mode 100644 index 0000000..1856fc4 --- /dev/null +++ b/twilio/dto/types/TwilioMessageStatus.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "../../../types/Enum"; +import { explainNot, explainOk, explainOr } from "../../../types/explain"; +import { isUndefined } from "../../../types/undefined"; + +export enum TwilioMessageStatus { + ACCEPTED = "accepted", + SCHEDULED = "scheduled", + CANCELED = "canceled", + QUEUED = "queued", + SENDING = "sending", + SENT = "sent", + FAILED = "failed", + DELIVERED = "delivered", + UNDELIVERED = "undelivered", + RECEIVING = "receiving", + RECEIVED = "received", + READ = "read", +} + +export function isTwilioMessageStatus (value: unknown) : value is TwilioMessageStatus { + return isEnum(TwilioMessageStatus, value); +} + +export function explainTwilioMessageStatus (value : unknown) : string { + return explainEnum("TwilioMessageStatus", TwilioMessageStatus, isTwilioMessageStatus, value); +} + +export function stringifyTwilioMessageStatus (value : TwilioMessageStatus) : string { + return stringifyEnum(TwilioMessageStatus, value); +} + +export function parseTwilioMessageStatus (value: any) : TwilioMessageStatus | undefined { + return parseEnum(TwilioMessageStatus, value) as TwilioMessageStatus | undefined; +} + +export function isTwilioMessageStatusOrUndefined (value: unknown): value is TwilioMessageStatus | undefined { + return isUndefined(value) || isTwilioMessageStatus(value); +} + +export function explainTwilioMessageStatusOrUndefined (value: unknown): string { + return isTwilioMessageStatusOrUndefined(value) ? explainOk() : explainNot(explainOr(['TwilioMessageStatus', 'undefined'])); +} diff --git a/twilio/twilio-constants.ts b/twilio/twilio-constants.ts new file mode 100644 index 0000000..0a21e8e --- /dev/null +++ b/twilio/twilio-constants.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +export const TWILIO_PRODUCTION_URL = 'https://api.twilio.com/2010-04-01'; + +export const TWILIO_CREATE_MESSAGE_PATH = (AccountSid: string) => `https://api.twilio.com/2010-04-01/Accounts/${q(AccountSid)}/Messages.json`; + +function q (value: string) : string { + return encodeURIComponent(value); +} diff --git a/types/Array.ts b/types/Array.ts new file mode 100644 index 0000000..2f73aba --- /dev/null +++ b/types/Array.ts @@ -0,0 +1,449 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { default as _isArray } from "lodash/isArray"; +import { explain, explainNot, explainOk } from "./explain"; +import { ExplainCallback } from "./ExplainCallback"; +import { TestCallback } from "./TestCallback"; +import { map } from "../functions/map"; +import { every } from "../functions/every"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isArray (value: unknown): value is any[] | readonly any[] { + return _isArray(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isArrayOrUndefined (value: unknown): value is (any[] | readonly any[] | undefined) { + + if ( value === undefined ) return true; + + return isArray(value); + +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainArrayOrUndefined (value: unknown): string { + return isArrayOrUndefined(value) ? explainOk() : 'not array or undefined'; +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainArray (value: unknown): string { + return isArray(value) ? explainOk() : 'not array'; +} + +/** + * + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @__PURE__ + * @nosideeffects + */ +export function isArrayOf ( + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): value is T[] | readonly T[] { + + if ( !_isArray(value) ) return false; + + const len = value?.length ?? 0; + + if ( minLength !== undefined && len < minLength ) { + return false; + } + + if ( maxLength !== undefined && len > maxLength ) { + return false; + } + + if ( isItem !== undefined ) { + return every(value, isItem); + } + + return true; + +} + +/** + * + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @param itemTypeName + * @param itemExplain + * @__PURE__ + * @nosideeffects + */ +export function explainArrayOf ( + itemTypeName: string, + itemExplain: ExplainCallback, + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): string { + if ( isArrayOf(value, isItem, minLength, maxLength) ) return explainOk(); + if ( !isArray(value) ) return explainNot(itemTypeName); + if ( value?.length < 1 ) return explainNot(itemTypeName); + return `${explainNot(itemTypeName)}: ${ + explain( + map( + value, + (item: any): string => itemExplain(item) + ) + ) + }`; +} + +/** + * + * @param value + * @param isItemA + * @param isItemB + * @__PURE__ + * @nosideeffects + */ +export function isPairArrayOf ( + value: unknown, + isItemA: TestCallback, + isItemB: TestCallback = isItemA, +): value is [T1, T2] | readonly [T1, T2] { + if ( !_isArray(value) ) return false; + const len : number = value?.length ?? 0; + if ( len !== 2 ) { + return false; + } + const [a, b] = value; + return isItemA(a, 0, value) && isItemB(b, 1, value); +} + +/** + * + * @param value + * @param isItemA + * @param isItemB + * @param itemTypeNameA + * @param itemTypeNameB + * @__PURE__ + * @nosideeffects + */ +export function explainPairArrayOf ( + value: unknown, + itemTypeNameA: string, + isItemA: TestCallback, + itemTypeNameB: string = itemTypeNameA, + isItemB: TestCallback = isItemA, +): string { + return isPairArrayOf( value, isItemA, isItemB ) ? explainOk() : explainNot( `[${itemTypeNameA}, ${itemTypeNameB}]` ); +} + +/** + * + * @param value + * @param isItemA + * @param isItemB + * @param isItemC + * @__PURE__ + * @nosideeffects + */ +export function isTripletArrayOf ( + value: unknown, + isItemA: TestCallback, + isItemB: TestCallback, + isItemC: TestCallback, +): value is [T1, T2, T3] | readonly [T1, T2, T3] { + if ( !_isArray(value) ) return false; + const len : number = value?.length ?? 0; + if ( len !== 3 ) { + return false; + } + const [a, b, c] = value; + return ( + isItemA(a, 0, value) + && isItemB(b, 1, value) + && isItemC(c, 2, value) + ); +} + +/** + * + * @param value + * @param isItemA + * @param isItemB + * @param isItemC + * @param itemTypeNameA + * @param itemTypeNameB + * @param itemTypeNameC + * @__PURE__ + * @nosideeffects + */ +export function explainTripletArrayOf ( + value: unknown, + itemTypeNameA: string, + isItemA: TestCallback, + itemTypeNameB: string, + isItemB: TestCallback, + itemTypeNameC: string, + isItemC: TestCallback, +): string { + return isTripletArrayOf( + value, + isItemA, + isItemB, + isItemC, + ) ? explainOk() : explainNot( + `[${itemTypeNameA}, ${itemTypeNameB}, ${itemTypeNameC}]` + ); +} + +/** + * + * @param value + * @param isItemA + * @param isItemB + * @param isItemC + * @param isItemD + * @__PURE__ + * @nosideeffects + */ +export function isTetradArrayOf ( + value: unknown, + isItemA: TestCallback, + isItemB: TestCallback, + isItemC: TestCallback, + isItemD: TestCallback, +): value is [T1, T2, T3, T4] | readonly [T1, T2, T3, T4] { + if ( !_isArray(value) ) return false; + const len : number = value?.length ?? 0; + if ( len !== 4 ) { + return false; + } + const [a, b, c, d] = value; + return ( + isItemA(a, 0, value) + && isItemB(b, 1, value) + && isItemC(c, 2, value) + && isItemD(d, 3, value) + ); +} + +/** + * + * @param value + * @param isItemA + * @param isItemB + * @param isItemC + * @param isItemD + * @param itemTypeNameA + * @param itemTypeNameB + * @param itemTypeNameC + * @param itemTypeNameD + * @__PURE__ + * @nosideeffects + */ +export function explainTetradArrayOf ( + value: unknown, + itemTypeNameA: string, + isItemA: TestCallback, + itemTypeNameB: string, + isItemB: TestCallback, + itemTypeNameC: string, + isItemC: TestCallback, + itemTypeNameD: string, + isItemD: TestCallback, +): string { + return isTetradArrayOf( + value, + isItemA, + isItemB, + isItemC, + isItemD, + ) ? explainOk() : explainNot( + `[${itemTypeNameA}, ${itemTypeNameB}, ${itemTypeNameC}, ${itemTypeNameD}]` + ); +} + +/** + * + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @__PURE__ + * @nosideeffects + */ +export function isReadonlyArrayOf ( + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): value is readonly T[] { + return isArrayOf(value, isItem, minLength, maxLength); +} + +/** + * + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @__PURE__ + * @nosideeffects + */ +export function isArrayOfOrUndefined ( + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): value is T[] | readonly T[] | undefined { + if ( value === undefined ) return true; + return isArrayOf(value, isItem, minLength, maxLength); +} + +/** + * + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @param itemTypeName + * @param itemExplain + * @__PURE__ + * @nosideeffects + */ +export function explainArrayOfOrUndefined ( + itemTypeName: string, + itemExplain: ExplainCallback, + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): string { + if ( isArrayOfOrUndefined(value, isItem, minLength, maxLength) ) return explainOk(); + if ( !isArray(value) ) return explainNot(itemTypeName); + if ( value?.length < 1 ) return explainNot(itemTypeName); + return `${explainNot(itemTypeName)}: ${ + explain( + map( + value, + (item: any): string => { + return itemExplain(item); + } + ) + ) + }`; +} + +/** + * + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @__PURE__ + * @nosideeffects + */ +export function isReadonlyArrayOfOrUndefined ( + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): value is readonly T[] | undefined { + if ( value === undefined ) return true; + return isReadonlyArrayOf(value, isItem, minLength, maxLength); +} + +/** + * + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @param itemTypeName + * @param itemExplain + * @__PURE__ + * @nosideeffects + */ +export function explainReadonlyArrayOfOrUndefined ( + itemTypeName: string, + itemExplain: ExplainCallback, + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): string { + return explainArrayOfOrUndefined( + itemTypeName, + itemExplain, + value, + isItem, + minLength, + maxLength + ); +} + +/** + * + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @__PURE__ + * @nosideeffects + */ +export function isArrayOrUndefinedOf ( + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): value is (T[] | readonly T[] | undefined) { + if ( value === undefined ) return true; + return isArrayOf(value, isItem, minLength, maxLength); +} + +/** + * + * @param itemTypeName + * @param itemExplain + * @param value + * @param isItem + * @param minLength + * @param maxLength + * @__PURE__ + * @nosideeffects + */ +export function explainArrayOrUndefinedOf ( + itemTypeName: string, + itemExplain: ExplainCallback, + value: any, + isItem: TestCallback | undefined = undefined, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): string { + if ( value === undefined ) return explainOk(); + return explainArrayOf(itemTypeName, itemExplain, value, isItem, minLength, maxLength); +} diff --git a/types/AssertCallback.ts b/types/AssertCallback.ts new file mode 100644 index 0000000..fa6c51c --- /dev/null +++ b/types/AssertCallback.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +export interface AssertCallback { + (value: any): void; +} diff --git a/types/Boolean.ts b/types/Boolean.ts new file mode 100644 index 0000000..4c2af61 --- /dev/null +++ b/types/Boolean.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isUndefined } from "./undefined"; +import isBoolean from "lodash/isBoolean"; +import { explainOk } from "./explain"; +import { isNull } from "./Null"; + +export {default as isBoolean} from 'lodash/isBoolean.js'; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainBoolean (value: unknown): string { + return isBoolean(value) ? explainOk() : 'not boolean'; +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function parseBoolean (value: any): boolean | undefined { + if ( value === undefined || value === null || value === '' ) return undefined; + if ( isBoolean(value) ) return value; + if ([ "true", "t", "on", "1", "enabled" ].includes(`${value}`.toLowerCase()) ) { + return true; + } + if ([ "false", "f", "off", "0", "disabled" ].includes(`${value}`.toLowerCase()) ) { + return false; + } + return undefined; +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isBooleanOrUndefined (value: unknown): value is boolean | undefined { + return isUndefined(value) || isBoolean(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainBooleanOrUndefined (value: unknown): string { + return isBooleanOrUndefined(value) ? explainOk() : 'not boolean or undefined'; +} + + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isBooleanOrNullOrUndefined (value: unknown): value is boolean | undefined | null { + return isUndefined(value) || isNull(value) || isBoolean(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainBooleanOrNullOrUndefined (value: unknown): string { + return isBooleanOrNullOrUndefined(value) ? explainOk() : 'not boolean or undefined or null'; +} + diff --git a/types/BooleanArray.ts b/types/BooleanArray.ts new file mode 100644 index 0000000..ef2227e --- /dev/null +++ b/types/BooleanArray.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isArray } from "./Array"; +import isBoolean from "lodash/isBoolean"; +import { every } from "../functions/every"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isBooleanArray (value: unknown): value is boolean[] { + return ( + !!value + && isArray(value) + && every(value, isBoolean) + ); +} diff --git a/types/Buffer.ts b/types/Buffer.ts new file mode 100644 index 0000000..f64c5dc --- /dev/null +++ b/types/Buffer.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { default as _isBuffer } from "lodash/isBuffer"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isBuffer (value: unknown): value is Buffer { + return _isBuffer(value); +} diff --git a/types/CalendarDTO.ts b/types/CalendarDTO.ts new file mode 100644 index 0000000..32c99e7 --- /dev/null +++ b/types/CalendarDTO.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { CalendarEvent, isCalendarEvent } from "./CalendarEvent"; +import { map } from "../functions/map"; +import { isRegularObject } from "./RegularObject"; +import { hasNoOtherKeys } from "./OtherKeys"; +import { isArrayOf } from "./Array"; + +export interface CalendarDTO { + readonly events : readonly CalendarEvent[]; +} + +export function createCalendarDTO ( + events: readonly CalendarEvent[] | CalendarEvent[] +): CalendarDTO { + return { + events: map(events, item => item) + }; +} + +export function isCalendarDTO (value: any): value is CalendarDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'events' + ]) + && isArrayOf(value?.events, isCalendarEvent) + ); +} + +export function stringifyCalendarDTO (value: CalendarDTO): string { + return `CalendarDTO(${value})`; +} + +export function parseCalendarDTO (value: any): CalendarDTO | undefined { + if ( isCalendarDTO(value) ) return value; + return undefined; +} diff --git a/types/CalendarEvent.ts b/types/CalendarEvent.ts new file mode 100644 index 0000000..5861438 --- /dev/null +++ b/types/CalendarEvent.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "./String"; +import { isRegularObject } from "./RegularObject"; +import { hasNoOtherKeys } from "./OtherKeys"; + +export interface CalendarEvent { + readonly start : string; + readonly end : string; + readonly repeatRule : string; + readonly stamp : string; + readonly uid : string; + readonly created : string; + readonly description : string; + readonly lastModified : string; + readonly location : string; + readonly sequence : string; + readonly status : string; + readonly summary : string; + readonly transparency : string; +} + +export function createCalendarEvent ( + start : string, + end : string, + repeatRule : string, + stamp : string, + uid : string, + created : string, + description : string, + lastModified : string, + location : string, + sequence : string, + status : string, + summary : string, + transparency : string +): CalendarEvent { + return { + start, + end, + repeatRule, + stamp, + uid, + created, + description, + lastModified, + location, + sequence, + status, + summary, + transparency + }; +} + +export function isCalendarEvent (value: any): value is CalendarEvent { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'start', + 'end', + 'repeatRule', + 'stamp', + 'uid', + 'created', + 'description', + 'lastModified', + 'location', + 'sequence', + 'status', + 'summary', + 'transparency' + ]) + && isString(value?.start) + && isString(value?.end) + && isString(value?.repeatRule) + && isString(value?.stamp) + && isString(value?.uid) + && isString(value?.created) + && isString(value?.description) + && isString(value?.lastModified) + && isString(value?.location) + && isString(value?.sequence) + && isString(value?.status) + && isString(value?.summary) + && isString(value?.transparency) + ); +} + +export function stringifyCalendarEvent (value: CalendarEvent): string { + return `CalendarEvent(${value})`; +} + +export function parseCalendarEvent (value: any): CalendarEvent | undefined { + if ( isCalendarEvent(value) ) return value; + return undefined; +} diff --git a/types/ChildProcessError.ts b/types/ChildProcessError.ts new file mode 100644 index 0000000..6277f2c --- /dev/null +++ b/types/ChildProcessError.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2022-2023 Heusala Group Oy. All rights reserved. +// Copyright (c) 2020-2021 Sendanor. All rights reserved. + +import { ReadonlyJsonObject } from "../Json"; + +export class ChildProcessError extends Error { + + public readonly name : string; + public readonly args : readonly string[]; + public readonly status : number; + public readonly signal ?: string | number | undefined; + public readonly origMessage ?: string | undefined; + + // @ts-ignore + private readonly __proto__: any; + + public constructor ( + name : string, + args : readonly string[], + status : number, + signal ?: number | string | undefined, + message ?: string + ) { + + super( ChildProcessError.stringifyExceptionArguments( + name, + args, + status, + signal, + message + )); + + const actualProto = new.target.prototype; + + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, actualProto); + } else { + this.__proto__ = actualProto; + } + + this.name = name; + this.args = args; + this.status = status; + this.signal = signal; + this.origMessage = message; + } + + /** + * *Note!* For some reason this method is called instead of toString() for + * exception conversions, so this returns a full string presentaton now. + */ + public valueOf () : string { + return this.toString(); + } + + public toString () : string { + return ChildProcessError.stringifyExceptionArguments( + this.name, + this.args, + this.status, + this.signal, + this.origMessage + ); + } + + public toJSON () : ReadonlyJsonObject { + const { + name, + args, + status, + signal, + message + } = this; + return { + name, + args, + status, + signal, + message + }; + } + + public getStatusCode () : number { + return this.status; + } + + public static stringifyExceptionArguments ( + name : string, + args : readonly string[], + status : number, + signal ?: number | string | undefined, + message ?: string + ) : string { + return `Command "${name}${args?.length?' ':''}${(args??[]).join(' ')}": ${ + message + ? message + : ( + signal + ? `Signal ${signal}` + : ( + status + ? `Exit status ${status}` + : `Unspecified error` + ) + ) + }`; + } + + public static create ( + name : string, + args : readonly string[], + status : number, + signal ?: number | string, + message ?: string + ) : ChildProcessError { + return new ChildProcessError( + name, + args, + status, + signal, + message + ); + } + +} + +export function createChildProcessError ( + name : string, + args : readonly string[], + status : number, + signal ?: number | string, + message ?: string +) : ChildProcessError { + return ChildProcessError.create( + name, + args, + status, + signal, + message + ); +} + +export function isChildProcessError (value : any) : value is ChildProcessError { + return ( + !!value + && value instanceof ChildProcessError + ); +} diff --git a/types/CookieObject.test.ts b/types/CookieObject.test.ts new file mode 100644 index 0000000..e28b446 --- /dev/null +++ b/types/CookieObject.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { parseCookieObject, parseCookiePair } from "./CookieObject"; +import { SameSite } from "./SameSite"; + +describe('CookieObject', () => { + + describe('parseCookieObject', () => { + + it('can parse string cookies', () => { + expect(parseCookieObject('id=a3fWa; Expires=Thu, 21 Oct 2021 07:28:00 GMT; Secure; HttpOnly')).toStrictEqual( + { + name: 'id', + value: 'a3fWa', + expires: 'Thu, 21 Oct 2021 07:28:00 GMT', + secure: true, + httpOnly: true + } + ); + }); + + it('can parse string cookies with SameSite=Strict', () => { + expect(parseCookieObject('id=a3fWa; SameSite=Strict')).toStrictEqual( + { + name: 'id', + value: 'a3fWa', + sameSite: SameSite.STRICT + } + ); + }); + + it('can parse string cookies with SameSite=Lax', () => { + expect(parseCookieObject('id=a3fWa; SameSite=Lax')).toStrictEqual( + { + name: 'id', + value: 'a3fWa', + sameSite: SameSite.LAX + } + ); + }); + + it('can parse string cookies with SameSite=None', () => { + expect(parseCookieObject('id=a3fWa; SameSite=None')).toStrictEqual( + { + name: 'id', + value: 'a3fWa', + sameSite: SameSite.NONE + } + ); + }); + + it('can parse string cookies with path', () => { + expect(parseCookieObject('id=a3fWa; path=/')).toStrictEqual( + { + name: 'id', + value: 'a3fWa', + path: '/' + } + ); + }); + + it('can parse string cookies with max-age', () => { + expect(parseCookieObject('id=a3fWa; max-age=3600')).toStrictEqual( + { + name: 'id', + value: 'a3fWa', + maxAge: 3600 + } + ); + }); + + it('can parse string cookies with domain', () => { + expect(parseCookieObject('id=a3fWa; domain=example.com')).toStrictEqual( + { + name: 'id', + value: 'a3fWa', + domain: 'example.com' + } + ); + }); + + }); + + describe('parseCookiePair', () => { + + it('can parse string pairs', () => { + expect( parseCookiePair('id=a3fWa') ).toStrictEqual(['id', 'a3fWa']); + expect( parseCookiePair('Expires=Thu, 21 Oct 2021 07:28:00 GMT') ).toStrictEqual(['Expires', 'Thu, 21 Oct 2021 07:28:00 GMT']); + expect( parseCookiePair('Secure') ).toStrictEqual(['Secure', undefined]); + expect( parseCookiePair('HttpOnly') ).toStrictEqual(['HttpOnly', undefined]); + }); + + }); + +}); diff --git a/types/CookieObject.ts b/types/CookieObject.ts new file mode 100644 index 0000000..cf1412e --- /dev/null +++ b/types/CookieObject.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainProperty } from "./explain"; +import { explainRegularObject, isRegularObject } from "./RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "./OtherKeys"; +import { explainSameSiteOrUndefined, isSameSiteOrUndefined, parseSameSite, SameSite } from "./SameSite"; +import { explainString, explainStringOrUndefined, isString, isStringOrUndefined } from "./String"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "./Boolean"; +import { trim } from "../functions/trim"; +import { split } from "../functions/split"; +import { map } from "../functions/map"; +import { trimStart } from "../functions/trimStart"; +import { reduce } from "../functions/reduce"; +import { LogService } from "../LogService"; +import { explainNumberOrUndefined, isNumberOrUndefined, parseInteger } from "./Number"; + +const LOG = LogService.createLogger('CookieObject'); + +export interface CookieObject { + + readonly name : string; + readonly value ?: string; + readonly domain ?: string; + readonly path ?: string; + readonly httpOnly ?: boolean; + readonly secure ?: boolean; + readonly expires ?: string; + readonly maxAge ?: number; + readonly sameSite ?: SameSite; + +} + +export function createCookieObject ( + name : string, + value : string, + domain : string, + path : string, + httpOnly : boolean, + secure : boolean, + expires : string, + maxAge : number, + sameSite : SameSite +) : CookieObject { + return { + path, + name, + value, + domain, + httpOnly, + secure, + expires, + maxAge, + sameSite + }; +} + +export function isCookieObject (value: unknown) : value is CookieObject { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'path', + 'name', + 'value', + 'domain', + 'httpOnly', + 'secure', + 'expires', + 'maxAge', + 'sameSite' + ]) + && isString(value?.name) + && isStringOrUndefined(value?.value) + && isStringOrUndefined(value?.path) + && isStringOrUndefined(value?.domain) + && isBooleanOrUndefined(value?.httpOnly) + && isBooleanOrUndefined(value?.secure) + && isStringOrUndefined(value?.expires) + && isNumberOrUndefined(value?.maxAge) + && isSameSiteOrUndefined(value?.sameSite) + ); +} + +export function explainCookieObject (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'path', + 'name', + 'value', + 'domain', + 'httpOnly', + 'secure', + 'expires', + 'maxAge', + 'sameSite' + ]) + , explainProperty("path", explainStringOrUndefined(value?.path)) + , explainProperty("name", explainString(value?.name)) + , explainProperty("value", explainStringOrUndefined(value?.value)) + , explainProperty("domain", explainStringOrUndefined(value?.domain)) + , explainProperty("httpOnly", explainBooleanOrUndefined(value?.httpOnly)) + , explainProperty("secure", explainBooleanOrUndefined(value?.secure)) + , explainProperty("expires", explainStringOrUndefined(value?.expires)) + , explainProperty("maxAge", explainNumberOrUndefined(value?.maxAge)) + , explainProperty("sameSite", explainSameSiteOrUndefined(value?.sameSite)) + ] + ); +} + +export function stringifyCookieObject (value : CookieObject) : string { + return `CookieObject(${value})`; +} + +export function parseCookieObject (value: unknown) : CookieObject | undefined { + if (isString(value)) { + const pairs = map(split(trim(value), ';'), trimStart); + const firstPair = pairs.shift(); + if (!firstPair) return undefined; + const [name, cookieValue] = parseCookiePair(firstPair); + const cookie : CookieObject = { + name, + value: cookieValue + }; + return reduce( + pairs, + (obj: CookieObject, param: string) : CookieObject => { + const [paramKey, paramValue] = parseCookiePair(param); + switch(paramKey.toLowerCase()) { + + case 'samesite': return { + ...obj, + sameSite: parseSameSite(paramValue) + }; + + case 'path': return { + ...obj, + path: paramValue + }; + + case 'max-age': return { + ...obj, + maxAge: parseInteger(paramValue) + }; + + case 'domain': return { + ...obj, + domain: paramValue + }; + + case 'secure': return { + ...obj, + secure: true + }; + + case 'httponly': return { + ...obj, + httpOnly: true + }; + + case 'expires': return { + ...obj, + expires: paramValue + }; + + } + LOG.warn(`Warning! Could not understand "${paramKey}=${paramValue}"`); + return obj; + }, + cookie + ); + } + if (isCookieObject(value)) return value; + return undefined; +} + +/** + * This is exported only so that it can be unit tested. + * + * You should use `parseCookieObject()` instead -- or publish more generic utility + * function under functions. + * + * @param line + */ +export function parseCookiePair ( + line: string +) : [string, string|undefined] { + const i = line.indexOf('='); + if (i >= 0) { + const name = line.substring(0, i); + const value = line.substring(i + 1); + return [name, value]; + } + return [line, undefined]; +} \ No newline at end of file diff --git a/types/Country.test.ts b/types/Country.test.ts new file mode 100644 index 0000000..538a630 --- /dev/null +++ b/types/Country.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createCountry, isCountry, parseCountry } from "./Country"; +import { CountryCode } from "./CountryCode"; +import { Sovereignty } from "./Sovereignty"; + +describe('Country', () => { + + describe('createCountry', () => { + + it('should return a country object', () => { + const country = createCountry( + CountryCode.AF, + Sovereignty.UN_MEMBER_STATE, + 'AFG', + 4, + 'Kabul', + '.af' + ); + + expect(country).toEqual({ + sovereignty: Sovereignty.UN_MEMBER_STATE, + iso2: CountryCode.AF, + iso3: 'AFG', + num: 4, + subdivision: 'Kabul', + tld: '.af' + }); + + }); + + }); + + describe('isCountry', () => { + + it('should return true for valid country objects', () => { + const country = createCountry( + CountryCode.AF, + Sovereignty.UN_MEMBER_STATE, + 'AFG', + 4, + 'Kabul', + '.af' + ); + + expect(isCountry(country)).toBe(true); + }); + + it('should return false for invalid country objects', () => { + expect(isCountry('not a country')).toBe(false); + expect(isCountry(123)).toBe(false); + expect(isCountry(null)).toBe(false); + expect(isCountry(undefined)).toBe(false); + }); + + }); + + describe('parseCountry', () => { + + it('should return the country object for valid input', () => { + const country = createCountry( + CountryCode.AF, + Sovereignty.UN_MEMBER_STATE, + 'AFG', + 4, + 'Kabul', + '.af' + ); + + expect(parseCountry(country)).toEqual(country); + }); + + it('should return undefined for invalid input', () => { + expect(parseCountry('not a country')).toBeUndefined(); + expect(parseCountry(123)).toBeUndefined(); + expect(parseCountry(null)).toBeUndefined(); + expect(parseCountry(undefined)).toBeUndefined(); + }); + + }); + +}); diff --git a/types/Country.ts b/types/Country.ts new file mode 100644 index 0000000..71f51ae --- /dev/null +++ b/types/Country.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { CountryCode } from "./CountryCode"; +import { explainSovereignty, isSovereignty, Sovereignty } from "./Sovereignty"; +import { explainString, isString } from "./String"; +import { explainRegularObject, isRegularObject } from "./RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "./OtherKeys"; +import { explainNumber, isNumber } from "./Number"; +import { isUndefined } from "./undefined"; +import { explain, explainNot, explainOk, explainOr, explainProperty } from "./explain"; +import { isStringArray } from "./StringArray"; + +export interface Country { + readonly sovereignty : Sovereignty; + readonly iso2 : CountryCode | string; + readonly iso3 : string; + readonly num : number; + readonly subdivision : string; + readonly tld : string | string[]; +} + +export function createCountry ( + iso2 : CountryCode, + sovereignty : Sovereignty, + iso3 : string, + num : number, + subdivision : string, + tld : string | string[] +) : Country { + return { + sovereignty, + iso2, + iso3, + num, + subdivision, + tld + }; +} + +export function isCountry (value: any): value is Country { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'sovereignty', + 'iso2', + 'iso3', + 'num', + 'subdivision', + 'tld', + ]) + && isSovereignty(value?.sovereignty) + && isString(value?.iso2) + && isString(value?.iso3) + && isNumber(value?.num) + && isString(value?.subdivision) + && (isString(value?.tld) || isStringArray(value?.tld)) + ); +} + +export function parseCountry (value: any): Country | undefined { + if ( isCountry(value) ) return value; + return undefined; +} + +export function explainCountry (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'sovereignty', + 'iso2', + 'iso3', + 'num', + 'subdivision', + 'tld', + ]) + , explainProperty("sovereignty", explainSovereignty(value?.sovereignty)) + , explainProperty("iso2", explainString(value?.iso2)) + , explainProperty("iso3", explainString(value?.iso3)) + , explainProperty("num", explainNumber(value?.num)) + , explainProperty("subdivision", explainString(value?.subdivision)) + , explainProperty("tld", isString(value?.tld) || isStringArray(value?.tld) ? explainOk() : explainNot('string or string[]') ) + ] + ); +} +export function isCountryOrUndefined (value: unknown): value is Country | undefined { + return isUndefined(value) || isCountry(value); +} + +export function explainCountryOrUndefined (value: unknown): string { + return isCountryOrUndefined(value) ? explainOk() : explainNot(explainOr(['Country', 'undefined'])); +} diff --git a/types/CountryCode.test.ts b/types/CountryCode.test.ts new file mode 100644 index 0000000..5c7bec2 --- /dev/null +++ b/types/CountryCode.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { CountryCode, isCountryCode, parseCountryCode } from "./CountryCode"; +import { values } from "../functions/values"; +import { forEach } from "../functions/forEach"; + +const ALL_COUNTRY_CODES : string[] = values(CountryCode); + +describe('CountryCode', () => { + + describe('isCountryCode', () => { + + forEach( + ALL_COUNTRY_CODES, + (code) => { + it(`should return true for valid country ${code}`, () => { + expect(isCountryCode(code)).toBe(true); + }); + } + ); + + it('should return false for invalid country codes', () => { + expect(isCountryCode('XX')).toBe(false); + expect(isCountryCode(123)).toBe(false); + expect(isCountryCode(null)).toBe(false); + expect(isCountryCode(undefined)).toBe(false); + }); + }); + + describe('parseCountryCode', () => { + + forEach( + ALL_COUNTRY_CODES, + (code) => { + it(`should return the country code for ${code}`, () => { + expect(parseCountryCode(code)).toBe(code); + }); + + } + ); + + it('should return undefined for invalid input', () => { + expect(parseCountryCode('XX')).toBeUndefined(); + expect(parseCountryCode(123)).toBeUndefined(); + expect(parseCountryCode(null)).toBeUndefined(); + expect(parseCountryCode(undefined)).toBeUndefined(); + }); + + }); + +}); diff --git a/types/CountryCode.ts b/types/CountryCode.ts new file mode 100644 index 0000000..f042e7c --- /dev/null +++ b/types/CountryCode.ts @@ -0,0 +1,1024 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { trim } from "../functions/trim"; +import { explainEnum } from "./Enum"; + +export enum CountryCode { + AF = "AF", + AX = "AX", + AL = "AL", + DZ = "DZ", + AS = "AS", + AD = "AD", + AO = "AO", + AI = "AI", + AQ = "AQ", + AG = "AG", + AR = "AR", + AM = "AM", + AW = "AW", + AU = "AU", + AT = "AT", + AZ = "AZ", + BS = "BS", + BH = "BH", + BD = "BD", + BB = "BB", + BY = "BY", + BE = "BE", + BZ = "BZ", + BJ = "BJ", + BM = "BM", + BT = "BT", + BO = "BO", + BQ = "BQ", + BA = "BA", + BW = "BW", + BV = "BV", + BR = "BR", + IO = "IO", + BN = "BN", + BG = "BG", + BF = "BF", + BI = "BI", + CV = "CV", + KH = "KH", + CM = "CM", + CA = "CA", + KY = "KY", + CF = "CF", + TD = "TD", + CL = "CL", + CN = "CN", + CX = "CX", + CC = "CC", + CO = "CO", + KM = "KM", + CD = "CD", + CG = "CG", + CK = "CK", + CR = "CR", + CI = "CI", + HR = "HR", + CU = "CU", + CW = "CW", + CY = "CY", + CZ = "CZ", + DK = "DK", + DJ = "DJ", + DM = "DM", + DO = "DO", + EC = "EC", + EG = "EG", + SV = "SV", + GQ = "GQ", + ER = "ER", + EE = "EE", + SZ = "SZ", + ET = "ET", + FK = "FK", + FO = "FO", + FJ = "FJ", + FI = "FI", + FR = "FR", + GF = "GF", + PF = "PF", + TF = "TF", + GA = "GA", + GM = "GM", + GE = "GE", + DE = "DE", + GH = "GH", + GI = "GI", + GR = "GR", + GL = "GL", + GD = "GD", + GP = "GP", + GU = "GU", + GT = "GT", + GG = "GG", + GN = "GN", + GW = "GW", + GY = "GY", + HT = "HT", + HM = "HM", + VA = "VA", + HN = "HN", + HK = "HK", + HU = "HU", + IS = "IS", + IN = "IN", + ID = "ID", + IR = "IR", + IQ = "IQ", + IE = "IE", + IM = "IM", + IL = "IL", + IT = "IT", + JM = "JM", + JP = "JP", + JE = "JE", + JO = "JO", + KZ = "KZ", + KE = "KE", + KI = "KI", + KP = "KP", + KR = "KR", + KW = "KW", + KG = "KG", + LA = "LA", + LV = "LV", + LB = "LB", + LS = "LS", + LR = "LR", + LY = "LY", + LI = "LI", + LT = "LT", + LU = "LU", + MO = "MO", + MK = "MK", + MG = "MG", + MW = "MW", + MY = "MY", + MV = "MV", + ML = "ML", + MT = "MT", + MH = "MH", + MQ = "MQ", + MR = "MR", + MU = "MU", + YT = "YT", + MX = "MX", + FM = "FM", + MD = "MD", + MC = "MC", + MN = "MN", + ME = "ME", + MS = "MS", + MA = "MA", + MZ = "MZ", + MM = "MM", + NA = "NA", + NR = "NR", + NP = "NP", + NL = "NL", + NC = "NC", + NZ = "NZ", + NI = "NI", + NE = "NE", + NG = "NG", + NU = "NU", + NF = "NF", + MP = "MP", + NO = "NO", + OM = "OM", + PK = "PK", + PW = "PW", + PS = "PS", + PA = "PA", + PG = "PG", + PY = "PY", + PE = "PE", + PH = "PH", + PN = "PN", + PL = "PL", + PT = "PT", + PR = "PR", + QA = "QA", + RE = "RE", + RO = "RO", + RU = "RU", + RW = "RW", + BL = "BL", + SH = "SH", + KN = "KN", + LC = "LC", + MF = "MF", + PM = "PM", + VC = "VC", + WS = "WS", + SM = "SM", + ST = "ST", + SA = "SA", + SN = "SN", + RS = "RS", + SC = "SC", + SL = "SL", + SG = "SG", + SX = "SX", + SK = "SK", + SI = "SI", + SB = "SB", + SO = "SO", + ZA = "ZA", + GS = "GS", + SS = "SS", + ES = "ES", + LK = "LK", + SD = "SD", + SR = "SR", + SJ = "SJ", + SE = "SE", + CH = "CH", + SY = "SY", + TW = "TW", + TJ = "TJ", + TZ = "TZ", + TH = "TH", + TL = "TL", + TG = "TG", + TK = "TK", + TO = "TO", + TT = "TT", + TN = "TN", + TR = "TR", + TM = "TM", + TC = "TC", + TV = "TV", + UG = "UG", + UA = "UA", + AE = "AE", + GB = "GB", + UM = "UM", + US = "US", + UY = "UY", + UZ = "UZ", + VU = "VU", + VE = "VE", + VN = "VN", + VG = "VG", + VI = "VI", + WF = "WF", + EH = "EH", + YE = "YE", + ZM = "ZM", + ZW = "ZW" +} + +export function isCountryCode (value: any): value is CountryCode { + switch (value) { + case CountryCode.AF: + case CountryCode.AX: + case CountryCode.AL: + case CountryCode.DZ: + case CountryCode.AS: + case CountryCode.AD: + case CountryCode.AO: + case CountryCode.AI: + case CountryCode.AQ: + case CountryCode.AG: + case CountryCode.AR: + case CountryCode.AM: + case CountryCode.AW: + case CountryCode.AU: + case CountryCode.AT: + case CountryCode.AZ: + case CountryCode.BS: + case CountryCode.BH: + case CountryCode.BD: + case CountryCode.BB: + case CountryCode.BY: + case CountryCode.BE: + case CountryCode.BZ: + case CountryCode.BJ: + case CountryCode.BM: + case CountryCode.BT: + case CountryCode.BO: + case CountryCode.BQ: + case CountryCode.BA: + case CountryCode.BW: + case CountryCode.BV: + case CountryCode.BR: + case CountryCode.IO: + case CountryCode.BN: + case CountryCode.BG: + case CountryCode.BF: + case CountryCode.BI: + case CountryCode.CV: + case CountryCode.KH: + case CountryCode.CM: + case CountryCode.CA: + case CountryCode.KY: + case CountryCode.CF: + case CountryCode.TD: + case CountryCode.CL: + case CountryCode.CN: + case CountryCode.CX: + case CountryCode.CC: + case CountryCode.CO: + case CountryCode.KM: + case CountryCode.CD: + case CountryCode.CG: + case CountryCode.CK: + case CountryCode.CR: + case CountryCode.CI: + case CountryCode.HR: + case CountryCode.CU: + case CountryCode.CW: + case CountryCode.CY: + case CountryCode.CZ: + case CountryCode.DK: + case CountryCode.DJ: + case CountryCode.DM: + case CountryCode.DO: + case CountryCode.EC: + case CountryCode.EG: + case CountryCode.SV: + case CountryCode.GQ: + case CountryCode.ER: + case CountryCode.EE: + case CountryCode.SZ: + case CountryCode.ET: + case CountryCode.FK: + case CountryCode.FO: + case CountryCode.FJ: + case CountryCode.FI: + case CountryCode.FR: + case CountryCode.GF: + case CountryCode.PF: + case CountryCode.TF: + case CountryCode.GA: + case CountryCode.GM: + case CountryCode.GE: + case CountryCode.DE: + case CountryCode.GH: + case CountryCode.GI: + case CountryCode.GR: + case CountryCode.GL: + case CountryCode.GD: + case CountryCode.GP: + case CountryCode.GU: + case CountryCode.GT: + case CountryCode.GG: + case CountryCode.GN: + case CountryCode.GW: + case CountryCode.GY: + case CountryCode.HT: + case CountryCode.HM: + case CountryCode.VA: + case CountryCode.HN: + case CountryCode.HK: + case CountryCode.HU: + case CountryCode.IS: + case CountryCode.IN: + case CountryCode.ID: + case CountryCode.IR: + case CountryCode.IQ: + case CountryCode.IE: + case CountryCode.IM: + case CountryCode.IL: + case CountryCode.IT: + case CountryCode.JM: + case CountryCode.JP: + case CountryCode.JE: + case CountryCode.JO: + case CountryCode.KZ: + case CountryCode.KE: + case CountryCode.KI: + case CountryCode.KP: + case CountryCode.KR: + case CountryCode.KW: + case CountryCode.KG: + case CountryCode.LA: + case CountryCode.LV: + case CountryCode.LB: + case CountryCode.LS: + case CountryCode.LR: + case CountryCode.LY: + case CountryCode.LI: + case CountryCode.LT: + case CountryCode.LU: + case CountryCode.MO: + case CountryCode.MK: + case CountryCode.MG: + case CountryCode.MW: + case CountryCode.MY: + case CountryCode.MV: + case CountryCode.ML: + case CountryCode.MT: + case CountryCode.MH: + case CountryCode.MQ: + case CountryCode.MR: + case CountryCode.MU: + case CountryCode.YT: + case CountryCode.MX: + case CountryCode.FM: + case CountryCode.MD: + case CountryCode.MC: + case CountryCode.MN: + case CountryCode.ME: + case CountryCode.MS: + case CountryCode.MA: + case CountryCode.MZ: + case CountryCode.MM: + case CountryCode.NA: + case CountryCode.NR: + case CountryCode.NP: + case CountryCode.NL: + case CountryCode.NC: + case CountryCode.NZ: + case CountryCode.NI: + case CountryCode.NE: + case CountryCode.NG: + case CountryCode.NU: + case CountryCode.NF: + case CountryCode.MP: + case CountryCode.NO: + case CountryCode.OM: + case CountryCode.PK: + case CountryCode.PW: + case CountryCode.PS: + case CountryCode.PA: + case CountryCode.PG: + case CountryCode.PY: + case CountryCode.PE: + case CountryCode.PH: + case CountryCode.PN: + case CountryCode.PL: + case CountryCode.PT: + case CountryCode.PR: + case CountryCode.QA: + case CountryCode.RE: + case CountryCode.RO: + case CountryCode.RU: + case CountryCode.RW: + case CountryCode.BL: + case CountryCode.SH: + case CountryCode.KN: + case CountryCode.LC: + case CountryCode.MF: + case CountryCode.PM: + case CountryCode.VC: + case CountryCode.WS: + case CountryCode.SM: + case CountryCode.ST: + case CountryCode.SA: + case CountryCode.SN: + case CountryCode.RS: + case CountryCode.SC: + case CountryCode.SL: + case CountryCode.SG: + case CountryCode.SX: + case CountryCode.SK: + case CountryCode.SI: + case CountryCode.SB: + case CountryCode.SO: + case CountryCode.ZA: + case CountryCode.GS: + case CountryCode.SS: + case CountryCode.ES: + case CountryCode.LK: + case CountryCode.SD: + case CountryCode.SR: + case CountryCode.SJ: + case CountryCode.SE: + case CountryCode.CH: + case CountryCode.SY: + case CountryCode.TW: + case CountryCode.TJ: + case CountryCode.TZ: + case CountryCode.TH: + case CountryCode.TL: + case CountryCode.TG: + case CountryCode.TK: + case CountryCode.TO: + case CountryCode.TT: + case CountryCode.TN: + case CountryCode.TR: + case CountryCode.TM: + case CountryCode.TC: + case CountryCode.TV: + case CountryCode.UG: + case CountryCode.UA: + case CountryCode.AE: + case CountryCode.GB: + case CountryCode.UM: + case CountryCode.US: + case CountryCode.UY: + case CountryCode.UZ: + case CountryCode.VU: + case CountryCode.VE: + case CountryCode.VN: + case CountryCode.VG: + case CountryCode.VI: + case CountryCode.WF: + case CountryCode.EH: + case CountryCode.YE: + case CountryCode.ZM: + case CountryCode.ZW: + return true; + + default: + return false; + + } +} + +export function parseCountryCode (value: any): CountryCode | undefined { + switch (trim(`${value}`.toUpperCase())) { + case CountryCode.AF: + return CountryCode.AF; + case CountryCode.AX: + return CountryCode.AX; + case CountryCode.AL: + return CountryCode.AL; + case CountryCode.DZ: + return CountryCode.DZ; + case CountryCode.AS: + return CountryCode.AS; + case CountryCode.AD: + return CountryCode.AD; + case CountryCode.AO: + return CountryCode.AO; + case CountryCode.AI: + return CountryCode.AI; + case CountryCode.AQ: + return CountryCode.AQ; + case CountryCode.AG: + return CountryCode.AG; + case CountryCode.AR: + return CountryCode.AR; + case CountryCode.AM: + return CountryCode.AM; + case CountryCode.AW: + return CountryCode.AW; + case CountryCode.AU: + return CountryCode.AU; + case CountryCode.AT: + return CountryCode.AT; + case CountryCode.AZ: + return CountryCode.AZ; + case CountryCode.BS: + return CountryCode.BS; + case CountryCode.BH: + return CountryCode.BH; + case CountryCode.BD: + return CountryCode.BD; + case CountryCode.BB: + return CountryCode.BB; + case CountryCode.BY: + return CountryCode.BY; + case CountryCode.BE: + return CountryCode.BE; + case CountryCode.BZ: + return CountryCode.BZ; + case CountryCode.BJ: + return CountryCode.BJ; + case CountryCode.BM: + return CountryCode.BM; + case CountryCode.BT: + return CountryCode.BT; + case CountryCode.BO: + return CountryCode.BO; + case CountryCode.BQ: + return CountryCode.BQ; + case CountryCode.BA: + return CountryCode.BA; + case CountryCode.BW: + return CountryCode.BW; + case CountryCode.BV: + return CountryCode.BV; + case CountryCode.BR: + return CountryCode.BR; + case CountryCode.IO: + return CountryCode.IO; + case CountryCode.BN: + return CountryCode.BN; + case CountryCode.BG: + return CountryCode.BG; + case CountryCode.BF: + return CountryCode.BF; + case CountryCode.BI: + return CountryCode.BI; + case CountryCode.CV: + return CountryCode.CV; + case CountryCode.KH: + return CountryCode.KH; + case CountryCode.CM: + return CountryCode.CM; + case CountryCode.CA: + return CountryCode.CA; + case CountryCode.KY: + return CountryCode.KY; + case CountryCode.CF: + return CountryCode.CF; + case CountryCode.TD: + return CountryCode.TD; + case CountryCode.CL: + return CountryCode.CL; + case CountryCode.CN: + return CountryCode.CN; + case CountryCode.CX: + return CountryCode.CX; + case CountryCode.CC: + return CountryCode.CC; + case CountryCode.CO: + return CountryCode.CO; + case CountryCode.KM: + return CountryCode.KM; + case CountryCode.CD: + return CountryCode.CD; + case CountryCode.CG: + return CountryCode.CG; + case CountryCode.CK: + return CountryCode.CK; + case CountryCode.CR: + return CountryCode.CR; + case CountryCode.CI: + return CountryCode.CI; + case CountryCode.HR: + return CountryCode.HR; + case CountryCode.CU: + return CountryCode.CU; + case CountryCode.CW: + return CountryCode.CW; + case CountryCode.CY: + return CountryCode.CY; + case CountryCode.CZ: + return CountryCode.CZ; + case CountryCode.DK: + return CountryCode.DK; + case CountryCode.DJ: + return CountryCode.DJ; + case CountryCode.DM: + return CountryCode.DM; + case CountryCode.DO: + return CountryCode.DO; + case CountryCode.EC: + return CountryCode.EC; + case CountryCode.EG: + return CountryCode.EG; + case CountryCode.SV: + return CountryCode.SV; + case CountryCode.GQ: + return CountryCode.GQ; + case CountryCode.ER: + return CountryCode.ER; + case CountryCode.EE: + return CountryCode.EE; + case CountryCode.SZ: + return CountryCode.SZ; + case CountryCode.ET: + return CountryCode.ET; + case CountryCode.FK: + return CountryCode.FK; + case CountryCode.FO: + return CountryCode.FO; + case CountryCode.FJ: + return CountryCode.FJ; + case CountryCode.FI: + return CountryCode.FI; + case CountryCode.FR: + return CountryCode.FR; + case CountryCode.GF: + return CountryCode.GF; + case CountryCode.PF: + return CountryCode.PF; + case CountryCode.TF: + return CountryCode.TF; + case CountryCode.GA: + return CountryCode.GA; + case CountryCode.GM: + return CountryCode.GM; + case CountryCode.GE: + return CountryCode.GE; + case CountryCode.DE: + return CountryCode.DE; + case CountryCode.GH: + return CountryCode.GH; + case CountryCode.GI: + return CountryCode.GI; + case CountryCode.GR: + return CountryCode.GR; + case CountryCode.GL: + return CountryCode.GL; + case CountryCode.GD: + return CountryCode.GD; + case CountryCode.GP: + return CountryCode.GP; + case CountryCode.GU: + return CountryCode.GU; + case CountryCode.GT: + return CountryCode.GT; + case CountryCode.GG: + return CountryCode.GG; + case CountryCode.GN: + return CountryCode.GN; + case CountryCode.GW: + return CountryCode.GW; + case CountryCode.GY: + return CountryCode.GY; + case CountryCode.HT: + return CountryCode.HT; + case CountryCode.HM: + return CountryCode.HM; + case CountryCode.VA: + return CountryCode.VA; + case CountryCode.HN: + return CountryCode.HN; + case CountryCode.HK: + return CountryCode.HK; + case CountryCode.HU: + return CountryCode.HU; + case CountryCode.IS: + return CountryCode.IS; + case CountryCode.IN: + return CountryCode.IN; + case CountryCode.ID: + return CountryCode.ID; + case CountryCode.IR: + return CountryCode.IR; + case CountryCode.IQ: + return CountryCode.IQ; + case CountryCode.IE: + return CountryCode.IE; + case CountryCode.IM: + return CountryCode.IM; + case CountryCode.IL: + return CountryCode.IL; + case CountryCode.IT: + return CountryCode.IT; + case CountryCode.JM: + return CountryCode.JM; + case CountryCode.JP: + return CountryCode.JP; + case CountryCode.JE: + return CountryCode.JE; + case CountryCode.JO: + return CountryCode.JO; + case CountryCode.KZ: + return CountryCode.KZ; + case CountryCode.KE: + return CountryCode.KE; + case CountryCode.KI: + return CountryCode.KI; + case CountryCode.KP: + return CountryCode.KP; + case CountryCode.KR: + return CountryCode.KR; + case CountryCode.KW: + return CountryCode.KW; + case CountryCode.KG: + return CountryCode.KG; + case CountryCode.LA: + return CountryCode.LA; + case CountryCode.LV: + return CountryCode.LV; + case CountryCode.LB: + return CountryCode.LB; + case CountryCode.LS: + return CountryCode.LS; + case CountryCode.LR: + return CountryCode.LR; + case CountryCode.LY: + return CountryCode.LY; + case CountryCode.LI: + return CountryCode.LI; + case CountryCode.LT: + return CountryCode.LT; + case CountryCode.LU: + return CountryCode.LU; + case CountryCode.MO: + return CountryCode.MO; + case CountryCode.MK: + return CountryCode.MK; + case CountryCode.MG: + return CountryCode.MG; + case CountryCode.MW: + return CountryCode.MW; + case CountryCode.MY: + return CountryCode.MY; + case CountryCode.MV: + return CountryCode.MV; + case CountryCode.ML: + return CountryCode.ML; + case CountryCode.MT: + return CountryCode.MT; + case CountryCode.MH: + return CountryCode.MH; + case CountryCode.MQ: + return CountryCode.MQ; + case CountryCode.MR: + return CountryCode.MR; + case CountryCode.MU: + return CountryCode.MU; + case CountryCode.YT: + return CountryCode.YT; + case CountryCode.MX: + return CountryCode.MX; + case CountryCode.FM: + return CountryCode.FM; + case CountryCode.MD: + return CountryCode.MD; + case CountryCode.MC: + return CountryCode.MC; + case CountryCode.MN: + return CountryCode.MN; + case CountryCode.ME: + return CountryCode.ME; + case CountryCode.MS: + return CountryCode.MS; + case CountryCode.MA: + return CountryCode.MA; + case CountryCode.MZ: + return CountryCode.MZ; + case CountryCode.MM: + return CountryCode.MM; + case CountryCode.NA: + return CountryCode.NA; + case CountryCode.NR: + return CountryCode.NR; + case CountryCode.NP: + return CountryCode.NP; + case CountryCode.NL: + return CountryCode.NL; + case CountryCode.NC: + return CountryCode.NC; + case CountryCode.NZ: + return CountryCode.NZ; + case CountryCode.NI: + return CountryCode.NI; + case CountryCode.NE: + return CountryCode.NE; + case CountryCode.NG: + return CountryCode.NG; + case CountryCode.NU: + return CountryCode.NU; + case CountryCode.NF: + return CountryCode.NF; + case CountryCode.MP: + return CountryCode.MP; + case CountryCode.NO: + return CountryCode.NO; + case CountryCode.OM: + return CountryCode.OM; + case CountryCode.PK: + return CountryCode.PK; + case CountryCode.PW: + return CountryCode.PW; + case CountryCode.PS: + return CountryCode.PS; + case CountryCode.PA: + return CountryCode.PA; + case CountryCode.PG: + return CountryCode.PG; + case CountryCode.PY: + return CountryCode.PY; + case CountryCode.PE: + return CountryCode.PE; + case CountryCode.PH: + return CountryCode.PH; + case CountryCode.PN: + return CountryCode.PN; + case CountryCode.PL: + return CountryCode.PL; + case CountryCode.PT: + return CountryCode.PT; + case CountryCode.PR: + return CountryCode.PR; + case CountryCode.QA: + return CountryCode.QA; + case CountryCode.RE: + return CountryCode.RE; + case CountryCode.RO: + return CountryCode.RO; + case CountryCode.RU: + return CountryCode.RU; + case CountryCode.RW: + return CountryCode.RW; + case CountryCode.BL: + return CountryCode.BL; + case CountryCode.SH: + return CountryCode.SH; + case CountryCode.KN: + return CountryCode.KN; + case CountryCode.LC: + return CountryCode.LC; + case CountryCode.MF: + return CountryCode.MF; + case CountryCode.PM: + return CountryCode.PM; + case CountryCode.VC: + return CountryCode.VC; + case CountryCode.WS: + return CountryCode.WS; + case CountryCode.SM: + return CountryCode.SM; + case CountryCode.ST: + return CountryCode.ST; + case CountryCode.SA: + return CountryCode.SA; + case CountryCode.SN: + return CountryCode.SN; + case CountryCode.RS: + return CountryCode.RS; + case CountryCode.SC: + return CountryCode.SC; + case CountryCode.SL: + return CountryCode.SL; + case CountryCode.SG: + return CountryCode.SG; + case CountryCode.SX: + return CountryCode.SX; + case CountryCode.SK: + return CountryCode.SK; + case CountryCode.SI: + return CountryCode.SI; + case CountryCode.SB: + return CountryCode.SB; + case CountryCode.SO: + return CountryCode.SO; + case CountryCode.ZA: + return CountryCode.ZA; + case CountryCode.GS: + return CountryCode.GS; + case CountryCode.SS: + return CountryCode.SS; + case CountryCode.ES: + return CountryCode.ES; + case CountryCode.LK: + return CountryCode.LK; + case CountryCode.SD: + return CountryCode.SD; + case CountryCode.SR: + return CountryCode.SR; + case CountryCode.SJ: + return CountryCode.SJ; + case CountryCode.SE: + return CountryCode.SE; + case CountryCode.CH: + return CountryCode.CH; + case CountryCode.SY: + return CountryCode.SY; + case CountryCode.TW: + return CountryCode.TW; + case CountryCode.TJ: + return CountryCode.TJ; + case CountryCode.TZ: + return CountryCode.TZ; + case CountryCode.TH: + return CountryCode.TH; + case CountryCode.TL: + return CountryCode.TL; + case CountryCode.TG: + return CountryCode.TG; + case CountryCode.TK: + return CountryCode.TK; + case CountryCode.TO: + return CountryCode.TO; + case CountryCode.TT: + return CountryCode.TT; + case CountryCode.TN: + return CountryCode.TN; + case CountryCode.TR: + return CountryCode.TR; + case CountryCode.TM: + return CountryCode.TM; + case CountryCode.TC: + return CountryCode.TC; + case CountryCode.TV: + return CountryCode.TV; + case CountryCode.UG: + return CountryCode.UG; + case CountryCode.UA: + return CountryCode.UA; + case CountryCode.AE: + return CountryCode.AE; + case CountryCode.GB: + return CountryCode.GB; + case CountryCode.UM: + return CountryCode.UM; + case CountryCode.US: + return CountryCode.US; + case CountryCode.UY: + return CountryCode.UY; + case CountryCode.UZ: + return CountryCode.UZ; + case CountryCode.VU: + return CountryCode.VU; + case CountryCode.VE: + return CountryCode.VE; + case CountryCode.VN: + return CountryCode.VN; + case CountryCode.VG: + return CountryCode.VG; + case CountryCode.VI: + return CountryCode.VI; + case CountryCode.WF: + return CountryCode.WF; + case CountryCode.EH: + return CountryCode.EH; + case CountryCode.YE: + return CountryCode.YE; + case CountryCode.ZM: + return CountryCode.ZM; + case CountryCode.ZW: + return CountryCode.ZW; + default : + return undefined; + } +} + +export function explainCountryCode (value : unknown) : string { + return explainEnum("CountryCode", CountryCode, isCountryCode, value); +} diff --git a/types/Currency.test.ts b/types/Currency.test.ts new file mode 100644 index 0000000..61bae52 --- /dev/null +++ b/types/Currency.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { Currency, explainCurrency, isCurrency, parseCurrency, stringifyCurrency } from './Currency'; + +describe('Currency', () => { + + describe('isCurrency', () => { + + it('should return true for valid currencies', () => { + expect(isCurrency(Currency.EUR)).toBe(true); + expect(isCurrency(Currency.USD)).toBe(true); + expect(isCurrency(Currency.GBP)).toBe(true); + }); + + it('should return false for invalid currencies', () => { + expect(isCurrency('XYZ')).toBe(false); + expect(isCurrency(null)).toBe(false); + expect(isCurrency(undefined)).toBe(false); + }); + + }); + + describe('explainCurrency', () => { + + it('should return explanation for valid currencies', () => { + expect(explainCurrency(Currency.EUR)).toContain('OK'); + expect(explainCurrency(Currency.USD)).toContain('OK'); + expect(explainCurrency(Currency.GBP)).toContain('OK'); + }); + + it('should return explanation for invalid currencies', () => { + expect(explainCurrency('XYZ')).toContain('incorrect enum value "XYZ" for Currency: Accepted values EUR, USD, GBP'); + expect(explainCurrency(null)).toContain('incorrect enum value "null" for Currency: Accepted values EUR, USD, GBP'); + expect(explainCurrency(undefined)).toContain('incorrect enum value "undefined" for Currency: Accepted values EUR, USD, GBP'); + }); + }); + + describe('stringifyCurrency', () => { + + it('should return string representation for valid currencies', () => { + expect(stringifyCurrency(Currency.EUR)).toBe('EUR'); + expect(stringifyCurrency(Currency.USD)).toBe('USD'); + expect(stringifyCurrency(Currency.GBP)).toBe('GBP'); + }); + + it('should throw an error for invalid currencies', () => { + expect( + // @ts-ignore + () => stringifyCurrency('XYZ') + ).toThrowError('Unsupported Currency value'); + }); + + }); + + describe('parseCurrency', () => { + + it('should parse string to currency for valid strings', () => { + expect(parseCurrency('EUR')).toBe(Currency.EUR); + expect(parseCurrency('USD')).toBe(Currency.USD); + expect(parseCurrency('GBP')).toBe(Currency.GBP); + }); + + it('should return undefined for invalid strings', () => { + expect(parseCurrency('XYZ')).toBeUndefined(); + expect(parseCurrency(null)).toBeUndefined(); + expect(parseCurrency(undefined)).toBeUndefined(); + }); + + }); + +}); diff --git a/types/Currency.ts b/types/Currency.ts new file mode 100644 index 0000000..d6b862d --- /dev/null +++ b/types/Currency.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "./Enum"; + +/** + * ISO 4217 code for currencies + */ +export enum Currency { + EUR = "EUR", + USD = "USD", + GBP = "GBP" +} + +export function isCurrency (value: any): value is Currency { + switch (value) { + case Currency.EUR: + case Currency.USD: + case Currency.GBP: + return true; + default: + return false; + } +} + +export function explainCurrency (value: any): string { + return explainEnum("Currency", Currency, isCurrency, value); +} + +export function stringifyCurrency (value: Currency): string { + switch (value) { + case Currency.EUR : return 'EUR'; + case Currency.USD : return 'USD'; + case Currency.GBP : return 'GBP'; + } + throw new TypeError(`Unsupported Currency value: ${value}`); +} + +export function parseCurrency (value: any): Currency | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toUpperCase()) { + case 'EUR' : return Currency.EUR; + case 'USD' : return Currency.USD; + case 'GBP' : return Currency.GBP; + default : return undefined; + } +} diff --git a/types/CurrencyExchangeCallback.ts b/types/CurrencyExchangeCallback.ts new file mode 100644 index 0000000..0adc999 --- /dev/null +++ b/types/CurrencyExchangeCallback.ts @@ -0,0 +1,10 @@ +import { Currency } from "./Currency"; + +export interface CurrencyExchangeCallback { + ( + amount: number, + from: Currency, + to: Currency, + accuracy ?: number + ): Promise; +} diff --git a/types/CurrencyFetchRatesCallback.ts b/types/CurrencyFetchRatesCallback.ts new file mode 100644 index 0000000..37a8bc5 --- /dev/null +++ b/types/CurrencyFetchRatesCallback.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { CurrencyRates } from "./CurrencyRates"; + +export interface CurrencyFetchRatesCallback { + (): Promise; +} diff --git a/types/CurrencyRates.test.ts b/types/CurrencyRates.test.ts new file mode 100644 index 0000000..d49ad1a --- /dev/null +++ b/types/CurrencyRates.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createCurrencyRates, CurrencyRates, explainCurrencyRates, isCurrencyRates, parseCurrencyRates, stringifyCurrencyRates } from "./CurrencyRates"; +import { Currency } from "./Currency"; + +describe('CurrencyRates', () => { + + const validRates: CurrencyRates = { + [Currency.EUR]: 1, + [Currency.USD]: 1.2, + [Currency.GBP]: 0.85 + }; + + const invalidRates = { + [Currency.EUR]: 1, + [Currency.USD]: "1.2", + [Currency.GBP]: 0.85 + }; + + describe('createCurrencyRates', () => { + + it('should correctly create CurrencyRates object', () => { + const rates = createCurrencyRates(1.2, 0.85); + expect(rates).toEqual(validRates); + }); + + }); + + describe('isCurrencyRates', () => { + + it('should correctly identify valid CurrencyRates', () => { + expect(isCurrencyRates(validRates)).toBe(true); + }); + + it('should correctly identify invalid CurrencyRates', () => { + expect(isCurrencyRates(invalidRates)).toBe(false); + }); + + }); + + describe('explainCurrencyRates', () => { + + it('should correctly explain valid CurrencyRates', () => { + const explanation = explainCurrencyRates(validRates); + expect(explanation).toBe('OK'); + }); + + it('should correctly explain invalid CurrencyRates', () => { + const explanation = explainCurrencyRates(invalidRates); + expect(explanation).toContain('property "USD" not number'); + }); + + }); + + describe('stringifyCurrencyRates', () => { + + it('should correctly stringify valid CurrencyRates', () => { + const stringified = stringifyCurrencyRates(validRates); + expect(stringified).toEqual('CurrencyRates([object Object])'); + }); + + it('should throw an error for invalid CurrencyRates', () => { + expect( + // @ts-ignore + () => stringifyCurrencyRates(invalidRates) + ).toThrowError('Not CurrencyRates'); + }); + + }); + + describe('parseCurrencyRates', () => { + + it('should correctly parse valid CurrencyRates', () => { + const parsed = parseCurrencyRates(validRates); + expect(parsed).toEqual(validRates); + }); + + it('should return undefined for invalid CurrencyRates', () => { + const parsed = parseCurrencyRates(invalidRates); + expect(parsed).toBeUndefined(); + }); + + }); + +}); diff --git a/types/CurrencyRates.ts b/types/CurrencyRates.ts new file mode 100644 index 0000000..02a1504 --- /dev/null +++ b/types/CurrencyRates.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { Currency } from "./Currency"; +import { explain, explainProperty } from "./explain"; +import { explainNumber, isNumber } from "./Number"; +import { explainRegularObject, isRegularObject } from "./RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "./OtherKeys"; + +export interface CurrencyRates { + readonly [Currency.EUR]: number; + readonly [Currency.USD]: number; + readonly [Currency.GBP]: number; +} + +export function createCurrencyRates ( + usd: number, + gbp: number +): CurrencyRates { + return { + [Currency.EUR]: 1, + [Currency.USD]: usd, + [Currency.GBP]: gbp + }; +} + +export function isCurrencyRates (value: any): value is CurrencyRates { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + Currency.EUR, + Currency.USD, + Currency.GBP + ]) + && isNumber(value[Currency.EUR]) + && isNumber(value[Currency.USD]) + && isNumber(value[Currency.GBP]) + ); +} + +export function explainCurrencyRates (value: any): string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + Currency.EUR, + Currency.USD, + Currency.GBP + ]), + explainProperty(Currency.EUR, explainNumber(value[Currency.EUR])), + explainProperty(Currency.USD, explainNumber(value[Currency.USD])), + explainProperty(Currency.GBP, explainNumber(value[Currency.GBP])) + ] + ); +} + +export function stringifyCurrencyRates (value: CurrencyRates): string { + if ( !isCurrencyRates(value) ) throw new TypeError(`Not CurrencyRates: ${value}`); + return `CurrencyRates(${value})`; +} + +export function parseCurrencyRates (value: any): CurrencyRates | undefined { + if ( isCurrencyRates(value) ) return value; + return undefined; +} diff --git a/types/Date.test.ts b/types/Date.test.ts new file mode 100644 index 0000000..b9bb447 --- /dev/null +++ b/types/Date.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createIsoDateString, createIsoDateStringWithMilliseconds, isIsoDateString, isIsoDateStringWithMilliseconds, parseIsoDateString, parseIsoDateStringWithMilliseconds } from "./Date"; + +describe('Date', () => { + + describe('IsoDateStringWithMilliseconds', () => { + + describe('#isIsoDateStringWithMilliseconds', () => { + + it('can detect valid values with milliseconds', () => { + expect( isIsoDateStringWithMilliseconds('2023-04-23T10:51:32.000Z') ).toBe(true); + expect( isIsoDateStringWithMilliseconds('2023-04-23T10:51:32.493Z') ).toBe(true); + expect( isIsoDateStringWithMilliseconds('2023-04-30T10:03:12.000Z') ).toBe(true); + expect( isIsoDateStringWithMilliseconds('2023-04-30T10:03:12.123Z') ).toBe(true); + }); + + it('can detect invalid values', () => { + expect( isIsoDateStringWithMilliseconds('2023-04-23T10:51:32Z') ).toBe(false); + expect( isIsoDateStringWithMilliseconds(new Date('2023-04-23T10:51:32.567Z')) ).toBe(false); + expect( isIsoDateStringWithMilliseconds('2023-04-99T10:51:32.567Z') ).toBe(false); + expect( isIsoDateStringWithMilliseconds('') ).toBe(false); + expect( isIsoDateStringWithMilliseconds(false) ).toBe(false); + expect( isIsoDateStringWithMilliseconds(true) ).toBe(false); + expect( isIsoDateStringWithMilliseconds(null) ).toBe(false); + expect( isIsoDateStringWithMilliseconds([null]) ).toBe(false); + expect( isIsoDateStringWithMilliseconds([]) ).toBe(false); + expect( isIsoDateStringWithMilliseconds({}) ).toBe(false); + expect( isIsoDateStringWithMilliseconds({hello: 'world'}) ).toBe(false); + expect( isIsoDateStringWithMilliseconds([1, 2, 3]) ).toBe(false); + expect( isIsoDateStringWithMilliseconds('hello world') ).toBe(false); + expect( isIsoDateStringWithMilliseconds(new Date('2023-04-99T10:51:32.567Z')) ).toBe(false); + }); + + }); + + describe('#createIsoDateStringWithMilliseconds', () => { + + it('can create iso date string with milliseconds', () => { + expect( createIsoDateStringWithMilliseconds('2023-04-23T10:51:32.678Z') ).toBe('2023-04-23T10:51:32.678Z'); + expect( createIsoDateStringWithMilliseconds(new Date('2023-04-23T10:51:32.678Z')) ).toBe('2023-04-23T10:51:32.678Z'); + expect( createIsoDateStringWithMilliseconds(new Date('2023-04-30T10:03:12.000Z')) ).toBe('2023-04-30T10:03:12.000Z'); + expect( createIsoDateStringWithMilliseconds(new Date('2023-04-30T10:03:12.952Z')) ).toBe('2023-04-30T10:03:12.952Z'); + }); + + it('can create iso date string without milliseconds', () => { + expect( createIsoDateStringWithMilliseconds('2023-04-23T10:51:32Z') ).toBe('2023-04-23T10:51:32.000Z'); + expect( createIsoDateStringWithMilliseconds(new Date('2023-04-23T10:51:32Z')) ).toBe('2023-04-23T10:51:32.000Z'); + }); + + it('cannot create invalid values', () => { + expect( () => createIsoDateStringWithMilliseconds('2023-04-99T10:51:32.000Z') ).toThrow(``); + expect( () => createIsoDateStringWithMilliseconds('') ).toThrow(``); + expect( () => createIsoDateStringWithMilliseconds('hello world') ).toThrow(``); + expect( () => createIsoDateStringWithMilliseconds(new Date('2023-04-99T10:51:32.000Z')) ).toThrow(``); + }); + + }); + + describe('#parseIsoDateStringWithMilliseconds', () => { + + it('can parse valid values with milliseconds', () => { + expect( parseIsoDateStringWithMilliseconds('2023-04-23T10:51:32.000Z') ).toBe('2023-04-23T10:51:32.000Z'); + expect( parseIsoDateStringWithMilliseconds('2023-04-30T10:03:12.000Z') ).toBe('2023-04-30T10:03:12.000Z'); + expect( parseIsoDateStringWithMilliseconds(new Date('2023-04-23T10:51:32.000Z')) ).toBe('2023-04-23T10:51:32.000Z'); + expect( parseIsoDateStringWithMilliseconds(1682247092123) ).toBe('2023-04-23T10:51:32.123Z'); + }); + + it('can parse valid values without milliseconds', () => { + expect( parseIsoDateStringWithMilliseconds('2023-04-23T10:51:32Z') ).toBe('2023-04-23T10:51:32.000Z'); + }); + + it('can parse valid values without milliseconds', () => { + expect( parseIsoDateStringWithMilliseconds('"Sun Apr 30 2023 10:03:12 GMT+0300 (Eastern European Summer Time)') ).toBe('2023-04-30T07:03:12.000Z'); + }); + + it('can parse invalid values', () => { + expect( parseIsoDateStringWithMilliseconds('2023-04-99T10:51:32.000Z') ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds('') ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds(undefined) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds(false) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds(true) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds(null) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds([null]) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds([]) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds({}) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds({hello: 'world'}) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds([1, 2, 3]) ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds('hello world') ).toBeUndefined(); + expect( parseIsoDateStringWithMilliseconds(new Date('2023-04-99T10:51:32.000Z')) ).toBeUndefined(); + }); + + }); + + }); + + describe('IsoDateString', () => { + + describe('#isIsoDateString', () => { + + it('can detect valid values with milliseconds', () => { + expect( isIsoDateString('2023-04-23T10:51:32.000Z') ).toBe(true); + expect( isIsoDateString('2023-04-23T10:51:32.493Z') ).toBe(true); + expect( isIsoDateString('2023-04-30T10:03:12.000Z') ).toBe(true); + expect( isIsoDateString('2023-04-30T10:03:12.123Z') ).toBe(true); + }); + + it('can detect valid values without milliseconds', () => { + expect( isIsoDateString('2023-04-23T10:51:32Z') ).toBe(true); + expect( isIsoDateString('2023-04-23T10:51:32Z') ).toBe(true); + expect( isIsoDateString('2023-04-30T10:03:12Z') ).toBe(true); + expect( isIsoDateString('2023-04-30T10:03:12Z') ).toBe(true); + }); + + it('can detect invalid values', () => { + expect( isIsoDateString(new Date('2023-04-23T10:51:32.567Z')) ).toBe(false); + expect( isIsoDateString('2023-04-99T10:51:32.567Z') ).toBe(false); + expect( isIsoDateString('') ).toBe(false); + expect( isIsoDateString(false) ).toBe(false); + expect( isIsoDateString(true) ).toBe(false); + expect( isIsoDateString(null) ).toBe(false); + expect( isIsoDateString([null]) ).toBe(false); + expect( isIsoDateString([]) ).toBe(false); + expect( isIsoDateString({}) ).toBe(false); + expect( isIsoDateString({hello: 'world'}) ).toBe(false); + expect( isIsoDateString([1, 2, 3]) ).toBe(false); + expect( isIsoDateString('hello world') ).toBe(false); + expect( isIsoDateString(new Date('2023-04-99T10:51:32.567Z')) ).toBe(false); + }); + + }); + + describe('#createIsoDateString', () => { + + it('can create iso date string with milliseconds', () => { + expect( createIsoDateString('2023-04-23T10:51:32.678Z') ).toBe('2023-04-23T10:51:32.678Z'); + expect( createIsoDateString(new Date('2023-04-23T10:51:32.678Z')) ).toBe('2023-04-23T10:51:32.678Z'); + expect( createIsoDateString(new Date('2023-04-30T10:03:12.000Z')) ).toBe('2023-04-30T10:03:12.000Z'); + expect( createIsoDateString(new Date('2023-04-30T10:03:12.952Z')) ).toBe('2023-04-30T10:03:12.952Z'); + }); + + it('can create iso date string without milliseconds', () => { + expect( createIsoDateString('2023-04-23T10:51:32Z') ).toBe('2023-04-23T10:51:32Z'); + expect( createIsoDateString(new Date('2023-04-23T10:51:32Z')) ).toBe('2023-04-23T10:51:32.000Z'); + }); + + it('cannot create invalid values', () => { + expect( () => createIsoDateString('2023-04-99T10:51:32.000Z') ).toThrow(``); + expect( () => createIsoDateString('') ).toThrow(``); + expect( () => createIsoDateString('hello world') ).toThrow(``); + expect( () => createIsoDateString(new Date('2023-04-99T10:51:32.000Z')) ).toThrow(``); + }); + + }); + + describe('#parseIsoDateString', () => { + + it('can parse valid values with milliseconds', () => { + expect( parseIsoDateString('2023-04-23T10:51:32.000Z') ).toBe('2023-04-23T10:51:32.000Z'); + expect( parseIsoDateString('2023-04-30T10:03:12.000Z') ).toBe('2023-04-30T10:03:12.000Z'); + expect( parseIsoDateString(new Date('2023-04-23T10:51:32.000Z')) ).toBe('2023-04-23T10:51:32.000Z'); + expect( parseIsoDateString(1682247092123) ).toBe('2023-04-23T10:51:32.123Z'); + }); + + it('can parse valid values without milliseconds', () => { + expect( parseIsoDateString('2023-04-23T10:51:32Z') ).toBe('2023-04-23T10:51:32Z'); + }); + + it('can parse valid values without milliseconds', () => { + expect( parseIsoDateString('"Sun Apr 30 2023 10:03:12 GMT+0300 (Eastern European Summer Time)') ).toBe('2023-04-30T07:03:12.000Z'); + }); + + it('can parse invalid values', () => { + expect( parseIsoDateString('2023-04-99T10:51:32.000Z') ).toBeUndefined(); + expect( parseIsoDateString('') ).toBeUndefined(); + expect( parseIsoDateString(undefined) ).toBeUndefined(); + expect( parseIsoDateString(false) ).toBeUndefined(); + expect( parseIsoDateString(true) ).toBeUndefined(); + expect( parseIsoDateString(null) ).toBeUndefined(); + expect( parseIsoDateString([null]) ).toBeUndefined(); + expect( parseIsoDateString([]) ).toBeUndefined(); + expect( parseIsoDateString({}) ).toBeUndefined(); + expect( parseIsoDateString({hello: 'world'}) ).toBeUndefined(); + expect( parseIsoDateString([1, 2, 3]) ).toBeUndefined(); + expect( parseIsoDateString('hello world') ).toBeUndefined(); + expect( parseIsoDateString(new Date('2023-04-99T10:51:32.000Z')) ).toBeUndefined(); + }); + + }); + + }); + +}); diff --git a/types/Date.ts b/types/Date.ts new file mode 100644 index 0000000..ff5c0fd --- /dev/null +++ b/types/Date.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isNumber } from "./Number"; +import { isString } from "./String"; +import { explainNot, explainOk, explainOr } from "./explain"; +import { trimStart } from "../functions/trimStart"; +import { isUndefined } from "./undefined"; + +export function isValidDate (time: any) : time is Date { + try { + if (!time) return false; + if (!(time instanceof Date)) return false; + const utcFullYear = time.getUTCFullYear(); + const utcMonth = time.getUTCMonth(); + const utcDate = time.getUTCDate(); + const utcHours = time.getUTCHours(); + const utcMinutes = time.getUTCMinutes(); + const utcSeconds = time.getUTCSeconds(); + return ( + isNumber(utcFullYear) && utcFullYear >= 0 + && isNumber(utcMonth) && utcMonth >= 0 + && isNumber(utcDate) && utcDate >= 0 + && isNumber(utcHours) && utcHours >= 0 + && isNumber(utcMinutes) && utcMinutes >= 0 + && isNumber(utcSeconds) && utcSeconds >= 0 + ); + } catch (err) { + return false; + } +} + +export function parseValidDate (value : unknown) : Date | undefined { + if ( isValidDate(value) ) return value; + if ( isNumber(value) ) { + const date = new Date(); + date.setTime(value); + return isValidDate(date) ? date : undefined; + } + if ( isIsoDateStringWithMilliseconds(value) ) { + const date = new Date(value); + return isValidDate(date) ? date : undefined; + } + if ( isString(value) ) { + const date = new Date(value); + return isValidDate(date) ? date : undefined; + } + return undefined; +} + + +////////////////////// IsoDateStringWithMilliseconds /////////////////////////// + + +export type IsoDateStringWithMilliseconds = string; + +export function isIsoDateStringWithMilliseconds (value: unknown): value is IsoDateStringWithMilliseconds { + if ( !isString( value ) ) return false; + if ( !/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/.test( value ) ) return false; + const d = new Date( value ); + return d.toISOString() === value; +} + +export function parseIsoDateStringWithMilliseconds ( + value: any, + trimFractions ?: boolean +): IsoDateStringWithMilliseconds | undefined { + if ( isIsoDateStringWithMilliseconds( value ) ) return value; + const date = parseValidDate( value ); + if ( !date ) return undefined; + const str = date.toISOString(); + if ( trimFractions !== true ) { + return str; + } else { + const i = str.lastIndexOf( '.' ); + if ( i < 0 ) return str; + return str.substring( 0, i ) + trimStart( str.substring( i + 1 ), '0123456789' ); + } +} + +export function createIsoDateStringWithMilliseconds ( + value: string | Date +): IsoDateStringWithMilliseconds { + const parsedValue = parseIsoDateStringWithMilliseconds( value ); + if ( !parsedValue ) throw new TypeError( `Value is not ISO data string: '${value}'` ); + return parsedValue; +} + +export function explainIsoDateStringWithMilliseconds (value: unknown): string { + return isIsoDateStringWithMilliseconds( value ) ? explainOk() : explainNot( `Expected '${value}' to be a valid IsoDateString` ); +} + +export function isIsoDateStringWithMillisecondsOrUndefined (value: unknown): value is IsoDateStringWithMilliseconds | undefined { + return isUndefined( value ) || isIsoDateStringWithMilliseconds( value ); +} + +export function explainIsoDateStringWithMillisecondsOrUndefined (value: unknown): string { + return isIsoDateStringWithMillisecondsOrUndefined( value ) ? explainOk() : explainNot( explainOr( [ 'IsoDateStringWithMilliseconds', 'undefined' ] ) ); +} + + +////////////////////// IsoDateString /////////////////////////// + + +export type IsoDateString = string; + +export function isIsoDateString (value: unknown): value is IsoDateString { + if ( !isString( value ) ) return false; + if ( !/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(\.\d+)?([+-][0-2]\d:[0-5]\d|Z)/.test( value ) ) return false; + const d = new Date( value ); + return d.toISOString() === value || _trimFractions(d.toISOString()) === value; +} + +export function parseIsoDateString ( + value: any, + trimFractions ?: boolean +): IsoDateString | undefined { + if ( isIsoDateString( value ) ) return value; + const date = parseValidDate( value ); + if ( !date ) return undefined; + const str = date.toISOString(); + return trimFractions !== true ? str : _trimFractions( str ); +} + +function _trimFractions (str : string) : string { + const i = str.lastIndexOf( '.' ); + if ( i < 0 ) return str; + return str.substring( 0, i ) + trimStart( str.substring( i + 1 ), '0123456789' ); +} + +export function createIsoDateString ( + value: string | Date +): IsoDateString { + const parsedValue = parseIsoDateString( value ); + if ( !parsedValue ) throw new TypeError( `Value is not ISO data string: '${value}'` ); + return parsedValue; +} + +export function explainIsoDateString (value: unknown): string { + return isIsoDateString( value ) ? explainOk() : explainNot( `Expected '${value}' to be a valid IsoDateString` ); +} + +export function isIsoDateStringOrUndefined (value: unknown): value is IsoDateString | undefined { + return isUndefined( value ) || isIsoDateString( value ); +} + +export function explainIsoDateStringOrUndefined (value: unknown): string { + return isIsoDateStringOrUndefined( value ) ? explainOk() : explainNot( explainOr( [ 'IsoDateString', 'undefined' ] ) ); +} diff --git a/types/Disposable.ts b/types/Disposable.ts new file mode 100644 index 0000000..d8e6064 --- /dev/null +++ b/types/Disposable.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Interface to be implemented by applications that want to release resources on + * destruction. + */ +export interface Disposable { + + /** + * Invoked by the containing application framework on destruction of the class. + */ + destroy() : void; + +} diff --git a/types/DisposeAware.ts b/types/DisposeAware.ts new file mode 100644 index 0000000..060c0cd --- /dev/null +++ b/types/DisposeAware.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Interface to be implemented by applications that want to be aware if + * the component has been destroyed. + */ +export interface DisposeAware { + + /** + * Returns true if the component has been destroyed + */ + isDestroyed () : boolean; + +} diff --git a/types/EcbDTO.ts b/types/EcbDTO.ts new file mode 100644 index 0000000..ef8edfc --- /dev/null +++ b/types/EcbDTO.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { CurrencyRates, explainCurrencyRates, isCurrencyRates } from "./CurrencyRates"; +import { Currency } from "./Currency"; +import { explain, explainProperty } from "./explain"; +import { explainRegularObject, isRegularObject } from "./RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "./OtherKeys"; + +/** + * ECB backend's DTO containing euro exchange rates + * @see https://github.com/heusalagroup/ecb.hg.fi + */ +export interface EcbDTO { + readonly [Currency.EUR] : CurrencyRates; +} + +export function createEcbDTO ( + eurRates: CurrencyRates +) : EcbDTO { + return { + [Currency.EUR]: eurRates + }; +} + +export function isEcbDTO (value: any) : value is EcbDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + Currency.EUR + ]) + && isCurrencyRates(value[Currency.EUR]) + ); +} + +export function explainEcbDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + Currency.EUR + ]), + explainProperty(Currency.EUR, explainCurrencyRates(value[Currency.EUR])) + ] + ); +} + +export function stringifyEcbDTO (value : EcbDTO) : string { + return `EcbDTO(${value})`; +} + +export function parseEcbDTO (value: any) : EcbDTO | undefined { + if (isEcbDTO(value)) return value; + return undefined; +} diff --git a/types/Enum.ts b/types/Enum.ts new file mode 100644 index 0000000..580eb1d --- /dev/null +++ b/types/Enum.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { + isRegularObjectOf, +} from "./RegularObject"; +import { + TestCallback, + TestCallbackNonStandard, +} from "./TestCallback"; +import { EXPLAIN_OK } from "./explain"; +import { join } from "../functions/join"; +import { + isString, + isStringOrNumber, +} from "./String"; +import { isNumber } from "./Number"; +import { indexOf } from "../functions/indexOf"; +import { map } from "../functions/map"; +import { trim } from "../functions/trim"; +import { EnumUtils } from "../EnumUtils"; + +export interface Enum { + readonly [key: string]: T; +} + +export type EnumType = Enum; + +/** + * Checks the given type is of the given enum type object. + * + * @template EnumType - The type of the enum's key. + * @param type - The enum object. + * @param isValue - The value to explain. + * @returns True if the type is an enum type object. + */ +export function isEnumType ( + type: unknown, + isValue: TestCallback = isStringOrNumber, +): type is Enum { + return isRegularObjectOf( + type, + isString, + isValue, + ); +} + +/** + * Explain the given value with respect to the given enum. + * + * @template EnumType - The type of the enum's value. + * @param name - The name of the enum. + * @param {Enum} type - The enum type object. + * @param type - The value to explain. + * @param isValue - A function that tests if a key is of the correct type. + * @returns {string} A string explaining the value with respect to the enum. + */ +export function explainEnumType ( + name: string, + type: unknown, + isValue: TestCallback = isStringOrNumber, +): string { + if ( !isEnumType(type, isValue) ) { + return `incorrect enum object for ${name}: ${JSON.stringify(type)}`; + } else { + return EXPLAIN_OK; + } +} + + +/** + * Checks the given value is part of the given enum. + * + * @template EnumType - The type of the enum's key. + * @param {Enum} type - The enum type object. + * @param {unknown} value - The value to explain. + * @returns {string} A string explaining the value with respect to the enum. + */ +export function isEnum ( + type: Enum, + value: unknown, +): value is T { + if ( isNumber(value) || isString(value) ) { + return indexOf(EnumUtils.getValues(type), value as T) >= 0; + } else { + return false; + } +} + +/** + * Explain the given value with respect to the given enum. + * + * @template EnumType - The type of the enum. + * @param {string} name - The name of the enum. + * @param {Enum} type - The enum. + * @param {TestCallbackNonStandard} isType - A function that tests if a value is of the correct type. + * @param {unknown} value - The value to explain. + * @returns {string} A string explaining the value with respect to the enum. + */ +export function explainEnum ( + name: string, + type: Enum, + isType: TestCallbackNonStandard, + value: unknown, +): string { + if ( !isType(value) ) { + return `incorrect enum value "${value}" for ${name}: Accepted values ${join(EnumUtils.getValues(type), ', ')}`; + } else { + return EXPLAIN_OK; + } +} + +/** + * + * @param type + * @param value + */ +export function stringifyEnum ( + type : Enum, + value : T, +) : string { + const enumValues = EnumUtils.getValues(type); + const enumKeys = EnumUtils.getKeys(type); + const index = indexOf(enumValues, value); + if (index < 0) throw new TypeError(`Unsupported enum value: ${value}`); + const key = enumKeys[index]; + return `${key}`; +} + +/** + * Parse enum value + * + * @param type The enum type + * @param value The expected value of an enum + * @param ignoreSpaces Ignore any space in the key when matching + * @param ignoreDashes Ignore any dash (`-`, or `_`) in the key when matching + */ +export function parseEnum ( + type : Enum, + value : any, + ignoreSpaces : boolean = false, + ignoreDashes : boolean = false, +) : T | undefined { + + if (value === undefined) return undefined; + + if ( isEnum(type, value) ) { + return value; + } + + if ( isString(value) ) { + + const normalize = (v: string) : string => { + v = trim(v).toUpperCase(); + if (ignoreSpaces) { + v = v.replace(/\s+/g, ""); + } + if (ignoreDashes) { + v = v.replace(/[_-]+/g, ""); + } + return v; + }; + + value = normalize(value); + + const normalizedKeys = map(EnumUtils.getKeys(type), (key) => normalize(key)); + const index = indexOf(normalizedKeys, value); + if ( index >= 0 ) { + const enumValues = EnumUtils.getValues(type); + return enumValues[index]; + } + } + + return undefined; +} diff --git a/types/ErrorCode.ts b/types/ErrorCode.ts new file mode 100644 index 0000000..3b6c86c --- /dev/null +++ b/types/ErrorCode.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "./Enum"; + +export enum ErrorCode { + + /** + * This error occurs when a socket timeout is exceeded during an HTTP + * request, such as when the server takes too long to respond. This can be + * caused by a slow network connection or a busy server. + */ + ETIMEDOUT = "ETIMEDOUT", + + /** + * This error occurs when an HTTP request is made to an invalid domain name + * or IP address. This can be caused by a typo in the domain name or by a + * temporary DNS issue. + */ + ENOTFOUND = "ENOTFOUND", + + /** + * This error occurs when an HTTP connection is reset by the server or + * unexpectedly closed by the client. This can be caused by various factors, + * such as network congestion or a server overload. + */ + ECONNRESET = "ECONNRESET", + + /** This error occurs when a connection is aborted by the client + * or the server. It can be caused by various factors, such as + * network congestion or a server overload. + */ + ECONNABORTED = "ECONNABORTED", + + /** This error occurs when the server cannot be reached due to a + * network issue, such as a firewall blocking the connection. + */ + EHOSTUNREACH = "EHOSTUNREACH", + + /** + * This error occurs when a socket operation times out, such as a read or + * write operation. + */ + ESOCKETTIMEDOUT = "ESOCKETTIMEDOUT", + + /** + * This error occurs when a DNS lookup fails due to temporary DNS server + * issues. + */ + EAI_AGAIN = "EAI_AGAIN", + + /** + * This error occurs when a broken pipe is detected during a write operation. + */ + EPIPE = "EPIPE", + + /** + * This error occurs when the server actively refuses to establish a connection. + * This can happen when the server is not running or is not accepting connections + * on the specified port or address. + */ + ECONNREFUSED = "ECONNREFUSED", + + /** + * This error occurs when the specified port is already in use by another process. + * This can happen when multiple instances of the same server are running on the same + * machine, or when a server is restarted before the previous instance has fully shut down. + */ + EADDRINUSE = "EADDRINUSE", + + /** + * This error occurs when the network is unreachable, either because the network interface + * is down or because there is no route to the specified host or network. + */ + ENETUNREACH = "ENETUNREACH", + + /** + * This error occurs when the connection is reset by the peer unexpectedly, typically + * due to a network issue or because the server terminated the connection. + */ + ENETRESET = "ENETRESET", + + /** + * This error occurs when a protocol error occurs during the connection, such as an + * invalid SSL certificate or a mismatched protocol version. + */ + EPROTO = "EPROTO", + + /** + * This error occurs when the specified host is down or otherwise unreachable. + * This can happen due to network issues or because the host is temporarily or permanently offline. + */ + EHOSTDOWN = "EHOSTDOWN", + + /** + * No such file or directory + */ + ENOENT = "ENOENT", + +} + +export function isErrorCode (value: unknown) : value is ErrorCode { + switch (value) { + case ErrorCode.ETIMEDOUT: + case ErrorCode.ENOTFOUND: + case ErrorCode.ECONNRESET: + case ErrorCode.ECONNABORTED: + case ErrorCode.EHOSTUNREACH: + case ErrorCode.ESOCKETTIMEDOUT: + case ErrorCode.EAI_AGAIN: + case ErrorCode.EPIPE: + case ErrorCode.ECONNREFUSED: + case ErrorCode.EADDRINUSE: + case ErrorCode.ENETUNREACH: + case ErrorCode.ENETRESET: + case ErrorCode.EPROTO: + case ErrorCode.EHOSTDOWN: + case ErrorCode.ENOENT: + return true; + default: + return false; + } +} + +export function explainErrorCode (value : unknown) : string { + return explainEnum("ErrorCode", ErrorCode, isErrorCode, value); +} + +export function stringifyErrorCode (value : ErrorCode) : string { + switch (value) { + case ErrorCode.ETIMEDOUT : return 'ETIMEDOUT'; + case ErrorCode.ENOTFOUND : return 'ENOTFOUND'; + case ErrorCode.ECONNRESET : return 'ECONNRESET'; + case ErrorCode.ECONNABORTED : return 'ECONNABORTED'; + case ErrorCode.EHOSTUNREACH : return 'EHOSTUNREACH'; + case ErrorCode.ESOCKETTIMEDOUT : return 'ESOCKETTIMEDOUT'; + case ErrorCode.EAI_AGAIN : return 'EAI_AGAIN'; + case ErrorCode.EPIPE : return 'EPIPE'; + case ErrorCode.ECONNREFUSED : return 'ECONNREFUSED'; + case ErrorCode.EADDRINUSE : return 'EADDRINUSE'; + case ErrorCode.ENETUNREACH : return 'ENETUNREACH'; + case ErrorCode.ENETRESET : return 'ENETRESET'; + case ErrorCode.EPROTO : return 'EPROTO'; + case ErrorCode.EHOSTDOWN : return 'EHOSTDOWN'; + case ErrorCode.ENOENT : return 'ENOENT'; + } + throw new TypeError(`Unsupported ErrorCode value: ${value}`) +} + +export function parseErrorCode (value: unknown) : ErrorCode | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'ETIMEDOUT' : return ErrorCode.ETIMEDOUT; + case 'ENOTFOUND' : return ErrorCode.ENOTFOUND; + case 'ECONNRESET' : return ErrorCode.ECONNRESET; + case 'ECONNABORTED' : return ErrorCode.ECONNABORTED; + case 'EHOSTUNREACH' : return ErrorCode.EHOSTUNREACH; + case 'ESOCKETTIMEDOUT' : return ErrorCode.ESOCKETTIMEDOUT; + case 'EAI_AGAIN' : return ErrorCode.EAI_AGAIN; + case 'EPIPE' : return ErrorCode.EPIPE; + case 'ECONNREFUSED' : return ErrorCode.ECONNREFUSED; + case 'EADDRINUSE' : return ErrorCode.EADDRINUSE; + case 'ENETUNREACH' : return ErrorCode.ENETUNREACH; + case 'ENETRESET' : return ErrorCode.ENETRESET; + case 'EPROTO' : return ErrorCode.EPROTO; + case 'EHOSTDOWN' : return ErrorCode.EHOSTDOWN; + case 'ENOENT' : return ErrorCode.ENOENT; + default : return undefined; + } +} diff --git a/types/ErrorDTO.ts b/types/ErrorDTO.ts new file mode 100644 index 0000000..bc618b7 --- /dev/null +++ b/types/ErrorDTO.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2022. . All rights reserved. +// + +import { explain, explainProperty } from "./explain"; +import { explainString, isString } from "./String"; +import { explainNumberOrUndefined, isNumberOrUndefined } from "./Number"; +import { explainRegularObject, isRegularObject } from "./RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "./OtherKeys"; + +export interface ErrorDTO { + readonly error : string; + readonly code ?: number; +} + +export function createErrorDTO ( + error : string, + code ?: number +) : ErrorDTO { + return { + error, + code + }; +} + +export function isErrorDTO (value: any): value is ErrorDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'error', + 'code' + ]) + && isString(value?.error) + && isNumberOrUndefined(value?.code) + ); +} + +export function explainErrorDTO (value : any): string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'error', + 'code' + ]), + explainProperty("error", explainString(value?.error)), + explainProperty("code", explainNumberOrUndefined(value?.code)) + ] + ); +} + +export function stringifyErrorDTO (value: ErrorDTO): string { + return `ErrorDTO(${value})`; +} + +export function parseErrorDTO (value: any) : ErrorDTO | undefined { + if ( isErrorDTO(value) ) return value; + return undefined; +} diff --git a/types/ExplainCallback.ts b/types/ExplainCallback.ts new file mode 100644 index 0000000..30bcdaf --- /dev/null +++ b/types/ExplainCallback.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +export interface ExplainCallback { + (value: any): string; +} diff --git a/types/Function.ts b/types/Function.ts new file mode 100644 index 0000000..dfcb296 --- /dev/null +++ b/types/Function.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { default as _isFunction } from "lodash/isFunction"; +import trim from "lodash/trim"; +import startsWith from "lodash/startsWith"; +import { explainOk } from "./explain"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isFunction (value: unknown): value is Function { + return _isFunction(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainFunction (value: unknown): string { + return isFunction(value) ? explainOk() : 'not function'; +} + +/** + * + * @param f + * @__PURE__ + * @nosideeffects + */ +export function parseFunctionSignature (f: any): string | undefined { + + if ( !isFunction(f) ) return undefined; + + let fString = trim(`${f}`); + + if ( startsWith(fString, 'function ') ) { + fString = trim(fString.substr('function '.length)); + } + + const index = fString.indexOf('{'); + if ( index >= 0 ) { + return trim(fString.substr(0, index)); + } + return trim(fString); + +} diff --git a/types/HealthCheckDTO.ts b/types/HealthCheckDTO.ts new file mode 100644 index 0000000..2979f12 --- /dev/null +++ b/types/HealthCheckDTO.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2022. . All rights reserved. +// + +import { isBoolean } from "./Boolean"; +import { isRegularObject } from "./RegularObject"; +import { hasNoOtherKeys } from "./OtherKeys"; + +export interface HealthCheckDTO { + readonly ready ?: boolean; +} + +export function createHealthCheckDTO ( + ready : boolean +) : HealthCheckDTO { + return {ready}; +} + +export function isHealthCheckDTO (value: any): value is HealthCheckDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'ready' + ]) + && isBoolean(value?.ready) + ); +} + +export function stringifyHealthCheckDTO (value: HealthCheckDTO): string { + return `HealthCheckDTO(${value?.ready})`; +} + +export function parseHealthCheckDTO (value: any) : HealthCheckDTO | undefined { + if ( isHealthCheckDTO(value) ) return value; + if ( isBoolean(value) ) return createHealthCheckDTO(value); + return undefined; +} diff --git a/types/I18NextResource.ts b/types/I18NextResource.ts new file mode 100644 index 0000000..d8525bc --- /dev/null +++ b/types/I18NextResource.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { ReadonlyJsonObject } from "../Json"; + +export interface I18NextResourceProperty { + readonly translation: ReadonlyJsonObject; + readonly [key: string]: ReadonlyJsonObject; +} + +/** + * The keyword is Language + */ +export interface I18NextResource { + readonly [key: string]: I18NextResourceProperty; +} diff --git a/types/InternetCalendarLine.ts b/types/InternetCalendarLine.ts new file mode 100644 index 0000000..045bf94 --- /dev/null +++ b/types/InternetCalendarLine.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { InternetCalendarParam, isInternetCalendarParam } from "./InternetCalendarParam"; +import { isString } from "./String"; +import { isRegularObject } from "./RegularObject"; +import { hasNoOtherKeys } from "./OtherKeys"; +import { isArrayOf } from "./Array"; + +export interface InternetCalendarLine { + readonly name : string; + readonly params : InternetCalendarParam[]; + readonly value : string; +} + +export function createInternetCalendarLine ( + name: string, + value : string, + params : InternetCalendarParam[] = [] +): InternetCalendarLine { + return { + name, + value, + params + }; +} + +export function isInternetCalendarLine (value: any): value is InternetCalendarLine { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name', + 'params', + 'value' + ]) + && isString(value?.name) + && isArrayOf(value?.params, isInternetCalendarParam) + && isString(value?.value) + ); +} + +export function stringifyInternetCalendarLine (value: InternetCalendarLine): string { + return `InternetCalendarLine(${value})`; +} + +export function parseInternetCalendarLine (value: any): InternetCalendarLine | undefined { + if ( isInternetCalendarLine(value) ) return value; + return undefined; +} diff --git a/types/InternetCalendarParam.ts b/types/InternetCalendarParam.ts new file mode 100644 index 0000000..c3ca90a --- /dev/null +++ b/types/InternetCalendarParam.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { isString } from "./String"; +import { isRegularObject } from "./RegularObject"; +import { hasNoOtherKeys } from "./OtherKeys"; + +export interface InternetCalendarParam { + readonly name : string; + readonly value : string; +} + +export function createInternetCalendarParam ( + name : string, + value : string +): InternetCalendarParam { + return { + name, + value + }; +} + +export function isInternetCalendarParam (value: any): value is InternetCalendarParam { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'name', + 'value' + ]) + && isString(value?.name) + && isString(value?.value) + ); +} + +export function stringifyInternetCalendarParam (value: InternetCalendarParam): string { + return `InternetCalendarParam(${value})`; +} + +export function parseInternetCalendarParam (value: any): InternetCalendarParam | undefined { + if ( isInternetCalendarParam(value) ) return value; + return undefined; +} diff --git a/types/Language.test.ts b/types/Language.test.ts new file mode 100644 index 0000000..b2729ff --- /dev/null +++ b/types/Language.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isLanguage, Language, parseLanguage, stringifyLanguage } from "./Language"; + +describe('Language', () => { + + describe('isLanguage', () => { + it('should return true for valid languages', () => { + expect(isLanguage('fi')).toBe(true); + expect(isLanguage('en')).toBe(true); + }); + + it('should return false for invalid languages', () => { + expect(isLanguage('es')).toBe(false); + expect(isLanguage('de')).toBe(false); + expect(isLanguage(123)).toBe(false); + expect(isLanguage(null)).toBe(false); + }); + }); + + describe('stringifyLanguage', () => { + it('should return string representation for valid languages', () => { + expect(stringifyLanguage(Language.FINNISH)).toBe('fi'); + expect(stringifyLanguage(Language.ENGLISH)).toBe('en'); + }); + + it('should throw error for invalid languages', () => { + expect(() => stringifyLanguage('es' as any)).toThrow(TypeError); + }); + }); + + describe('parseLanguage', () => { + it('should return Language for valid inputs', () => { + expect(parseLanguage('fi')).toBe(Language.FINNISH); + expect(parseLanguage('en')).toBe(Language.ENGLISH); + expect(parseLanguage('FINNISH')).toBe(Language.FINNISH); + expect(parseLanguage('ENGLISH')).toBe(Language.ENGLISH); + }); + + it('should return undefined for invalid inputs', () => { + expect(parseLanguage('es')).toBeUndefined(); + expect(parseLanguage('de')).toBeUndefined(); + expect(parseLanguage(123)).toBeUndefined(); + expect(parseLanguage(null)).toBeUndefined(); + }); + }); + +}); diff --git a/types/Language.ts b/types/Language.ts new file mode 100644 index 0000000..d106a81 --- /dev/null +++ b/types/Language.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2022-2023. Heusala Group . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +export enum Language { + FINNISH = "fi", + ENGLISH = "en" +} + +export function isLanguage (value: any): value is Language { + switch (value) { + case Language.FINNISH: + case Language.ENGLISH: + return true; + + default: + return false; + } +} + +export function stringifyLanguage (value: Language): string { + switch (value) { + case Language.FINNISH : return 'fi'; + case Language.ENGLISH : return 'en'; + } + throw new TypeError(`Unsupported Language value: ${value}`); +} + +export function parseLanguage (value: any): Language | undefined { + switch (`${value}`.toUpperCase()) { + + case 'FI' : + case 'FINNISH' : + return Language.FINNISH; + + case 'EN' : + case 'ENGLISH' : + return Language.ENGLISH; + + default : + return undefined; + } +} diff --git a/types/LogLevel.ts b/types/LogLevel.ts new file mode 100644 index 0000000..28e92e9 --- /dev/null +++ b/types/LogLevel.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2022-2023. Heusala Group Oy . All rights reserved. +// Copyright (c) 2021-2022. Sendanor . All rights reserved. + +/** + * An enumeration of possible log levels. + * + * @enum {number} + */ +export enum LogLevel { + + /** + * The debug log level. + * + * Messages logged at this level are intended for developers and are used + * for debugging purposes. These messages can contain detailed information + * about program state and execution flow. + * + * While it is important to ensure that debug log statements are removed + * from production code to increase performance and security, this can be + * a delicate task. Our build system attempts to eliminate static code + * blocks that are never used, but dynamic variables or conditions may + * still prevent this from happening. However, we can guarantee that debug + * log statements will be removed from production code if it is a specific + * requirement for a customer release, and the customer has paid for this + * service. + * + * @type {number} + */ + DEBUG, + + /** + * The info log level. + * + * Messages logged at this level are informational in nature and provide + * general information about program state and execution flow. These + * messages are intended to be read by developers and operations personnel. + * + * @type {number} + */ + INFO, + + /** + * The warning log level. + * + * Messages logged at this level indicate a potential problem or issue that + * should be addressed. These messages are intended to be read by developers + * and operations personnel. + * + * @type {number} + */ + WARN, + + /** + * The error log level. + * + * Messages logged at this level indicate an error or unexpected condition + * that has occurred. These messages are intended to be read by developers + * and operations personnel and may indicate a problem that requires + * immediate attention. + * + * @type {number} + */ + ERROR, + + /** + * The none log level. + * + * This level disables all logging, regardless of the logger's configuration. + * + * @type {number} + */ + NONE + +} + +export function isLogLevel (value: any): value is LogLevel { + switch (value) { + + case LogLevel.DEBUG: + case LogLevel.INFO: + case LogLevel.WARN: + case LogLevel.ERROR: + case LogLevel.NONE: + return true; + + default: + return false; + + } +} + +export function stringifyLogLevel (value : LogLevel) : string { + switch (value) { + case LogLevel.DEBUG : return 'DEBUG'; + case LogLevel.INFO : return 'INFO'; + case LogLevel.WARN : return 'WARN'; + case LogLevel.ERROR : return 'ERROR'; + case LogLevel.NONE : return 'NONE'; + default : return `Unknown:${value}`; + } +} + +export function parseLogLevel (value: any): LogLevel | undefined { + + if ( !value ) return undefined; + if ( isLogLevel(value) ) return value; + + value = `${value}`.toUpperCase(); + + switch (value) { + + case 'ALL': + case 'DEBUG': + return LogLevel.DEBUG; + + case 'INFO': + return LogLevel.INFO; + + case 'WARN' : + case 'WARNING' : + return LogLevel.WARN; + + case 'ERR' : + case 'ERROR' : + return LogLevel.ERROR; + + case 'FALSE' : + case 'OFF' : + case 'QUIET' : + case 'SILENT' : + case 'NONE' : + return LogLevel.NONE; + + default: + return undefined; + } + +} diff --git a/types/LogWriter.ts b/types/LogWriter.ts new file mode 100644 index 0000000..afb0ea9 --- /dev/null +++ b/types/LogWriter.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2023. Heusala Group . All rights reserved. + +export interface LogWriter { + + /** + * Writes string messages to log output + * + * @param input + */ + write (input: string) : void; + +} diff --git a/types/Logger.ts b/types/Logger.ts new file mode 100644 index 0000000..17d0cee --- /dev/null +++ b/types/Logger.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2022. Heusala Group . All rights reserved. +// Copyright (c) 2021. Sendanor . All rights reserved. + +import { LogLevel } from "./LogLevel"; + +/** + * A logger interface that defines methods for logging messages at different + * levels of severity. + */ +export interface Logger { + + /** + * Gets the log level of the logger. + * + * @returns {LogLevel} - The log level of the logger. Implementations should + * default to DEBUG. + */ + getLogLevel () : LogLevel; + + /** + * Sets the log level of the logger. + * + * @param {LogLevel | undefined} level - The new log level. + * @returns {this} - The logger instance. + */ + setLogLevel (level : LogLevel | undefined) : this; + + /** + * Logs a debug message. + * + * @param args - The arguments to log. + * @see {@link LogLevel.DEBUG} + */ + debug (...args: readonly any[]) : void; + + /** + * Logs an info message. + * + * @param args - The arguments to log. + * @see {@link LogLevel.INFO} + */ + info (...args: readonly any[]) : void; + + /** + * Logs a warning message. + * + * @param args - The arguments to log. + * @see @{@link LogLevel.WARN} + */ + warn (...args: readonly any[]) : void; + + /** + * Logs an error message. + * + * @param args - The arguments to log. + * @see {@link LogLevel.ERROR} + */ + error (...args: readonly any[]) : void; + +} diff --git a/types/Method.ts b/types/Method.ts new file mode 100644 index 0000000..f47140c --- /dev/null +++ b/types/Method.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "./Enum"; + +export enum Method { + GET = "GET", + POST = "POST", + DELETE = "DELETE" +} + +export function isMethod (value: unknown) : value is Method { + switch (value) { + case Method.GET: + case Method.POST: + case Method.DELETE: + return true; + default: + return false; + } +} + +export function explainMethod (value : unknown) : string { + return explainEnum("Method", Method, isMethod, value); +} + +export function stringifyMethod (value : Method) : string { + switch (value) { + case Method.GET : return 'GET'; + case Method.POST : return 'POST'; + case Method.DELETE : return 'DELETE'; + } + throw new TypeError(`Unsupported Method value: ${value}`) +} + +export function parseMethod (value: unknown) : Method | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'GET' : return Method.GET; + case 'POST' : return Method.POST; + case 'DELETE' : return Method.DELETE; + default : return undefined; + } +} diff --git a/types/Null.ts b/types/Null.ts new file mode 100644 index 0000000..b7da98a --- /dev/null +++ b/types/Null.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk } from "./explain"; +import { default as _isNull } from 'lodash/isNull'; +import { isUndefined } from "./undefined"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNull (value: unknown): value is null { + return _isNull(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNull (value: any): string { + return isNull(value) ? explainOk() : explainNot('null'); +} + + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNullOrUndefined (value: unknown): value is null | undefined { + return _isNull(value) || isUndefined(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNullOrUndefined (value: any): string { + return isNullOrUndefined(value) ? explainOk() : explainNot('null or undefined'); +} diff --git a/types/Number.ts b/types/Number.ts new file mode 100644 index 0000000..251eb09 --- /dev/null +++ b/types/Number.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "./explain"; +import { trim } from "../functions/trim"; +import { default as _isSafeInteger } from "lodash/isSafeInteger"; +import { isUndefined } from "./undefined"; +import { isNull } from "./Null"; + +// These are required to overcome circular references +import _isString from "lodash/isString"; +import _isInteger from "lodash/isInteger"; +import _isNumber from "lodash/isNumber"; +import _toSafeInteger from "lodash/toSafeInteger"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function toSafeInteger (value: unknown): number { + return _toSafeInteger(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumber (value: unknown): value is number { + return _isNumber(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumber (value: any): string { + return isNumber(value) ? explainOk() : explainNot('number'); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumberOrUndefined (value: unknown): value is number | undefined { + return isUndefined(value) || isNumber(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumberOrUndefined (value: any): string { + return isNumberOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'number', 'undefined' ])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumberOrNullOrUndefined (value: unknown): value is number | null | undefined { + return isUndefined(value) || isNull(value) || isNumber(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumberOrNullOrUndefined (value: any): string { + return isNumberOrNullOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'number', 'null', 'undefined' ])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumberOrNull (value: unknown): value is number | null { + return isNull(value) || isNumber(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumberOrNull (value: any): string { + return isNumberOrNull(value) ? explainOk() : explainNot(explainOr([ 'number', 'null' ])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function parseInteger (value: any): number | undefined { + if ( value === undefined ) return undefined; + if ( value === null ) return undefined; + if ( isSafeInteger(value) ) return value; + if ( _isString(value) ) { + value = trim(value); + if ( value.length === 0 ) return undefined; + } + const parsedValue = toSafeInteger(value); + return isSafeInteger(parsedValue) && parsedValue.toFixed(0) === value ? parsedValue : undefined; +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isInteger (value: unknown): value is number { + return _isInteger(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainInteger (value: any): string { + return isInteger(value) ? explainOk() : explainNot('integer'); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isIntegerOrUndefined (value: unknown): value is number | undefined { + return isUndefined(value) || _isInteger(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainIntegerOrUndefined (value: any): string { + return isInteger(value) ? explainOk() : explainNot(explainOr([ 'integer', 'undefined' ])); +} + +/** + * + * @param value + * @param rangeStart + * @param rangeEnd + * @__PURE__ + * @nosideeffects + */ +export function isIntegerOf ( + value: any, + rangeStart: number | undefined = undefined, + rangeEnd: number | undefined = undefined +): value is number { + + if ( !_isInteger(value) ) return false; + + if ( rangeStart !== undefined && value < rangeStart ) { + return false; + } + + if ( rangeEnd !== undefined && value > rangeEnd ) { + return false; + } + + return true; + +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isSafeInteger (value: unknown): value is number { + return _isSafeInteger(value); +} + +/** + * + * @param value + * @param rangeStart + * @param rangeEnd + * @__PURE__ + * @nosideeffects + */ +export function isSafeIntegerOf ( + value: any, + rangeStart: number | undefined = undefined, + rangeEnd: number | undefined = undefined +): value is number { + + if ( !_isSafeInteger(value) ) return false; + + if ( rangeStart !== undefined && value < rangeStart ) { + return false; + } + + if ( rangeEnd !== undefined && value > rangeEnd ) { + return false; + } + + return true; + +} diff --git a/types/NumberArray.ts b/types/NumberArray.ts new file mode 100644 index 0000000..be802aa --- /dev/null +++ b/types/NumberArray.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isArray } from "./Array"; +import { isNumber } from "./Number"; +import { every } from "../functions/every"; +import { explainNot, explainOk, explainOr } from "./explain"; +import { isUndefined } from "./undefined"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumberArray (value: unknown): value is number[] { + return ( + !!value + && isArray(value) + && every(value, isNumber) + ); +} + + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumberArray (value: any): string { + return isNumberArray(value) ? explainOk() : explainNot('number[]'); +} + + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumberArrayOrUndefined (value: unknown): value is number[] | undefined { + return isUndefined(value) || isNumberArray(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumberArrayOrUndefined (value: any): string { + return isNumberArrayOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'number[]', 'undefined' ])); + +} diff --git a/types/NumberOrStringOrBooleanOrUndefined.ts b/types/NumberOrStringOrBooleanOrUndefined.ts new file mode 100644 index 0000000..f38b93a --- /dev/null +++ b/types/NumberOrStringOrBooleanOrUndefined.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isUndefined } from "./undefined"; +import { isNumber } from "./Number"; +import { isString } from "./String"; +import { isBoolean } from "./Boolean"; +import { explainNot, explainOk, explainOr } from "./explain"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumberOrStringOrBooleanOrUndefined (value: unknown): value is number | undefined { + return isUndefined(value) || isNumber(value) || isString(value) || isBoolean(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumberOrStringOrBooleanOrUndefined (value: any): string { + return isNumberOrStringOrBooleanOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'number', 'string', 'boolean', 'undefined' ])); +} diff --git a/types/NumberPair.ts b/types/NumberPair.ts new file mode 100644 index 0000000..a02a0c4 --- /dev/null +++ b/types/NumberPair.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isArray } from "./Array"; +import { isNumber } from "./Number"; +import { every } from "../functions/every"; +import { explainNot, explainOk, explainOr } from "./explain"; +import { isUndefined } from "./undefined"; + +export type NumberPair = readonly [number, number]; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumberPair (value: unknown): value is NumberPair { + return ( + !!value + && isArray(value) + && value.length === 2 + && every(value, isNumber) + ); +} + + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumberPair (value: any): string { + return isNumberPair(value) ? explainOk() : explainNot('[number, number]'); +} + + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNumberPairOrUndefined (value: unknown): value is [number, number] | undefined { + return isUndefined(value) || isNumberPair(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNumberPairOrUndefined (value: any): string { + return isNumberPairOrUndefined(value) ? explainOk() : explainNot(explainOr([ '[number, number]', 'undefined' ])); + +} diff --git a/types/Object.ts b/types/Object.ts new file mode 100644 index 0000000..4893d04 --- /dev/null +++ b/types/Object.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { explainNot, explainOk, explainOr } from "./explain"; +import { TestCallback, TestCallbackOf } from "./TestCallback"; +import { default as _isObject } from "lodash/isObject"; +import { isUndefined } from "./undefined"; +import { everyKey, explainEveryKey } from "../functions/everyKey"; +import { everyProperty, explainEveryProperty } from "../functions/everyProperty"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isObject (value: any): value is { [P in string]: any } { + return _isObject(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainObject (value: any): string { + return isObject(value) ? explainOk() : 'not object'; +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isObjectOrUndefined (value: any): value is { [P in string]: any } | undefined { + return _isObject(value) || isUndefined(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainObjectOrUndefined (value: any): string { + return isObjectOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'object', 'undefined' ])); +} + +/** + * + * @param value + * @param isKey + * @param isItem + * @__PURE__ + * @nosideeffects + */ +export function isObjectOf ( + value: any, + isKey: TestCallback | undefined = undefined, + isItem: TestCallback | undefined = undefined +): value is { [P in K]: T } { + + if ( isKey === undefined ) { + return _isObject(value); + } + + if ( isItem === undefined ) { + return everyKey(value, isKey); + } + + return everyProperty(value, isKey, isItem); + +} + +/** + * + * @param value + * @param isKey + * @param isItem + * @param keyTypeName + * @param itemTypeName + * @__PURE__ + * @nosideeffects + */ +export function explainObjectOf ( + value: any, + isKey: TestCallbackOf | undefined = undefined, + isItem: TestCallbackOf | undefined = undefined, + keyTypeName: string, +): string { + if ( isObjectOf(value, isKey, isItem) ) { + return explainOk(); + } + if ( isKey === undefined ) { + return explainObject(value); + } + if ( isItem === undefined ) { + return explainEveryKey(value, isKey, keyTypeName); + } + return explainEveryProperty(value, isKey, isItem); +} diff --git a/types/OtherKeys.ts b/types/OtherKeys.ts new file mode 100644 index 0000000..ab8d504 --- /dev/null +++ b/types/OtherKeys.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isObject } from "./Object"; +import { IS_DEVELOPMENT } from "../constants/environment"; +import { default as _filter } from "lodash/filter"; +import { explainOk } from "./explain"; +import { keys } from "../functions/keys"; + +/** + * + * @param obj + * @param acceptedKeys + * @__PURE__ + * @nosideeffects + */ +export function getOtherKeys (obj: any, acceptedKeys: readonly string[]): readonly string[] { + return _filter(keys(obj), (key: string): boolean => !acceptedKeys.includes(key)); +} + +/** + * + * @param obj + * @param acceptedKeys + * @__PURE__ + * @nosideeffects + */ +export function hasNoOtherKeys (obj: any, acceptedKeys: readonly string[]): boolean { + return isObject(obj) && getOtherKeys(obj, acceptedKeys).length === 0; +} + +/** + * + * @param value + * @param array + * @__PURE__ + * @nosideeffects + */ +export function hasNoOtherKeysInDevelopment (value: any, array: readonly string[]): boolean { + return ( + IS_DEVELOPMENT ? hasNoOtherKeys(value, array) : true + ); +} + +export function explainNoOtherKeys (value: any, array: readonly string[]): string { + if ( !hasNoOtherKeys(value, array) ) { + return `Value had extra properties: ${ + _filter( + keys(value), + (item: string): boolean => !array.includes(item) + ) + }`; + } else { + return explainOk(); + } +} + +export function explainNoOtherKeysInDevelopment (value: any, array: readonly string[]): string { + if ( !hasNoOtherKeysInDevelopment(value, array) ) { + return `Value had extra properties: ${ + _filter( + keys(value), + (item: string): boolean => !array.includes(item) + ) + }`; + } else { + return explainOk(); + } +} diff --git a/types/ParserCallback.ts b/types/ParserCallback.ts new file mode 100644 index 0000000..ceb5b59 --- /dev/null +++ b/types/ParserCallback.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +export interface ParserCallback { + (value: any): T | undefined; +} diff --git a/types/PermissionListDTO.ts b/types/PermissionListDTO.ts new file mode 100644 index 0000000..5dbcf57 --- /dev/null +++ b/types/PermissionListDTO.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainPermissionList, isPermissionList, PermissionList } from "../PermissionUtils"; +import { TestCallback } from "./TestCallback"; +import { ExplainCallback } from "./ExplainCallback"; +import { explain, explainProperty } from "./explain"; +import { explainRegularObject, isRegularObject } from "./RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "./OtherKeys"; + +export interface PermissionListDTO { + readonly permissions : PermissionList; +} + +export function createPermissionListDTO ( + permissions : PermissionList +) : PermissionListDTO { + return { + permissions + }; +} + +export function isPermissionListDTO ( + value: any, + isT: TestCallback +) : value is PermissionListDTO { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'permissions' + ]) + && isPermissionList(value?.permissions, isT) + ); +} + +export function explainPermissionListDTO ( + value : any, + permissionName : string, + permissionExplain : ExplainCallback, + isPermission : TestCallback | undefined = undefined +) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'permissions' + ]), + explainProperty( + "permissions", + explainPermissionList( + permissionName, + permissionExplain, + value?.permissions, + isPermission + ) + ) + ] + ); +} diff --git a/types/PermissionManager.ts b/types/PermissionManager.ts new file mode 100644 index 0000000..9411ace --- /dev/null +++ b/types/PermissionManager.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { PermissionList, PermissionObject } from "../PermissionUtils"; + +export interface PermissionManager { + + /** + * Fetch permissions against a target entity + * + * @param entityId The entity who is performing the action + * @param targetId The entity which is the target of the action + * @returns Promise Promise of permissions this entity has + */ + getEntityPermissionList ( + entityId : string, + targetId ?: string + ) : Promise>; + + /** + * Check a list of permissions + * + * @param checkPermissions List of permissions which the entity must have + * @param entityId The entity who is performing the action + * @param targetId The entity which is the target of the action + * @returns PermissionObject Object which contains boolean test results for each permission + */ + checkEntityPermission ( + checkPermissions: PermissionList, + entityId : string, + targetId ?: string + ) : Promise; + +} diff --git a/types/Promise.ts b/types/Promise.ts new file mode 100644 index 0000000..b7aa0b7 --- /dev/null +++ b/types/Promise.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { default as _isObject } from "lodash/isObject"; +import { default as _isFunction } from "lodash/isFunction"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isPromise (value: any): value is Promise { + // @ts-ignore + return _isObject(value) && _isFunction(value?.then) && _isFunction(value?.catch); +} diff --git a/types/RegularObject.ts b/types/RegularObject.ts new file mode 100644 index 0000000..ac936b6 --- /dev/null +++ b/types/RegularObject.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { TestCallback, TestCallbackNonStandardOf } from "./TestCallback"; +import { isString } from "./String"; +import { default as _isObject } from "lodash/isObject"; +import { isFunction } from "./Function"; +import { isArray } from "./Array"; +import { ExplainCallback } from "./ExplainCallback"; +import { explainNot, explainOk, explainOr } from "./explain"; +import { assertEveryProperty, everyProperty } from "../functions/everyProperty"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isRegularObject ( + value: any +): value is { [P in string]: any } { + + if ( !_isObject(value) ) return false; + if ( value instanceof Date ) return false; + if ( isFunction(value) ) return false; + if ( isArray(value) ) return false; + + return everyProperty(value, isString, undefined); + +} + +export function explainRegularObject (value: any) { + return isRegularObject(value) ? explainOk() : 'not regular object'; +} + +/** + * + * @param value + * @param isKey + * @param isItem + * @__PURE__ + * @nosideeffects + */ +export function isRegularObjectOf ( + value: any, + isKey: TestCallback = isString, + isItem: TestCallback | undefined = undefined +): value is { [P in K]: T } { + + if ( !_isObject(value) ) return false; + if ( value instanceof Date ) return false; + if ( isFunction(value) ) return false; + if ( isArray(value) ) return false; + + return everyProperty(value, isKey, isItem); + +} + +/** + * + * @param value + * @param isKey + * @param isItem + * @param explainKey + * @param explainValue + * @__PURE__ + * @nosideeffects + */ +export function assertRegularObjectOf ( + value: any, + isKey: TestCallbackNonStandardOf | undefined = undefined, + isItem: TestCallbackNonStandardOf | undefined = undefined, + explainKey: ExplainCallback | undefined = undefined, + explainValue: ExplainCallback | undefined = undefined +): void { + + const isKeyTest: TestCallbackNonStandardOf = isKey === undefined ? isString as TestCallbackNonStandardOf : isKey; + + if ( !_isObject(value) ) { + throw new TypeError(`value was not object`); + } + + if ( value instanceof Date ) { + throw new TypeError(`value was Date`); + } + + if ( isFunction(value) ) { + throw new TypeError(`value was Function`); + } + + if ( isArray(value) ) { + throw new TypeError(`value was array`); + } + + assertEveryProperty(value, isKeyTest, isItem, explainKey, explainValue); + +} + +/** + * + * @param value + * @param isKey + * @param isItem + * @param explainKey + * @param explainValue + * @__PURE__ + * @nosideeffects + */ +export function explainRegularObjectOf ( + value: any, + isKey: TestCallbackNonStandardOf | undefined = undefined, + isItem: TestCallbackNonStandardOf | undefined = undefined, + explainKey: ExplainCallback | undefined = undefined, + explainValue: ExplainCallback | undefined = undefined +) { + try { + assertRegularObjectOf(value, isKey, isItem, explainKey, explainValue); + return explainOk(); + } catch (err: any) { + return err?.message ?? `${err}`; + } +} + +/** + * + * @param value + * @param isKey + * @param isItem + * @__PURE__ + * @nosideeffects + */ +export function isRegularObjectOrUndefinedOf ( + value: any, + isKey: TestCallback = isString, + isItem: TestCallback | undefined = undefined +): value is ({ [P in K]: T } | undefined) { + + if ( value === undefined ) return true; + + return isRegularObjectOf(value, isKey, isItem); + +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isRegularObjectOrUndefined (value: unknown): value is ({ [P in string]: any } | undefined) { + + if ( value === undefined ) return true; + + return isRegularObjectOf(value, isString, undefined); + +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainRegularObjectOrUndefined (value: any): string { + return isRegularObjectOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'regular object', 'undefined' ])); +} diff --git a/types/RequestSigner.ts b/types/RequestSigner.ts new file mode 100644 index 0000000..f5885b0 --- /dev/null +++ b/types/RequestSigner.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * Generates cryptographic signature of the request. Some APIs require this + * type of signature for extra security to prevent SSL attacks. + */ +export interface RequestSigner { + + /** + * Generates cryptographic signature of the request. Some APIs require this + * type of signature for extra security to prevent SSL attacks. + * + * @param bodyString The request body + */ + ( + bodyString : string + ) : string; + +} diff --git a/types/SameSite.ts b/types/SameSite.ts new file mode 100644 index 0000000..c863279 --- /dev/null +++ b/types/SameSite.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "./Enum"; +import { isUndefined } from "./undefined"; +import { explainNot, explainOk, explainOr } from "./explain"; + +export enum SameSite { + LAX = "LAX", + NONE = "NONE", + STRICT = "STRICT" +} + +export function isSameSite (value: unknown) : value is SameSite { + switch (value) { + case SameSite.LAX: + case SameSite.NONE: + case SameSite.STRICT: + return true; + default: + return false; + } +} + +export function isSameSiteOrUndefined (value: unknown) : value is SameSite | undefined { + return isSameSite(value) || isUndefined(value); +} + +export function explainSameSite (value : unknown) : string { + return explainEnum("SameSite", SameSite, isSameSite, value); +} + +export function explainSameSiteOrUndefined (value: unknown) : string { + return isSameSite(value) || isUndefined(value) ? explainOk() : explainNot(explainOr(['SameSite', 'undefined'])); +} + +export function stringifySameSite (value : SameSite) : string { + switch (value) { + case SameSite.LAX : return 'LAX'; + case SameSite.NONE : return 'NONE'; + case SameSite.STRICT : return 'STRICT'; + } + throw new TypeError(`Unsupported SameSite value: ${value}`) +} + +export function parseSameSite (value: unknown) : SameSite | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'LAX' : return SameSite.LAX; + case 'NONE' : return SameSite.NONE; + case 'STRICT' : return SameSite.STRICT; + default : return undefined; + } +} diff --git a/types/SiType.test.ts b/types/SiType.test.ts new file mode 100644 index 0000000..737b5d2 --- /dev/null +++ b/types/SiType.test.ts @@ -0,0 +1,179 @@ +import { SiType, isSiType, stringifySiType, parseSiType } from "./SiType"; + +describe("SiType", () => { + + describe("SiType", () => { + it("should have the correct values", () => { + expect(SiType.NONE).toBe("NONE"); + expect(SiType.KILO).toBe("KILO"); + expect(SiType.MEGA).toBe("MEGA"); + expect(SiType.GIGA).toBe("GIGA"); + expect(SiType.TERA).toBe("TERA"); + expect(SiType.PETA).toBe("PETA"); + expect(SiType.EXA).toBe("EXA"); + expect(SiType.ZETTA).toBe("ZETTA"); + expect(SiType.YOTTA).toBe("YOTTA"); + + // Check that there are the correct number of assertions + expect.assertions(9); + }); + }); + + describe("isSiType", () => { + + it("should return true for valid SiType values", () => { + expect(isSiType(SiType.NONE)).toBe(true); + expect(isSiType(SiType.KILO)).toBe(true); + expect(isSiType(SiType.MEGA)).toBe(true); + expect(isSiType(SiType.GIGA)).toBe(true); + expect(isSiType(SiType.TERA)).toBe(true); + expect(isSiType(SiType.PETA)).toBe(true); + expect(isSiType(SiType.EXA)).toBe(true); + expect(isSiType(SiType.ZETTA)).toBe(true); + expect(isSiType(SiType.YOTTA)).toBe(true); + + // Check that there are the correct number of assertions + expect.assertions(9); + }); + + it("should return false for invalid SiType values", () => { + + expect(isSiType("invalid value")).toBe(false); + expect(isSiType(null)).toBe(false); + expect(isSiType(undefined)).toBe(false); + expect(isSiType(123)).toBe(false); + expect(isSiType(true)).toBe(false); + expect(isSiType({})).toBe(false); + expect(isSiType([])).toBe(false); + expect(isSiType(() => {})).toBe(false); + + // Check that there are the correct number of assertions + expect.assertions(8); // update this value as needed + + }); + }); + + describe("stringifySiType", () => { + it("should return the correct string representation for SiType values", () => { + + expect(stringifySiType(SiType.NONE)).toBe("NONE"); + expect(stringifySiType(SiType.KILO)).toBe("KILO"); + expect(stringifySiType(SiType.MEGA)).toBe("MEGA"); + expect(stringifySiType(SiType.GIGA)).toBe("GIGA"); + expect(stringifySiType(SiType.TERA)).toBe("TERA"); + expect(stringifySiType(SiType.PETA)).toBe("PETA"); + expect(stringifySiType(SiType.EXA)).toBe("EXA"); + expect(stringifySiType(SiType.ZETTA)).toBe("ZETTA"); + expect(stringifySiType(SiType.YOTTA)).toBe("YOTTA"); + + // Check that there are the correct number of assertions + expect.assertions(9); + + }); + + it("should throw an error for invalid SiType values", () => { + + // @ts-ignore + expect(() => stringifySiType("invalid value")).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifySiType(null)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifySiType(undefined)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifySiType(123)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifySiType(true)).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifySiType({})).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifySiType([])).toThrowError(TypeError); + // @ts-ignore + expect(() => stringifySiType(() => {})).toThrowError(TypeError); + + // Check that there are the correct number of assertions + expect.assertions(8); // update this value as needed + + }); + }); + + describe("parseSiType", () => { + it("should return the correct SiType value for string inputs", () => { + + expect(parseSiType("NONE")).toBe(SiType.NONE); + expect(parseSiType("KILO")).toBe(SiType.KILO); + expect(parseSiType("MEGA")).toBe(SiType.MEGA); + expect(parseSiType("GIGA")).toBe(SiType.GIGA); + expect(parseSiType("TERA")).toBe(SiType.TERA); + expect(parseSiType("PETA")).toBe(SiType.PETA); + expect(parseSiType("EXA")).toBe(SiType.EXA); + expect(parseSiType("ZETTA")).toBe(SiType.ZETTA); + expect(parseSiType("YOTTA")).toBe(SiType.YOTTA); + + expect(parseSiType("none")).toBe(SiType.NONE); + expect(parseSiType("kilo")).toBe(SiType.KILO); + expect(parseSiType("mega")).toBe(SiType.MEGA); + expect(parseSiType("giga")).toBe(SiType.GIGA); + expect(parseSiType("tera")).toBe(SiType.TERA); + expect(parseSiType("peta")).toBe(SiType.PETA); + expect(parseSiType("exa")).toBe(SiType.EXA); + expect(parseSiType("zetta")).toBe(SiType.ZETTA); + expect(parseSiType("yotta")).toBe(SiType.YOTTA); + + expect(parseSiType("")).toBe(SiType.NONE); + expect(parseSiType("k")).toBe(SiType.KILO); + expect(parseSiType("M")).toBe(SiType.MEGA); + expect(parseSiType("G")).toBe(SiType.GIGA); + expect(parseSiType("T")).toBe(SiType.TERA); + expect(parseSiType("P")).toBe(SiType.PETA); + expect(parseSiType("E")).toBe(SiType.EXA); + expect(parseSiType("Z")).toBe(SiType.ZETTA); + expect(parseSiType("Y")).toBe(SiType.YOTTA); + + // Check that there are the correct number of assertions + expect.assertions(9*3); + + }); + + it("should return undefined value for non-string inputs", () => { + + expect(parseSiType(123)).toBeUndefined(); + expect(parseSiType(null)).toBeUndefined(); + expect(parseSiType(undefined)).toBeUndefined(); + expect(parseSiType(true)).toBeUndefined(); + expect(parseSiType(false)).toBeUndefined(); + expect(parseSiType({})).toBeUndefined(); + expect(parseSiType([])).toBeUndefined(); + expect(parseSiType(() => {})).toBeUndefined(); + + // Check that there are the correct number of assertions + expect.assertions(8); // update this value as needed + + }); + + it("should return undefined for invalid inputs", () => { + + expect(parseSiType("K")).toBeUndefined(); + expect(parseSiType("m")).toBeUndefined(); + expect(parseSiType("g")).toBeUndefined(); + expect(parseSiType("t")).toBeUndefined(); + expect(parseSiType("p")).toBeUndefined(); + expect(parseSiType("e")).toBeUndefined(); + expect(parseSiType("z")).toBeUndefined(); + expect(parseSiType("y")).toBeUndefined(); + + expect(parseSiType("invalid value")).toBe(undefined); + expect(parseSiType("A")).toBe(undefined); + expect(parseSiType("B")).toBe(undefined); + expect(parseSiType("C")).toBe(undefined); + expect(parseSiType("abc")).toBe(undefined); + expect(parseSiType("foo")).toBe(undefined); + expect(parseSiType("bar")).toBe(undefined); + expect(parseSiType("baz")).toBe(undefined); + + // Check that there are the correct number of assertions + expect.assertions(16); // update this value as needed + + }); + }); + +}); diff --git a/types/SiType.ts b/types/SiType.ts new file mode 100644 index 0000000..ffeace1 --- /dev/null +++ b/types/SiType.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +/** + * @module + * @overview + * + * This module provides `SiType` enumeration and utilities for working with it. + * + * The `SiType` enumeration consists of a set of predefined values representing + * various units of measurement according to the International System of Units (SI). + * + * The module also provides functions for parsing, stringifying, and checking + * the validity of these values. + * + * ### Using the SiType Enumeration + * + * To use the `SiType` enumeration in your code, you will first need to import it + * from the module that defines it. + * + * You can do this by adding the following line at the top of your file: + * + * ```typescript + * import { SiType } from "./io/hyperify/types/SiType"; + * ``` + * + * Once you have imported the SiType enumeration, you can use the constants it + * defines in your code. For example, you could use the SiType.KILO constant in + * a function like this: + * + * ```typescript + * function convertToKilometers (distanceInMeters: number): number { + * return distanceInMeters / 1000; + * } + * const translations = { + * [SiType.KILO]: "kilometers", + * // ...add more translations for other SiType values as needed + * }; + * const distanceInKilometers = convertToKilometers(5000); // 5 + * console.log(`The distance is ${distanceInKilometers} ${translations[SiType.KILO]}.`); // The distance is 5 kilometers. * + * ``` + * + * ### Using the Utility Functions + * + * The module provides three utility functions that you can use to work with + * `SiType` values: `isSiType`, `stringifySiType`, and `parseSiType`. + * + * Here's an example of how you might use these functions in your code: + * + * ```typescript + * import { isSiType, stringifySiType, parseSiType } from "./path/to/si-type-module"; + * + * // Check if a value is a valid SiType + * if (isSiType(SiType.MEGA)) { + * console.log("This is a valid SiType value."); + * } else { + * console.log("This is not a valid SiType value."); + * } + * + * // Convert an SiType value to a string + * const siTypeString = stringifySiType(SiType.MEGA); // "MEGA" + * + * // Parse a value into an SiType + * const siTypeValue = parseSiType("mega"); // SiType.MEGA + * ``` + */ + +import { toUpper } from "../functions/toUpper"; +import { isArray } from "./Array"; +import { isString } from "./String"; + +/** + * An enumeration of predefined SI standard values. + */ +export enum SiType { + NONE = "NONE", + KILO = "KILO", + MEGA = "MEGA", + GIGA = "GIGA", + TERA = "TERA", + PETA = "PETA", + EXA = "EXA", + ZETTA = "ZETTA", + YOTTA = "YOTTA" +} + +/** + * A type guard function that checks if a value is a valid SiType. + * + * @param value - The value to check. + * @returns {boolean} `true` if the value is a valid SiType, `false` otherwise. + * @throws {None} This function does not throw any exceptions. + * @nosideeffects + */ +export function isSiType (value: any): value is SiType { + switch (value) { + + case SiType.NONE: + case SiType.KILO: + case SiType.MEGA: + case SiType.GIGA: + case SiType.TERA: + case SiType.PETA: + case SiType.EXA: + case SiType.ZETTA: + case SiType.YOTTA: + return true; + + default: + return false; + + } +} + +/** + * Converts an SiType value to its string representation. + * + * @param value - The SiType value to stringify. + * @returns {string} The string representation of the SiType value. + * @throws {TypeError} If the provided SiType value is not recognized. + * @nosideeffects + */ +export function stringifySiType (value: SiType): string { + switch (value) { + case SiType.NONE : return 'NONE'; + case SiType.KILO : return 'KILO'; + case SiType.MEGA : return 'MEGA'; + case SiType.GIGA : return 'GIGA'; + case SiType.TERA : return 'TERA'; + case SiType.PETA : return 'PETA'; + case SiType.EXA : return 'EXA'; + case SiType.ZETTA : return 'ZETTA'; + case SiType.YOTTA : return 'YOTTA'; + } + throw new TypeError(`Unsupported SiType value: ${value}`); +} + +/** + * Attempts to parse a value into an SiType. + * + * @param value - The value to parse. + * @returns The parsed SiType value, or `undefined` if the value could not be parsed. + * @throws {None} This function does not throw any exceptions. + * @nosideeffects + */ +export function parseSiType (value: any): SiType | undefined { + if (isArray(value)) return undefined; + if (!isString(value)) value = `${value}`; + value = value.length === 1 ? value : toUpper(value); + switch ( value ) { + + case '': + case 'NONE' : return SiType.NONE; + + case 'k': + case 'KILO' : return SiType.KILO; + + case 'M': + case 'MEGA' : return SiType.MEGA; + + case 'G': + case 'GIGA' : return SiType.GIGA; + + case 'T': + case 'TERA' : return SiType.TERA; + + case 'P': + case 'PETA' : return SiType.PETA; + + case 'E': + case 'EXA' : return SiType.EXA; + + case 'Z': + case 'ZETTA' : return SiType.ZETTA; + + case 'Y': + case 'YOTTA' : return SiType.YOTTA; + + default : return undefined; + + } + +} diff --git a/types/Sovereignty.test.ts b/types/Sovereignty.test.ts new file mode 100644 index 0000000..2dfbff9 --- /dev/null +++ b/types/Sovereignty.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isSovereignty, parseSovereignty, Sovereignty, stringifySovereignty } from "./Sovereignty"; + +describe('Sovereignty', () => { + + describe('isSovereignty', () => { + + it('should return true for valid Sovereignty value', () => { + expect(isSovereignty(Sovereignty.UN_MEMBER_STATE)).toBe(true); + }); + + it('should return false for invalid Sovereignty value', () => { + expect(isSovereignty('invalid')).toBe(false); + expect(isSovereignty(123)).toBe(false); + expect(isSovereignty(null)).toBe(false); + expect(isSovereignty(undefined)).toBe(false); + }); + }); + + describe('stringifySovereignty', () => { + it('should return correct string for valid Sovereignty value', () => { + expect(stringifySovereignty(Sovereignty.UN_MEMBER_STATE)).toBe('UN_MEMBER_STATE'); + }); + + it('should throw TypeError for invalid Sovereignty value', () => { + expect(() => stringifySovereignty('invalid' as any)).toThrow(TypeError); + }); + }); + + describe('parseSovereignty', () => { + + it('should return correct Sovereignty value for valid strings', () => { + expect(parseSovereignty('UN MEMBER STATE')).toBe(Sovereignty.UN_MEMBER_STATE); + expect(parseSovereignty('UN MEMBER STATE')).toBe(Sovereignty.UN_MEMBER_STATE); + expect(parseSovereignty('UN MEMBER STATE')).toBe(Sovereignty.UN_MEMBER_STATE); + expect(parseSovereignty('UN_MEMBER_STATE')).toBe(Sovereignty.UN_MEMBER_STATE); + }); + + it('should return undefined for invalid strings', () => { + expect(parseSovereignty('UN MEMBER')).toBeUndefined(); + expect(parseSovereignty('UN')).toBeUndefined(); + expect(parseSovereignty('UN STATE')).toBeUndefined(); + expect(parseSovereignty('invalid')).toBeUndefined(); + expect(parseSovereignty(123)).toBeUndefined(); + expect(parseSovereignty(null)).toBeUndefined(); + expect(parseSovereignty(undefined)).toBeUndefined(); + }); + + }); + +}); diff --git a/types/Sovereignty.ts b/types/Sovereignty.ts new file mode 100644 index 0000000..c0e23db --- /dev/null +++ b/types/Sovereignty.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum, isEnum, parseEnum, stringifyEnum } from "./Enum"; +import { explainNot, explainOk, explainOr } from "./explain"; +import { isUndefined } from "./undefined"; + +export enum Sovereignty { + DISPUTED_Z = 0, + UN_MEMBER_STATE = 1, + FINLAND = 2, + UNITED_STATES = 3, + UNITED_KINGDOM = 4, + ANTARCTIC_TREATY = 5, + NETHERLANDS = 6, + NORWAY = 7, + AUSTRALIA = 8, + NEW_ZEALAND = 9, + DENMARK = 10, + FRANCE = 11, + DISPUTED_AI = 12, + BRITISH_CROWN = 13, + UN_OBSERVER = 14, + CHINA = 15 +} + +export function isSovereignty (value: unknown) : value is Sovereignty { + return isEnum(Sovereignty, value); +} + +export function explainSovereignty (value : unknown) : string { + return explainEnum("Sovereignty", Sovereignty, isSovereignty, value); +} + +export function stringifySovereignty (value : Sovereignty) : string { + return stringifyEnum(Sovereignty, value); +} + +export function parseSovereignty (value: any) : Sovereignty | undefined { + return parseEnum(Sovereignty, value, true, true) as Sovereignty | undefined; +} + +export function isSovereigntyOrUndefined (value: unknown): value is Sovereignty | undefined { + return isUndefined(value) || isSovereignty(value); +} + +export function explainSovereigntyOrUndefined (value: unknown): string { + return isSovereigntyOrUndefined(value) ? explainOk() : explainNot(explainOr(['Sovereignty', 'undefined'])); +} diff --git a/types/String.ts b/types/String.ts new file mode 100644 index 0000000..bd396c4 --- /dev/null +++ b/types/String.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { default as _isString } from "lodash/isString"; +import { default as _isSymbol } from "lodash/isSymbol"; +import { replaceAll } from "../functions/replaceAll"; +import { isUndefined } from "./undefined"; +import { explainNot, explainOk, explainOr } from "./explain"; +import { isNull } from "lodash"; +import { isNumber } from "./Number"; +import { isFunction } from "./Function"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isString (value: unknown): value is string { + return _isString(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainString (value: any): string { + return isString(value) ? explainOk() : explainNot('string'); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringOrFalse (value: unknown): value is string | false { + return isString(value) || (value === false); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringOrFalse (value: any): string { + return isStringOrFalse(value) ? explainOk() : explainNot(explainOr(['string', 'false'])); +} + + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringOrNumber (value: unknown): value is string | number { + return isString(value) || isNumber(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringOrNumber (value: unknown): string { + return isStringOrNumber(value) ? explainOk() : explainNot(explainOr(['string', 'number'])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isNonEmptyString (value: unknown): value is string { + return _isString(value) && !!value; +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainNonEmptyString (value: any): string { + return isNonEmptyString(value) ? explainOk() : explainNot('non-empty string'); +} + +/** + * + * @param value + * @param minLength + * @param maxLength + * @__PURE__ + * @nosideeffects + */ +export function isStringOf ( + value: any, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): value is string { + + if ( !_isString(value) ) return false; + + const len = value?.length ?? 0; + + if ( minLength !== undefined && len < minLength ) { + return false; + } + + if ( maxLength !== undefined && len > maxLength ) { + return false; + } + + return true; + +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringOrUndefined (value: unknown): value is string | undefined { + return isUndefined(value) || isString(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringOrUndefined (value: any): string { + return isStringOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'string', 'undefined' ])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringOrNullOrUndefined (value: unknown): value is string | undefined | null { + return isNull(value) || isUndefined(value) || isString(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringOrNullOrUndefined (value: any): string { + return isStringOrNullOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'string', 'null', 'undefined' ])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringOrNumberOrNullOrUndefined (value: unknown): value is string | undefined | null { + return isNumber(value) || isNull(value) || isUndefined(value) || isString(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringOrNumberOrNullOrUndefined (value: any): string { + return isStringOrNumberOrNullOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'string', 'number', 'null', 'undefined' ])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringOrNull (value: unknown): value is string | null { + return isNull(value) || isString(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringOrNull (value: any): string { + return isStringOrNull(value) ? explainOk() : explainNot(explainOr([ 'string', 'null' ])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringOrSymbol (value: unknown): value is string { + return _isString(value) || _isSymbol(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringOrSymbol (value: any): string { + return isStringOrSymbol(value) ? explainOk() : explainNot('string | symbol'); +} + +/** + * + * @param value + * @param minLength + * @param maxLength + * @__PURE__ + * @nosideeffects + */ +export function isStringOrUndefinedOf ( + value: any, + minLength: number | undefined = undefined, + maxLength: number | undefined = undefined +): value is string | undefined { + return isUndefined(value) || isStringOf(value, minLength, maxLength); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function parseString (value: unknown): string | undefined { + if ( value === undefined ) return undefined; + if ( value === null ) return undefined; + if ( isFunction((value as any)?.toString) ) return (value as any)?.toString(); + return `${value}`; +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function parseNonEmptyString (value: any): string | undefined { + if ( value === undefined ) return undefined; + if ( value === '' ) return undefined; + return `${value}`; +} + +/** + * Returns the string with each line prefixed with another string. + * + * @param value The value + * @param prefix The prefix string + */ +export function prefixLines ( + value: string, + prefix: string +) : string { + return `${prefix}${replaceAll( + value, + "\n", + `\n${prefix}` + )}`; +} diff --git a/types/StringArray.ts b/types/StringArray.ts new file mode 100644 index 0000000..a8971d5 --- /dev/null +++ b/types/StringArray.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { isArray } from "./Array"; +import { isString } from "./String"; +import { explainNot, explainOk, explainOr } from "./explain"; +import { isUndefined } from "./undefined"; +import { every } from "../functions/every"; +import { isNull } from "./Null"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringArray (value: unknown): value is string[] { + return ( + !!value + && isArray(value) + && every(value, isString) + ); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringArray (value: any): string { + return isStringArray(value) ? explainOk() : explainNot('string[]'); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringArrayOrUndefined (value: unknown): value is string[] | undefined { + return isUndefined(value) || isStringArray(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringArrayOrUndefined (value: any): string { + return isStringArrayOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'string[]', 'undefined' ])); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isStringArrayOrNullOrUndefined (value: unknown): value is string[] | undefined { + return isNull(value) || isUndefined(value) || isStringArray(value); +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainStringArrayOrNullOrUndefined (value: any): string { + return isStringArrayOrNullOrUndefined(value) ? explainOk() : explainNot(explainOr([ 'string[]', 'null', 'undefined' ])); +} diff --git a/types/StringifyCallback.ts b/types/StringifyCallback.ts new file mode 100644 index 0000000..2e68fe4 --- /dev/null +++ b/types/StringifyCallback.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +export interface StringifyCallback { + (value: T): string; +} + diff --git a/types/TestCallback.ts b/types/TestCallback.ts new file mode 100644 index 0000000..e7f17bf --- /dev/null +++ b/types/TestCallback.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { every } from "../functions/every"; +import { some } from "../functions/some"; + +export interface TestCallback { + (value: any, index: number, arr: any[]): boolean; +} + +export interface TestCallbackOf { + (value: any, index: number, arr: any[]): value is T; +} + +export interface TestCallbackNonStandard { + (value: any, arg2 ?: undefined | number | string | boolean, arg3 ?: undefined | number | string | boolean): boolean; +} + +export interface TestCallbackNonStandardOf { + (value: any, arg2 ?: undefined | number | string | boolean, arg3 ?: undefined | number | string | boolean): value is T; +} + +/** + * + * @param callback + * @__PURE__ + * @nosideeffects + */ +export function toTestCallback (callback: TestCallbackNonStandard): TestCallback { + return (value, + // @ts-ignore @todo why unused? + index, + // @ts-ignore @todo why unused? + arr + ): boolean => callback(value); +} + +/** + * + * @param callback + * @__PURE__ + * @nosideeffects + */ +export function toTestCallbackNonStandard (callback: TestCallback): TestCallbackNonStandard { + // @ts-ignore + return (value, index, arr): boolean => callback(value); +} + +/** + * + * @param callbacks + * @__PURE__ + * @nosideeffects + */ +export function createOr (...callbacks: (TestCallback | TestCallbackNonStandard)[]): TestCallback { + return (value: unknown): value is T => some(callbacks, callback => callback(value)); +} + +/** + * + * @param callbacks + * @__PURE__ + * @nosideeffects + */ +export function createAnd (...callbacks: (TestCallback | TestCallbackNonStandard)[]): TestCallback { + return (value: unknown): value is T => every(callbacks, callback => callback(value)); +} diff --git a/types/Theme.ts b/types/Theme.ts new file mode 100644 index 0000000..11156fd --- /dev/null +++ b/types/Theme.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "./Enum"; + +export enum Theme { + DARK = "dark", + LIGHT = "light" +} + +export function isTheme (value: any): value is Theme { + switch (value) { + case Theme.DARK: + case Theme.LIGHT: + return true; + default: + return false; + } +} + +export function explainTheme (value: any): string { + return explainEnum("Theme", Theme, isTheme, value); +} + +export function stringifyTheme (value: Theme): string { + switch (value) { + case Theme.DARK : + return 'DARK'; + case Theme.LIGHT : + return 'LIGHT'; + } + throw new TypeError(`Unsupported Theme value: ${value}`); +} + +export function parseTheme (value: any): Theme | undefined { + if ( value === undefined ) return undefined; + switch (`${value}`.toUpperCase()) { + case 'DARK' : + return Theme.DARK; + case 'LIGHT' : + return Theme.LIGHT; + default : + return undefined; + } +} + + diff --git a/types/TranslatedObject.ts b/types/TranslatedObject.ts new file mode 100644 index 0000000..8f641a4 --- /dev/null +++ b/types/TranslatedObject.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2021-2022. Heusala Group Oy . All rights reserved. + +export interface TranslatedObject { + [key: string]: string; +} diff --git a/types/TranslationFunction.ts b/types/TranslationFunction.ts new file mode 100644 index 0000000..9aae823 --- /dev/null +++ b/types/TranslationFunction.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { TranslationParams } from "./TranslationParams"; + +export interface TranslationFunction { + (key: string, translationParams ?: TranslationParams) : string; +} diff --git a/types/TranslationParams.ts b/types/TranslationParams.ts new file mode 100644 index 0000000..36d3e07 --- /dev/null +++ b/types/TranslationParams.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +export interface TranslationParams { + readonly [key: string]: any; +} diff --git a/types/TranslationResourceObject.ts b/types/TranslationResourceObject.ts new file mode 100644 index 0000000..2952f7c --- /dev/null +++ b/types/TranslationResourceObject.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2021-2023. Heusala Group Oy . All rights reserved. + +import { ReadonlyJsonObject } from "../Json"; + +export interface TranslationResourceObject { + [key: string]: ReadonlyJsonObject; +} diff --git a/types/Uuid.ts b/types/Uuid.ts new file mode 100644 index 0000000..664dd43 --- /dev/null +++ b/types/Uuid.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr } from "./explain"; +import { explainString, isString } from "./String"; +import { isUndefined } from "./undefined"; + +export type Uuid = string; + +export function createUuid ( + value : string +) : Uuid { + if (!isUuid(value)) throw new TypeError(`createUuid: Not uuid: ${value}`) + return value; +} + +export function isUuid (value: unknown) : value is Uuid { + return ( + isString(value) && /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/.test(value) + ); +} + +export function explainUuid (value: any) : string { + return explain( + [ + explainString(value), + /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/.test(value) ? explainOk() : explainNot('Uuid') + ] + ); +} + +export function parseUuid (value: unknown) : Uuid | undefined { + if (isUuid(value)) return value; + return undefined; +} + +export function isUuidOrUndefined (value: unknown): value is Uuid | undefined { + return isUndefined(value) || isUuid(value); +} + +export function explainUuidOrUndefined (value: unknown): string { + return isUuidOrUndefined(value) ? explainOk() : explainNot(explainOr(['Uuid', 'undefined'])); +} diff --git a/types/explain.ts b/types/explain.ts new file mode 100644 index 0000000..e36d873 --- /dev/null +++ b/types/explain.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +// We're using directly lodash here to overcome circular dependencies +import { default as _isString } from "lodash/isString"; +import { default as _every } from "lodash/every"; +import { default as _filter } from "lodash/filter"; +import { map } from "../functions/map"; +import { prefixLines } from "./String"; + +/** + * Returned from explain functions when the value is OK. + */ +export const EXPLAIN_OK = 'OK'; + +export function explainNot (value: string): string { + return `not ${value}`; +} + +export function explainOneOf (names: readonly string[]): string { + if ( names.length < 1 ) { + throw new TypeError(`explainOneOf: at least one name required`); + } + if ( names.length < 2 ) { + return names[0]; + } + return `one of:\n${ + map( + names, + (item: string) : string => ` - ${item}` + ).join('\n') + }`; +} + +export function explainOr (value: string[]): string { + return value.join(' or '); +} + +export function explainOk (): string { + return EXPLAIN_OK; +} + +export function isExplainOk (value: unknown): boolean { + return value === EXPLAIN_OK; +} + +export function explain ( + values: readonly string[] | string +): string { + if ( _isString(values) ) return values; + if ( _every(values, (item: string): boolean => isExplainOk(item)) ) { + return explainOk(); + } + return _filter(values, (item: string): boolean => !isExplainOk(item) && !!item).join(', '); +} + +export function explainProperty ( + name: string, + values: readonly string[] | string +): string { + const e = explain(values); + return isExplainOk(e) ? explainOk() : '\n' + prefixLines(`property "${name}" ${e}`, ' '); +} diff --git a/types/openapi/CHANGELOG.md b/types/openapi/CHANGELOG.md new file mode 100644 index 0000000..5659f23 --- /dev/null +++ b/types/openapi/CHANGELOG.md @@ -0,0 +1,41 @@ +# openapi-types Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.4.0 - 2021-01-05 +### Added +- Added an index signature to `OperationObject` to allow access to extensions. + +### Fixed +- Added `undefined` to the index signature for `PathsObject` to prevent unsafe null access when `strictNullChecks` is enabled. + +## 1.3.5 - 2019-05-13 +### Fixed +- Amended missing usage of PathsObject in OpenAPIV3.Document interface. + +## 1.3.4 - 2019-01-31 +### Fixed +- OpenAPIV3: relax security requirement object types (#327) + +## 1.3.3 - 2019-01-22 +### Fixed +- Allowing to set a property of BaseSchemaObject as a reference to another SchemaObject (#312) + +## 1.3.2 - 2018-10-17 +### Added +- Added `Operation` to `OpenAPI` namespace. + +## 1.3.1 - 2018-10-03 +### Fixed +- Updating .npmignore to publish `dist` + +## 1.3.0 - 2018-10-03 +### Added +- `OpenAPI.Parameter` - Represents a parameter across all OpenAPI versions that have the notion of a parameter. + +## 1.2.0 - 2018-09-29 +### Added +- `OpenAPI.Parameters` - Represents parameters across all OpenAPI versions that have the notion of parameters. +- exporting `OpenAPIV2.Parameters` and `OpenAPIV2.Parameter`. diff --git a/types/openapi/README.md b/types/openapi/README.md new file mode 100644 index 0000000..151c080 --- /dev/null +++ b/types/openapi/README.md @@ -0,0 +1,16 @@ +# OpenAPI types + + * [Upstream](https://github.com/kogosoftwarellc/open-api/tree/master/packages/openapi-types) + +### Reason for unorthodox "fork" + +This project was copied because the upstream wasn't its own git repository, and +we prefer pure typescript through git submodules. + +### Update changes from upstream + +``` +curl -q https://raw.githubusercontent.com/kogosoftwarellc/open-api/master/packages/openapi-types/index.ts -O index.ts +curl -q https://raw.githubusercontent.com/kogosoftwarellc/open-api/master/packages/openapi-types/README.md -O README.upstream.md +curl -q https://raw.githubusercontent.com/kogosoftwarellc/open-api/master/packages/openapi-types/CHANGELOG.md -O CHANGELOG.md +``` diff --git a/types/openapi/README.upstream.md b/types/openapi/README.upstream.md new file mode 100644 index 0000000..aee3d27 --- /dev/null +++ b/types/openapi/README.upstream.md @@ -0,0 +1,52 @@ +# openapi-types [![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] [![Build Status][travis-image]][travis-url] [![Coveralls Status][coveralls-image]][coveralls-url] [![Gitter chat][gitter-image]][gitter-url] +> Types for OpenAPI documents. + +## Usage + +```typescript +import { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; + +function processV2(doc: OpenAPIV2.Document) {} + +function processV3(doc: OpenAPIV3.Document) {} + +function processV3_1(doc: OpenAPIV3_1.Document) {} +``` + +## LICENSE +`````` +The MIT License (MIT) + +Copyright (c) 2018 Kogo Software LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +`````` + +[downloads-image]: http://img.shields.io/npm/dm/openapi-types.svg +[npm-url]: https://npmjs.org/package/openapi-types +[npm-image]: http://img.shields.io/npm/v/openapi-types.svg + +[travis-url]: https://travis-ci.org/kogosoftwarellc/open-api +[travis-image]: https://api.travis-ci.org/kogosoftwarellc/open-api.svg?branch=master + +[coveralls-url]: https://coveralls.io/r/kogosoftwarellc/open-api +[coveralls-image]: https://coveralls.io/repos/github/kogosoftwarellc/open-api/badge.svg?branch=master + +[gitter-url]: https://gitter.im/kogosoftwarellc/open-api +[gitter-image]: https://badges.gitter.im/kogosoftwarellc/open-api.png diff --git a/types/openapi/index.ts b/types/openapi/index.ts new file mode 100644 index 0000000..3be7d1f --- /dev/null +++ b/types/openapi/index.ts @@ -0,0 +1,915 @@ +/* tslint:disable:no-namespace no-empty-interface */ +export namespace OpenAPI { + // OpenAPI extensions can be declared using generics + // e.g.: + // OpenAPI.Document<{ + // 'x-amazon-apigateway-integration': AWSAPITGatewayDefinition + // }> + export type Document = + | OpenAPIV2.Document + | OpenAPIV3.Document + | OpenAPIV3_1.Document; + export type Operation = + | OpenAPIV2.OperationObject + | OpenAPIV3.OperationObject + | OpenAPIV3_1.OperationObject; + export type Parameter = + | OpenAPIV3_1.ReferenceObject + | OpenAPIV3_1.ParameterObject + | OpenAPIV3.ReferenceObject + | OpenAPIV3.ParameterObject + | OpenAPIV2.ReferenceObject + | OpenAPIV2.Parameter; + export type Parameters = + | (OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.ParameterObject)[] + | (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[] + | (OpenAPIV2.ReferenceObject | OpenAPIV2.Parameter)[]; + + export interface Request { + body?: any; + headers?: object; + params?: object; + query?: object; + } +} + +export namespace OpenAPIV3_1 { + type Modify = Omit & R; + + type PathsWebhooksComponents = { + paths: PathsObject; + webhooks: Record; + components: ComponentsObject; + }; + + export type Document = Modify< + Omit, 'paths' | 'components'>, + { + info: InfoObject; + jsonSchemaDialect?: string; + servers?: ServerObject[]; + } & ( + | (Pick, 'paths'> & + Omit>, 'paths'>) + | (Pick, 'webhooks'> & + Omit>, 'webhooks'>) + | (Pick, 'components'> & + Omit>, 'components'>) + ) + >; + + export type InfoObject = Modify< + OpenAPIV3.InfoObject, + { + summary?: string; + license?: LicenseObject; + } + >; + + export type ContactObject = OpenAPIV3.ContactObject; + + export type LicenseObject = Modify< + OpenAPIV3.LicenseObject, + { + identifier?: string; + } + >; + + export type ServerObject = Modify< + OpenAPIV3.ServerObject, + { + url: string; + description?: string; + variables?: Record; + } + >; + + export type ServerVariableObject = Modify< + OpenAPIV3.ServerVariableObject, + { + enum?: [string, ...string[]]; + } + >; + + export type PathsObject = Record< + string, + (PathItemObject & P) | undefined + >; + + export type HttpMethods = OpenAPIV3.HttpMethods; + + export type PathItemObject = Modify< + OpenAPIV3.PathItemObject, + { + servers?: ServerObject[]; + parameters?: (ReferenceObject | ParameterObject)[]; + } + > & + { + [method in HttpMethods]?: OperationObject; + }; + + export type OperationObject = Modify< + OpenAPIV3.OperationObject, + { + parameters?: (ReferenceObject | ParameterObject)[]; + requestBody?: ReferenceObject | RequestBodyObject; + responses?: ResponsesObject; + callbacks?: Record; + servers?: ServerObject[]; + } + > & + T; + + export type ExternalDocumentationObject = OpenAPIV3.ExternalDocumentationObject; + + export type ParameterObject = OpenAPIV3.ParameterObject; + + export type HeaderObject = OpenAPIV3.HeaderObject; + + export type ParameterBaseObject = OpenAPIV3.ParameterBaseObject; + + export type NonArraySchemaObjectType = + | OpenAPIV3.NonArraySchemaObjectType + | 'null'; + + export type ArraySchemaObjectType = OpenAPIV3.ArraySchemaObjectType; + + /** + * There is no way to tell typescript to require items when type is either 'array' or array containing 'array' type + * 'items' will be always visible as optional + * Casting schema object to ArraySchemaObject or NonArraySchemaObject will work fine + */ + export type SchemaObject = + | ArraySchemaObject + | NonArraySchemaObject + | MixedSchemaObject; + + export interface ArraySchemaObject extends BaseSchemaObject { + type: ArraySchemaObjectType; + items: ReferenceObject | SchemaObject; + } + + export interface NonArraySchemaObject extends BaseSchemaObject { + type?: NonArraySchemaObjectType; + } + + interface MixedSchemaObject extends BaseSchemaObject { + type?: (ArraySchemaObjectType | NonArraySchemaObjectType)[]; + items?: ReferenceObject | SchemaObject; + } + + export type BaseSchemaObject = Modify< + Omit, + { + examples?: OpenAPIV3.BaseSchemaObject['example'][]; + exclusiveMinimum?: boolean | number; + exclusiveMaximum?: boolean | number; + contentMediaType?: string; + $schema?: string; + additionalProperties?: boolean | ReferenceObject | SchemaObject; + properties?: { + [name: string]: ReferenceObject | SchemaObject; + }; + allOf?: (ReferenceObject | SchemaObject)[]; + oneOf?: (ReferenceObject | SchemaObject)[]; + anyOf?: (ReferenceObject | SchemaObject)[]; + not?: ReferenceObject | SchemaObject; + discriminator?: DiscriminatorObject; + externalDocs?: ExternalDocumentationObject; + xml?: XMLObject; + const?: any; + } + >; + + export type DiscriminatorObject = OpenAPIV3.DiscriminatorObject; + + export type XMLObject = OpenAPIV3.XMLObject; + + export type ReferenceObject = Modify< + OpenAPIV3.ReferenceObject, + { + summary?: string; + description?: string; + } + >; + + export type ExampleObject = OpenAPIV3.ExampleObject; + + export type MediaTypeObject = Modify< + OpenAPIV3.MediaTypeObject, + { + schema?: SchemaObject | ReferenceObject; + examples?: Record; + } + >; + + export type EncodingObject = OpenAPIV3.EncodingObject; + + export type RequestBodyObject = Modify< + OpenAPIV3.RequestBodyObject, + { + content: { [media: string]: MediaTypeObject }; + } + >; + + export type ResponsesObject = Record< + string, + ReferenceObject | ResponseObject + >; + + export type ResponseObject = Modify< + OpenAPIV3.ResponseObject, + { + headers?: { [header: string]: ReferenceObject | HeaderObject }; + content?: { [media: string]: MediaTypeObject }; + links?: { [link: string]: ReferenceObject | LinkObject }; + } + >; + + export type LinkObject = Modify< + OpenAPIV3.LinkObject, + { + server?: ServerObject; + } + >; + + export type CallbackObject = Record; + + export type SecurityRequirementObject = OpenAPIV3.SecurityRequirementObject; + + export type ComponentsObject = Modify< + OpenAPIV3.ComponentsObject, + { + schemas?: Record; + responses?: Record; + parameters?: Record; + examples?: Record; + requestBodies?: Record; + headers?: Record; + securitySchemes?: Record; + links?: Record; + callbacks?: Record; + pathItems?: Record; + } + >; + + export type SecuritySchemeObject = OpenAPIV3.SecuritySchemeObject; + + export type HttpSecurityScheme = OpenAPIV3.HttpSecurityScheme; + + export type ApiKeySecurityScheme = OpenAPIV3.ApiKeySecurityScheme; + + export type OAuth2SecurityScheme = OpenAPIV3.OAuth2SecurityScheme; + + export type OpenIdSecurityScheme = OpenAPIV3.OpenIdSecurityScheme; + + export type TagObject = OpenAPIV3.TagObject; +} + +export namespace OpenAPIV3 { + export interface Document { + openapi: string; + info: InfoObject; + servers?: ServerObject[]; + paths: PathsObject; + components?: ComponentsObject; + security?: SecurityRequirementObject[]; + tags?: TagObject[]; + externalDocs?: ExternalDocumentationObject; + 'x-express-openapi-additional-middleware'?: ( + | ((request: any, response: any, next: any) => Promise) + | ((request: any, response: any, next: any) => void) + )[]; + 'x-express-openapi-validation-strict'?: boolean; + } + + export interface InfoObject { + title: string; + description?: string; + termsOfService?: string; + contact?: ContactObject; + license?: LicenseObject; + version: string; + } + + export interface ContactObject { + name?: string; + url?: string; + email?: string; + } + + export interface LicenseObject { + name: string; + url?: string; + } + + export interface ServerObject { + url: string; + description?: string; + variables?: { [variable: string]: ServerVariableObject }; + } + + export interface ServerVariableObject { + enum?: string[]; + default: string; + description?: string; + } + + export interface PathsObject { + [pattern: string]: (PathItemObject & P) | undefined; + } + + // All HTTP methods allowed by OpenAPI 3 spec + // See https://swagger.io/specification/#path-item-object + // You can use keys or values from it in TypeScript code like this: + // for (const method of Object.values(OpenAPIV3.HttpMethods)) { … } + export enum HttpMethods { + GET = 'get', + PUT = 'put', + POST = 'post', + DELETE = 'delete', + OPTIONS = 'options', + HEAD = 'head', + PATCH = 'patch', + TRACE = 'trace', + } + + export type PathItemObject = { + $ref?: string; + summary?: string; + description?: string; + servers?: ServerObject[]; + parameters?: (ReferenceObject | ParameterObject)[]; + } & { + [method in HttpMethods]?: OperationObject; + }; + + export type OperationObject = { + tags?: string[]; + summary?: string; + description?: string; + externalDocs?: ExternalDocumentationObject; + operationId?: string; + parameters?: (ReferenceObject | ParameterObject)[]; + requestBody?: ReferenceObject | RequestBodyObject; + responses: ResponsesObject; + callbacks?: { [callback: string]: ReferenceObject | CallbackObject }; + deprecated?: boolean; + security?: SecurityRequirementObject[]; + servers?: ServerObject[]; + } & T; + + export interface ExternalDocumentationObject { + description?: string; + url: string; + } + + export interface ParameterObject extends ParameterBaseObject { + name ?: string; + in: string; + } + + export interface HeaderObject extends ParameterBaseObject {} + + export interface ParameterBaseObject { + description?: string; + required?: boolean; + deprecated?: boolean; + allowEmptyValue?: boolean; + style?: string; + explode?: boolean; + allowReserved?: boolean; + schema?: ReferenceObject | SchemaObject; + example?: any; + examples?: { [media: string]: ReferenceObject | ExampleObject }; + content?: { [media: string]: MediaTypeObject }; + } + export type NonArraySchemaObjectType = + | 'boolean' + | 'object' + | 'number' + | 'string' + | 'integer'; + export type ArraySchemaObjectType = 'array'; + export type SchemaObject = ArraySchemaObject | NonArraySchemaObject; + + export interface ArraySchemaObject extends BaseSchemaObject { + type: ArraySchemaObjectType; + items: ReferenceObject | SchemaObject; + } + + export interface NonArraySchemaObject extends BaseSchemaObject { + type?: NonArraySchemaObjectType; + } + + export interface BaseSchemaObject { + // JSON schema allowed properties, adjusted for OpenAPI + title?: string; + description?: string; + format?: string; + default?: any; + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: boolean; + minimum?: number; + exclusiveMinimum?: boolean; + maxLength?: number; + minLength?: number; + pattern?: string; + additionalProperties?: boolean | ReferenceObject | SchemaObject; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + maxProperties?: number; + minProperties?: number; + required?: string[]; + enum?: any[]; + properties?: { + [name: string]: ReferenceObject | SchemaObject; + }; + allOf?: (ReferenceObject | SchemaObject)[]; + oneOf?: (ReferenceObject | SchemaObject)[]; + anyOf?: (ReferenceObject | SchemaObject)[]; + not?: ReferenceObject | SchemaObject; + + // OpenAPI-specific properties + nullable?: boolean; + discriminator?: DiscriminatorObject; + readOnly?: boolean; + writeOnly?: boolean; + xml?: XMLObject; + externalDocs?: ExternalDocumentationObject; + example?: any; + deprecated?: boolean; + } + + export interface DiscriminatorObject { + propertyName: string; + mapping?: { [value: string]: string }; + } + + export interface XMLObject { + name?: string; + namespace?: string; + prefix?: string; + attribute?: boolean; + wrapped?: boolean; + } + + export interface ReferenceObject { + $ref: string; + } + + export interface ExampleObject { + summary?: string; + description?: string; + value?: any; + externalValue?: string; + } + + export interface MediaTypeObject { + schema?: ReferenceObject | SchemaObject; + example?: any; + examples?: { [media: string]: ReferenceObject | ExampleObject }; + encoding?: { [media: string]: EncodingObject }; + } + + export interface EncodingObject { + contentType?: string; + headers?: { [header: string]: ReferenceObject | HeaderObject }; + style?: string; + explode?: boolean; + allowReserved?: boolean; + } + + export interface RequestBodyObject { + description?: string; + content: { [media: string]: MediaTypeObject }; + required?: boolean; + } + + export interface ResponsesObject { + [code: string]: ReferenceObject | ResponseObject; + } + + export interface ResponseObject { + description: string; + headers?: { [header: string]: ReferenceObject | HeaderObject }; + content?: { [media: string]: MediaTypeObject }; + links?: { [link: string]: ReferenceObject | LinkObject }; + } + + export interface LinkObject { + operationRef?: string; + operationId?: string; + parameters?: { [parameter: string]: any }; + requestBody?: any; + description?: string; + server?: ServerObject; + } + + export interface CallbackObject { + [url: string]: PathItemObject; + } + + export interface SecurityRequirementObject { + [name: string]: string[]; + } + + export interface ComponentsObject { + schemas?: { [key: string]: ReferenceObject | SchemaObject }; + responses?: { [key: string]: ReferenceObject | ResponseObject }; + parameters?: { [key: string]: ReferenceObject | ParameterObject }; + examples?: { [key: string]: ReferenceObject | ExampleObject }; + requestBodies?: { [key: string]: ReferenceObject | RequestBodyObject }; + headers?: { [key: string]: ReferenceObject | HeaderObject }; + securitySchemes?: { [key: string]: ReferenceObject | SecuritySchemeObject }; + links?: { [key: string]: ReferenceObject | LinkObject }; + callbacks?: { [key: string]: ReferenceObject | CallbackObject }; + } + + export type SecuritySchemeObject = + | HttpSecurityScheme + | ApiKeySecurityScheme + | OAuth2SecurityScheme + | OpenIdSecurityScheme; + + export interface HttpSecurityScheme { + type: 'http'; + description?: string; + scheme: string; + bearerFormat?: string; + } + + export interface ApiKeySecurityScheme { + type: 'apiKey'; + description?: string; + name: string; + in: string; + } + + export interface OAuth2SecurityScheme { + type: 'oauth2'; + description?: string; + flows: { + implicit?: { + authorizationUrl: string; + refreshUrl?: string; + scopes: { [scope: string]: string }; + }; + password?: { + tokenUrl: string; + refreshUrl?: string; + scopes: { [scope: string]: string }; + }; + clientCredentials?: { + tokenUrl: string; + refreshUrl?: string; + scopes: { [scope: string]: string }; + }; + authorizationCode?: { + authorizationUrl: string; + tokenUrl: string; + refreshUrl?: string; + scopes: { [scope: string]: string }; + }; + }; + } + + export interface OpenIdSecurityScheme { + type: 'openIdConnect'; + description?: string; + openIdConnectUrl: string; + } + + export interface TagObject { + name: string; + description?: string; + externalDocs?: ExternalDocumentationObject; + } +} + +export namespace OpenAPIV2 { + export interface Document { + basePath?: string; + consumes?: MimeTypes; + definitions?: DefinitionsObject; + externalDocs?: ExternalDocumentationObject; + host?: string; + info: InfoObject; + parameters?: ParametersDefinitionsObject; + paths: PathsObject; + produces?: MimeTypes; + responses?: ResponsesDefinitionsObject; + schemes?: string[]; + security?: SecurityRequirementObject[]; + securityDefinitions?: SecurityDefinitionsObject; + swagger: string; + tags?: TagObject[]; + 'x-express-openapi-additional-middleware'?: ( + | ((request: any, response: any, next: any) => Promise) + | ((request: any, response: any, next: any) => void) + )[]; + 'x-express-openapi-validation-strict'?: boolean; + } + + export interface TagObject { + name: string; + description?: string; + externalDocs?: ExternalDocumentationObject; + } + + export interface SecuritySchemeObjectBase { + type: 'basic' | 'apiKey' | 'oauth2'; + description?: string; + } + + export interface SecuritySchemeBasic extends SecuritySchemeObjectBase { + type: 'basic'; + } + + export interface SecuritySchemeApiKey extends SecuritySchemeObjectBase { + type: 'apiKey'; + name: string; + in: string; + } + + export type SecuritySchemeOauth2 = + | SecuritySchemeOauth2Implicit + | SecuritySchemeOauth2AccessCode + | SecuritySchemeOauth2Password + | SecuritySchemeOauth2Application; + + export interface ScopesObject { + [index: string]: any; + } + + export interface SecuritySchemeOauth2Base extends SecuritySchemeObjectBase { + type: 'oauth2'; + flow: 'implicit' | 'password' | 'application' | 'accessCode'; + scopes: ScopesObject; + } + + export interface SecuritySchemeOauth2Implicit + extends SecuritySchemeOauth2Base { + flow: 'implicit'; + authorizationUrl: string; + } + + export interface SecuritySchemeOauth2AccessCode + extends SecuritySchemeOauth2Base { + flow: 'accessCode'; + authorizationUrl: string; + tokenUrl: string; + } + + export interface SecuritySchemeOauth2Password + extends SecuritySchemeOauth2Base { + flow: 'password'; + tokenUrl: string; + } + + export interface SecuritySchemeOauth2Application + extends SecuritySchemeOauth2Base { + flow: 'application'; + tokenUrl: string; + } + + export type SecuritySchemeObject = + | SecuritySchemeBasic + | SecuritySchemeApiKey + | SecuritySchemeOauth2; + + export interface SecurityDefinitionsObject { + [index: string]: SecuritySchemeObject; + } + + export interface SecurityRequirementObject { + [index: string]: string[]; + } + + export interface ReferenceObject { + $ref: string; + } + + export type Response = ResponseObject | ReferenceObject; + + export interface ResponsesDefinitionsObject { + [index: string]: ResponseObject; + } + + export type Schema = SchemaObject | ReferenceObject; + + export interface ResponseObject { + description: string; + schema?: Schema; + headers?: HeadersObject; + examples?: ExampleObject; + } + + export interface HeadersObject { + [index: string]: HeaderObject; + } + + export interface HeaderObject extends ItemsObject {} + + export interface ExampleObject { + [index: string]: any; + } + + export interface ResponseObject { + description: string; + schema?: Schema; + headers?: HeadersObject; + examples?: ExampleObject; + } + + export type OperationObject = { + tags?: string[]; + summary?: string; + description?: string; + externalDocs?: ExternalDocumentationObject; + operationId?: string; + consumes?: MimeTypes; + produces?: MimeTypes; + parameters?: Parameters; + responses: ResponsesObject; + schemes?: string[]; + deprecated?: boolean; + security?: SecurityRequirementObject[]; + } & T; + + export interface ResponsesObject { + [index: string]: Response | undefined; + default?: Response; + } + + export type Parameters = (ReferenceObject | Parameter)[]; + + export type Parameter = InBodyParameterObject | GeneralParameterObject; + + export interface InBodyParameterObject extends ParameterObject { + schema: Schema; + } + + export interface GeneralParameterObject extends ParameterObject, ItemsObject { + allowEmptyValue?: boolean; + } + + // All HTTP methods allowed by OpenAPI 2 spec + // See https://swagger.io/specification/v2#path-item-object + // You can use keys or values from it in TypeScript code like this: + // for (const method of Object.values(OpenAPIV2.HttpMethods)) { … } + export enum HttpMethods { + GET = 'get', + PUT = 'put', + POST = 'post', + DELETE = 'delete', + OPTIONS = 'options', + HEAD = 'head', + PATCH = 'patch', + } + + export type PathItemObject = { + $ref?: string; + parameters?: Parameters; + } & { + [method in HttpMethods]?: OperationObject; + }; + + export interface PathsObject { + [index: string]: PathItemObject; + } + + export interface ParametersDefinitionsObject { + [index: string]: ParameterObject; + } + + export interface ParameterObject { + name: string; + in: string; + description?: string; + required?: boolean; + [index: string]: any; + } + + export type MimeTypes = string[]; + + export interface DefinitionsObject { + [index: string]: SchemaObject; + } + + export interface SchemaObject extends IJsonSchema { + [index: string]: any; + discriminator?: string; + readOnly?: boolean; + xml?: XMLObject; + externalDocs?: ExternalDocumentationObject; + example?: any; + default?: any; + items?: ItemsObject | ReferenceObject; + properties?: { + [name: string]: SchemaObject; + }; + } + + export interface ExternalDocumentationObject { + [index: string]: any; + description?: string; + url: string; + } + + export interface ItemsObject { + type: string; + format?: string; + items?: ItemsObject | ReferenceObject; + collectionFormat?: string; + default?: any; + maximum?: number; + exclusiveMaximum?: boolean; + minimum?: number; + exclusiveMinimum?: boolean; + maxLength?: number; + minLength?: number; + pattern?: string; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + enum?: any[]; + multipleOf?: number; + $ref?: string; + } + + export interface XMLObject { + [index: string]: any; + name?: string; + namespace?: string; + prefix?: string; + attribute?: boolean; + wrapped?: boolean; + } + + export interface InfoObject { + title: string; + description?: string; + termsOfService?: string; + contact?: ContactObject; + license?: LicenseObject; + version: string; + } + + export interface ContactObject { + name?: string; + url?: string; + email?: string; + } + + export interface LicenseObject { + name: string; + url?: string; + } +} + +export interface IJsonSchema { + id?: string; + $schema?: string; + title?: string; + description?: string; + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: boolean; + minimum?: number; + exclusiveMinimum?: boolean; + maxLength?: number; + minLength?: number; + pattern?: string; + additionalItems?: boolean | IJsonSchema; + items?: IJsonSchema | IJsonSchema[]; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + maxProperties?: number; + minProperties?: number; + required?: string[]; + additionalProperties?: boolean | IJsonSchema; + definitions?: { + [name: string]: IJsonSchema; + }; + properties?: { + [name: string]: IJsonSchema; + }; + patternProperties?: { + [name: string]: IJsonSchema; + }; + dependencies?: { + [name: string]: IJsonSchema | string[]; + }; + enum?: any[]; + type?: string | string[]; + allOf?: IJsonSchema[]; + anyOf?: IJsonSchema[]; + oneOf?: IJsonSchema[]; + not?: IJsonSchema; + $ref?: string; +} diff --git a/types/staticCheck.ts b/types/staticCheck.ts new file mode 100644 index 0000000..36dbc19 --- /dev/null +++ b/types/staticCheck.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +/** + * This is a helper function to make sure a static type check is performed. + * + * It may be required in some cases where TypeScript or the IDE fails to check + * the type of the argument and/or results in complex type error. + * + * NOTE! IT DOES NOT PERFORM RUNTIME CHECKS. + * + * @template T + * @param value The value to return + * @returns {T} The value which was provided as the argument untouched + * @__PURE__ + * @nosideeffects + */ +export function staticCheck (value: T) : T { + return value; +} + +/** + * This is a helper function to make sure a static type check is performed. + * + * It may be required in some cases where TypeScript or the IDE fails to check + * the type of the argument and/or results in complex type error. + * + * NOTE! IT DOES NOT PERFORM RUNTIME CHECKS. + * + * You can use it like this: + * + * ```typescript + * interface Foo { + * name ?: string; + * age ?: number; + * } + * + * function createFoo ( + * name ?: string, + * age ?: number + * ) : Foo { + * return { + * ...( name !== undefined ? staticCheckPartial({name}) : {}) + * ...( age !== undefined ? staticCheckPartial({age}) : {}) + * } + * } + * + * ``` + * + * For example, this would end up as a static compile time error because there + * is no bar property: + * + * ```typescript + * ...( age !== undefined ? staticCheckPartial({bar: age}) : {}) + * ``` + * + * @template T + * @param value The value to return + * @returns {Partial} The value which was provided as the argument untouched + * @__PURE__ + * @nosideeffects + */ +export function staticCheckPartial (value: Partial) : Partial { + return value; +} diff --git a/types/undefined.ts b/types/undefined.ts new file mode 100644 index 0000000..d7f0b6b --- /dev/null +++ b/types/undefined.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2020-2023. Heusala Group Oy . All rights reserved. + +import { explainOk } from "./explain"; + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function isUndefined (value: any): value is undefined { + return value === undefined; +} + +/** + * + * @param value + * @__PURE__ + * @nosideeffects + */ +export function explainUndefined (value: unknown): string { + return isUndefined(value) ? explainOk() : 'not undefined'; +} diff --git a/utils/components/mergeComponentContent.ts b/utils/components/mergeComponentContent.ts new file mode 100644 index 0000000..f7aa5ea --- /dev/null +++ b/utils/components/mergeComponentContent.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ComponentDTOContent } from "../../entities/component/ComponentContent"; +import { isArray } from "../../types/Array"; + +export function mergeComponentContent ( + a: ComponentDTOContent | undefined, + b: ComponentDTOContent | undefined, +) : ComponentDTOContent { + return [ + ...(a !== undefined ? ( isArray( a ) ? a : [ a ] ) : []), + ...(b !== undefined ? ( isArray( b ) ? b : [ b ] ) : []), + ]; +} diff --git a/utils/components/populateComponentDTO.ts b/utils/components/populateComponentDTO.ts new file mode 100644 index 0000000..2a31180 --- /dev/null +++ b/utils/components/populateComponentDTO.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ComponentDTOContent } from "../../entities/component/ComponentContent"; +import { ComponentEntity } from "../../entities/component/ComponentEntity"; +import { find } from "../../functions/find"; +import { ComponentDTO } from "../../entities/component/ComponentDTO"; +import { isHyperComponent } from "../../entities/types/HyperComponent"; +import { mergeComponentContent } from "./mergeComponentContent"; + +export function populateComponentDTO ( + component : ComponentDTO, + components : readonly ComponentDTO[] +): ComponentDTO { + + const extend: string | undefined = component.extend; + if ( extend === undefined ) { + return component; + } + + const extendComponent: ComponentDTO | undefined = find( + components, + (c: ComponentDTO): boolean => c.name === extend + ); + + const componentContent: ComponentDTOContent | undefined = component.content; + + if ( !extendComponent ) { + // Is built in component + if ( isHyperComponent( extend ) ) { + return ( + ComponentEntity.create(extend) + .content(componentContent) + .meta(component.meta) + .style(component.style) + .getDTO() + ); + } + throw new TypeError( `Could not find component by name ${extend} to extend for ${component.name}` ); + } + + const extendContent: ComponentDTOContent | undefined = extendComponent.content; + + return populateComponentDTO( + ( + ComponentEntity.create(extendComponent.name) + .extend(extendComponent.extend) + .content( extendContent ? mergeComponentContent(extendContent, componentContent) : componentContent ) + .meta( + { + ...(extendComponent.meta ? extendComponent.meta : {}), + ...(component.meta ? component.meta : {}), + } + ) + .style( { + ...(extendComponent.style ? extendComponent.style : {}), + ...(component.style ? component.style : {}), + } ) + .getDTO() + ), + components + ); + +} \ No newline at end of file diff --git a/utils/populateAppDTO.ts b/utils/populateAppDTO.ts new file mode 100644 index 0000000..a6aec95 --- /dev/null +++ b/utils/populateAppDTO.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { + explainComponentDTO, + isComponentDTO, +} from "../entities/component/ComponentEntity"; +import { + explainRouteDTO, + isRouteDTO, +} from "../entities/route/RouteEntity"; +import { + explainViewDTO, + isViewDTO, +} from "../entities/view/ViewEntity"; +import { some } from "../functions/some"; +import { ViewDTO } from "../entities/view/ViewDTO"; +import { AppDTO } from "../entities/app/AppDTO"; +import { HttpService } from "../HttpService"; +import { LogService } from "../LogService"; +import { ReadonlyJsonAny } from "../Json"; +import { ComponentDTO } from "../entities/component/ComponentDTO"; +import { RouteDTO } from "../entities/route/RouteDTO"; + +const LOG = LogService.createLogger('populateAppDTO'); + +export async function fetchMissingViews ( + views: readonly ViewDTO[], + baseUrl: string, +) : Promise { + let newViews : ViewDTO[] = []; + for (const view of views) { + + let extend: string | undefined = view.extend; + if (extend === undefined) { + newViews.push(view); + continue; + } + if (extend.startsWith('/')) { + extend = baseUrl + extend; + } + + if (extend.startsWith('http://') || extend.startsWith('https://')) { + + newViews.push({ + ...view, + extend + }); + + // Skip if we already have the resource + if (some( + [...newViews, ...views], + (item: ViewDTO) : boolean => item.name === extend + )) { + continue; + } + + // Fetch missing resources + const response: ReadonlyJsonAny | ViewDTO | undefined = await HttpService.getJson(extend); + if ( isViewDTO(response) ) { + newViews.push( { + ...(response as ViewDTO), + name: extend, + } ); + } else { + LOG.debug( `response: ${explainViewDTO(response)}: `, response ); + throw new TypeError( `Response was not HyperViewDTO` ); + } + + } else { + newViews.push(view); + } + } + return newViews; +} + +export async function fetchMissingComponents ( + components : readonly ComponentDTO[], + baseUrl : string, +) : Promise { + let newComponents : ComponentDTO[] = []; + for (const component of components) { + newComponents.push(component); + let extend: string | undefined = component.extend; + + if (extend === undefined) { + continue; + } + if (extend.startsWith('/')) { + extend = baseUrl + extend; + } + if (extend.startsWith('http://') || extend.startsWith('https://')) { + + // Skip if we already have the resource + if (some( + [...newComponents, ...components], + (item: ComponentDTO) : boolean => item.name === extend + )) { + continue; + } + + // Fetch missing resources + const response: ReadonlyJsonAny | undefined = await HttpService.getJson(extend); + if ( isComponentDTO( response ) ) { + newComponents.push( { + ...(response as ComponentDTO), + name: extend + } ); + } else { + LOG.debug( `response: ${explainComponentDTO( response )}: `, response ); + throw new TypeError( `Response was not HyperComponentDTO` ); + } + + } + } + return newComponents; +} + +export async function fetchMissingRoutes ( + routes : readonly RouteDTO[], + baseUrl: string, +): Promise { + let newRoutes : RouteDTO[] = []; + for (const route of routes) { + newRoutes.push(route); + let extend: string | undefined = route.extend; + + if (extend === undefined) { + continue; + } + if (extend.startsWith('/')) { + extend = baseUrl + extend; + } + if (extend.startsWith('http://') || extend.startsWith('https://')) { + + // Skip if we already have the resource + if (some( + [...newRoutes, ...routes], + (item: RouteDTO) : boolean => item.name === extend + )) { + continue; + } + + // Fetch missing resources + const response: ReadonlyJsonAny | undefined = await HttpService.getJson(extend); + if ( isRouteDTO( response ) ) { + newRoutes.push( { + ...(response as RouteDTO), + name: extend + } ); + } else { + LOG.debug( `response: ${explainRouteDTO( response )}: `, response ); + throw new TypeError( `Response was not HyperRouteDTO` ); + } + newRoutes.push(response); + + } + } + return newRoutes; +} + +export async function populateAppDTO ( + hyper: AppDTO, + baseUrl: string | undefined = undefined, +): Promise { + + baseUrl = baseUrl ?? hyper.publicUrl ?? ''; + + const newViewsPromise = fetchMissingViews(hyper.views, baseUrl); + const newComponentsPromise = fetchMissingComponents(hyper.components, baseUrl); + const newRoutesPromise = fetchMissingRoutes(hyper.routes, baseUrl); + + const newViews = await newViewsPromise; + const newComponents = await newComponentsPromise; + const newRoutes = await newRoutesPromise; + + return { + ...hyper, + views: newViews, + components: newComponents, + routes: newRoutes, + }; + +} diff --git a/utils/views/findAndPopulateViewDTO.test.ts b/utils/views/findAndPopulateViewDTO.test.ts new file mode 100644 index 0000000..ba55b4b --- /dev/null +++ b/utils/views/findAndPopulateViewDTO.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ViewEntity } from "../../entities/view/ViewEntity"; +import { LogLevel } from "../../types/LogLevel"; +import { findAndPopulateViewDTO } from "./findAndPopulateViewDTO"; +import { isArrayOf } from "../../types/Array"; +import { findViewDTO } from "./findViewDTO"; +import { populateViewDTO } from "./populateViewDTO"; + +describe('findAndPopulateViewDTO', () => { + + const viewWithoutExtension = ( + ViewEntity.create('View1') + .setPublicUrl('url1') + .setLanguage('en') + .setContent(["Content 1", "Content 2"]) + .getDTO() + ); + + const viewWithExtension = ( + ViewEntity.create('View2') + .extend('View1') + .setLanguage('fr') + .setContent(["Content 3"]) + .getDTO() + ); + + const viewNotFound = ( + ViewEntity.create('View3') + .extend('NonexistentView') + .setLanguage('es') + .setContent(["Content 4"]) + .getDTO() + ); + + const views = [viewWithoutExtension, viewWithExtension, viewNotFound]; + + beforeAll( () => { + populateViewDTO.setLogger(LogLevel.NONE); + findViewDTO.setLogger(LogLevel.NONE); + }); + + it('should find and return the original view when extend is undefined', () => { + const result = findAndPopulateViewDTO("View1", views, ''); + expect(result).toEqual(viewWithoutExtension); + }); + + it('should find the view and populate it with properties from the extended view', () => { + const result = findAndPopulateViewDTO("View2", views, ''); + + // Verify that properties from the extended view are merged correctly + expect(result.name).toEqual('View2'); // Name from the extended view + expect(result.publicUrl).toEqual('url1'); // Public URL from the extended view + expect(result.language).toEqual('en'); // Language from the extended view + expect(isArrayOf(result?.content, undefined, 3, 3)).toEqual(true); // Merged content from both views + + // Make sure the original view is not modified + expect(viewWithExtension.name).toEqual('View2'); + expect(viewWithExtension.language).toEqual('fr'); // Original view language should not be modified + expect(viewWithExtension.content).toEqual(["Content 3"]); // Original content + }); + + it('should create empty view if view not found', () => { + const result = findAndPopulateViewDTO('NonexistentView', views, ''); + expect(result).toEqual({ + name: "NonexistentView" + } ); + }); + + it('should throw an error when the extended view is not found', () => { + expect(() => findAndPopulateViewDTO("View3", views, '')).toThrowError( + new TypeError('Could not find view by name NonexistentView to extend for View3') + ); + }); + +}); diff --git a/utils/views/findAndPopulateViewDTO.ts b/utils/views/findAndPopulateViewDTO.ts new file mode 100644 index 0000000..c5b0c94 --- /dev/null +++ b/utils/views/findAndPopulateViewDTO.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ViewDTO } from "../../entities/view/ViewDTO"; +import { findViewDTO } from "./findViewDTO"; +import { populateViewDTO } from "./populateViewDTO"; + +export function findAndPopulateViewDTO ( + viewName : string, + allViews : readonly ViewDTO[], + publicUrl : string, +) : ViewDTO { + const view : ViewDTO = populateViewDTO( + findViewDTO(viewName, allViews), + allViews, + publicUrl, + ); + return { + ...view, + name: viewName, + }; +} diff --git a/utils/views/findViewDTO.test.ts b/utils/views/findViewDTO.test.ts new file mode 100644 index 0000000..a551346 --- /dev/null +++ b/utils/views/findViewDTO.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ViewEntity } from "../../entities/view/ViewEntity"; +import { LogLevel } from "../../types/LogLevel"; +import { findViewDTO } from "./findViewDTO"; +import { populateViewDTO } from "./populateViewDTO"; + +describe('findViewDTO', () => { + + let hyperView1 = ViewEntity.create('View1').getDTO(); + + let hyperView2 = ViewEntity.create('View2').getDTO(); + + let allViews = [hyperView1, hyperView2]; + + beforeAll( () => { + populateViewDTO.setLogger(LogLevel.NONE); + findViewDTO.setLogger(LogLevel.NONE); + }); + + it('should find a hyper view by name', () => { + const result = findViewDTO('View1', allViews); + expect(result).toEqual(hyperView1); + }); + + it('should create an empty view when the view is not found', () => { + const result = findViewDTO('NonexistentView', allViews); + expect(result).toEqual( + expect.objectContaining({ + name: 'NonexistentView' + }) + ); + }); + +}); diff --git a/utils/views/findViewDTO.ts b/utils/views/findViewDTO.ts new file mode 100644 index 0000000..72a3423 --- /dev/null +++ b/utils/views/findViewDTO.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ViewEntity } from "../../entities/view/ViewEntity"; +import { find } from "../../functions/find"; +import { LogService } from "../../LogService"; +import { ViewDTO } from "../../entities/view/ViewDTO"; +import { LogLevel } from "../../types/LogLevel"; + +const LOG = LogService.createLogger( 'findViewDTO' ); + +export function findViewDTO ( + viewName : string, + allViews : readonly ViewDTO[], +) : ViewDTO { + const view : ViewDTO | undefined = find( + allViews, + (a: ViewDTO) : boolean => a.name === viewName + ); + if (!view) { + LOG.warn(`Warning! Could not find view by name: ${viewName}`); + return ViewEntity.create(viewName).getDTO(); + } + return view; +} + +findViewDTO.setLogger = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/utils/views/populateViewDTO.test.ts b/utils/views/populateViewDTO.test.ts new file mode 100644 index 0000000..5fa4b8a --- /dev/null +++ b/utils/views/populateViewDTO.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { ViewEntity } from "../../entities/view/ViewEntity"; +import { LogLevel } from "../../types/LogLevel"; +import { findViewDTO } from "./findViewDTO"; +import { populateViewDTO } from "./populateViewDTO"; +import { isArrayOf } from "../../types/Array"; + +describe('populateViewDTO', () => { + + beforeAll( () => { + populateViewDTO.setLogger(LogLevel.NONE); + findViewDTO.setLogger(LogLevel.NONE); + }); + + const viewWithoutExtension = ( + ViewEntity.create('View1') + .setPublicUrl('url1') + .setLanguage('en') + .setContent([ + "Content 1", + "Content 2" + ]) + .getDTO() + ); + + const viewWithExtension = ( + ViewEntity.create('View2') + .setExtend('View1') + .setLanguage('fr') + .setContent(["Content 3"]) + .getDTO() + ); + + const viewNotFound = ( + ViewEntity.create('View3') + .extend('NonexistentView') + .setLanguage('es') + .setContent(["Content 4"]) + .getDTO() + ); + + const components = [viewWithoutExtension, viewWithExtension]; + + it('should return the original view when extend is undefined', () => { + const result = populateViewDTO(viewWithoutExtension, components, ''); + expect(result).toEqual(viewWithoutExtension); + }); + + it('should populate the view with properties from the extended view', () => { + + const result = populateViewDTO(viewWithExtension, components, ''); + + // Verify that properties from the extended view are merged correctly + expect(result.name).toEqual('View1'); // Name from the extended view + expect(result.publicUrl).toEqual('url1'); // Public URL from the extended view + expect(result.language).toEqual('en'); // Language from the extended view + expect(isArrayOf(result?.content, undefined, 3, 3)).toEqual(true); // Merged content from both views + + // Make sure the original view is not modified + expect(viewWithExtension.name).toEqual('View2'); + expect(viewWithExtension.language).toEqual('fr'); // Original view language should not be modified + expect(viewWithExtension.content).toEqual(["Content 3"]); // Original content + + }); + + it('should throw an error when the extended view is not found', () => { + expect(() => populateViewDTO(viewNotFound, components, '')).toThrowError( + new TypeError('Could not find view by name NonexistentView to extend for View3') + ); + }); + +}); diff --git a/utils/views/populateViewDTO.ts b/utils/views/populateViewDTO.ts new file mode 100644 index 0000000..3f4cbd7 --- /dev/null +++ b/utils/views/populateViewDTO.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2023-2024. Sendanor . All rights reserved. + +import { ComponentDTOContent } from "../../entities/component/ComponentContent"; +import { ViewEntity } from "../../entities/view/ViewEntity"; +import { find } from "../../functions/find"; +import { LogService } from "../../LogService"; +import { LogLevel } from "../../types/LogLevel"; +import { ViewDTO } from "../../entities/view/ViewDTO"; +import { mergeComponentContent } from "../components/mergeComponentContent"; + +const LOG = LogService.createLogger( 'populateViewDTO' ); + +/** + * + * @param view + * @param views + * @param publicUrl + */ +export function populateViewDTO ( + view: ViewDTO, + views: readonly ViewDTO[], + publicUrl : string, +): ViewDTO { + + publicUrl = view.publicUrl ?? publicUrl; + + let extend: string | undefined = view.extend; + if ( extend === undefined ) { + return view; + } + if (extend.startsWith('/')) { + extend = `${publicUrl}${extend}`; + } + + const extendView: ViewDTO | undefined = find( + views, + (c: ViewDTO): boolean => c.name === extend + ); + + if ( !extendView ) { + LOG.debug(`views = `, views); + throw new TypeError( `Could not find view by name ${extend} to extend for ${view.name}` ); + } + + const componentContent: ComponentDTOContent | undefined = view.content; + const extendContent: ComponentDTOContent | undefined = extendView.content; + + return populateViewDTO( + ViewEntity.create(extendView.name) + .extend(extendView.extend) + .setPublicUrl(extendView.publicUrl ?? view.publicUrl) + .setLanguage(extendView.language ?? view.language) + .setSeo({ + ...(extendView.seo ? extendView.seo : {}), + ...(view.seo ? view.seo : {}), + }) + .setContent( mergeComponentContent(extendContent, componentContent) ) + .setStyle({ + ...(extendView.style ? extendView.style : {}), + ...(view.style ? view.style : {}), + }) + .setMeta({ + ...(extendView.meta ? extendView.meta : {}), + ...(view.meta ? view.meta : {}), + }) + .getDTO(), + views, + publicUrl, + ); + +} + +populateViewDTO.setLogger = (level: LogLevel) : void => { + LOG.setLogLevel(level); +}; diff --git a/views/extend/ExtendView.ts b/views/extend/ExtendView.ts new file mode 100644 index 0000000..876b5b5 --- /dev/null +++ b/views/extend/ExtendView.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainString, isString } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; + +/** + * View to redirect the frontend to another view or URL. + * + * This is required since the frontend usually cannot detect the Location HTTP + * header, e.g. the HTTP client library already implements the redirection. + */ +export interface ExtendView { + readonly extend: string; +} + +export function createExtendView ( + extend : string +) : ExtendView { + return { + extend + }; +} + +export function isExtendView ( value: unknown) : value is ExtendView { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'extend', + ]) + && isString(value?.extend) + ); +} + +export function explainExtendView (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'extend', + ]) + , explainProperty("extend", explainString(value?.extend)) + ] + ); +} + +export function stringifyExtendView (value : ExtendView) : string { + return `ExtendView(${value})`; +} + +export function parseExtendView (value: unknown) : ExtendView | undefined { + if (isExtendView(value)) return value; + return undefined; +} + +export function isExtendViewOrUndefined ( value: unknown): value is ExtendView | undefined { + return isUndefined(value) || isExtendView(value); +} + +export function explainExtendViewOrUndefined (value: unknown): string { + return isExtendViewOrUndefined(value) ? explainOk() : explainNot(explainOr(['ExtendView', 'undefined'])); +} diff --git a/views/extend/ExtendViewDTO.ts b/views/extend/ExtendViewDTO.ts new file mode 100644 index 0000000..4764af4 --- /dev/null +++ b/views/extend/ExtendViewDTO.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { createViewDTO, ViewDTO } from "../../entities/view/ViewDTO"; +import { ExtendView } from "./ExtendView"; + +export const EXTEND_VIEW_NAME: string = 'ExtendView'; + +export type ExtendViewDTO = ViewDTO; + +export function createExtendViewDTO ( + dto: ExtendView +) : ExtendViewDTO { + return createViewDTO( + EXTEND_VIEW_NAME, + dto.extend, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + ); +} diff --git a/views/redirect/RedirectView.ts b/views/redirect/RedirectView.ts new file mode 100644 index 0000000..24dc049 --- /dev/null +++ b/views/redirect/RedirectView.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explain, explainNot, explainOk, explainOr, explainProperty } from "../../types/explain"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainString, isString } from "../../types/String"; +import { isUndefined } from "../../types/undefined"; + +/** + * View to redirect the frontend to another view or URL. + * + * This is required since the frontend usually cannot detect the Location HTTP + * header, e.g. the HTTP client library already implements the redirection. + */ +export interface RedirectView { + readonly location: string; +} + +export function createRedirectView ( + location : string +) : RedirectView { + return { + location + }; +} + +export function isRedirectView ( value: unknown) : value is RedirectView { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'location', + ]) + && isString(value?.location) + ); +} + +export function explainRedirectView (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'location', + ]) + , explainProperty("location", explainString(value?.location)) + ] + ); +} + +export function stringifyRedirectView (value : RedirectView) : string { + return `RedirectView(${value})`; +} + +export function parseRedirectView (value: unknown) : RedirectView | undefined { + if (isRedirectView(value)) return value; + return undefined; +} + +export function isRedirectViewOrUndefined ( value: unknown): value is RedirectView | undefined { + return isUndefined(value) || isRedirectView(value); +} + +export function explainRedirectViewOrUndefined (value: unknown): string { + return isRedirectViewOrUndefined(value) ? explainOk() : explainNot(explainOr(['RedirectView', 'undefined'])); +} diff --git a/views/redirect/RedirectViewDTO.ts b/views/redirect/RedirectViewDTO.ts new file mode 100644 index 0000000..7505aea --- /dev/null +++ b/views/redirect/RedirectViewDTO.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2023. Sendanor . All rights reserved. + +import { createViewDTO, ViewDTO } from "../../entities/view/ViewDTO"; +import { RedirectView } from "./RedirectView"; + +export const REDIRECT_VIEW_NAME: string = 'RedirectView'; + +export type RedirectViewDTO = ViewDTO; + +export function createRedirectViewDTO ( + dto: RedirectView +) : RedirectViewDTO { + return createViewDTO( + REDIRECT_VIEW_NAME, + undefined, + undefined, + undefined, + undefined, + [ + 'Redirecting...' + ], + undefined, + { + location: dto.location, + }, + ); +} diff --git a/whois/WhoisServerRegistryService.ts b/whois/WhoisServerRegistryService.ts new file mode 100644 index 0000000..87d876d --- /dev/null +++ b/whois/WhoisServerRegistryService.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { WhoisServerOptions } from "./types/WhoisServerOptions"; + +export interface WhoisServerRegistryService { + + resolveServerFromAddress ( addr: string ): string | WhoisServerOptions | undefined; + +} diff --git a/whois/WhoisService.ts b/whois/WhoisService.ts new file mode 100644 index 0000000..96098fe --- /dev/null +++ b/whois/WhoisService.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { WhoisLookupResult } from "./types/WhoisLookupResult"; +import { WhoisLookupOptions } from "./types/WhoisLookupOptions"; + +/** + * @see NodeWhoisService at https://github.com/heusalagroup/fi.hg.node + * @see example use at https://github.com/heusalagroup/whois.hg.fi/blob/main/src/controllers/FiHgWhoisBackendController.ts#L51 + */ +export interface WhoisService { + + whoisLookup ( + address: string, + options?: WhoisLookupOptions + ) : Promise; + +} diff --git a/whois/types/WhoisDTO.ts b/whois/types/WhoisDTO.ts new file mode 100644 index 0000000..638f9ad --- /dev/null +++ b/whois/types/WhoisDTO.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { WhoisLookupResult } from "./WhoisLookupResult"; + +export interface WhoisDTO { + readonly payload : readonly WhoisLookupResult[]; +} + +export function createWhoisDTO ( + payload: readonly WhoisLookupResult[] +) : WhoisDTO { + return { + payload + }; +} + +// export function isWhoisDTO (value: any) : value is WhoisDTO { +// return ( +// isRegularObject(value) +// && hasNoOtherKeys(value, [ +// 'payload' +// ]) +// && isString(value?.foo) +// ); +// } +// +// export function explainWhoisDTO (value: any) : string { +// return explain( +// [ +// explainRegularObject(value), +// explainNoOtherKeys(value, [ +// '' +// ]), +// explainProperty("foo", explainString(value?.foo)) +// ] +// ); +// } +// +// export function stringifyWhoisDTO (value : WhoisDTO) : string { +// return `WhoisDTO(${value})`; +// } +// +// export function parseWhoisDTO (value: any) : WhoisDTO | undefined { +// if (isWhoisDTO(value)) return value; +// return undefined; +// } diff --git a/whois/types/WhoisLookupOptions.ts b/whois/types/WhoisLookupOptions.ts new file mode 100644 index 0000000..732a30c --- /dev/null +++ b/whois/types/WhoisLookupOptions.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { WhoisServerOptions } from "./WhoisServerOptions"; + +export interface WhoisLookupOptions { + readonly server?: string | WhoisServerOptions | null; + readonly follow?: number; + readonly timeout?: number; + readonly punycode?: boolean; + readonly encoding?: BufferEncoding; + readonly responseEncoding?: BufferEncoding; + readonly bind?: string | null; +} diff --git a/whois/types/WhoisLookupResult.ts b/whois/types/WhoisLookupResult.ts new file mode 100644 index 0000000..140d879 --- /dev/null +++ b/whois/types/WhoisLookupResult.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explain, explainProperty } from "../../types/explain"; +import { explainString, isString } from "../../types/String"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface WhoisLookupResult { + readonly server: string; + readonly data: string; +} + +export function createWhoisLookupResult ( + server: string, + data: string +): WhoisLookupResult { + return { + server, + data + }; +} + +export function isWhoisLookupResult (value: any): value is WhoisLookupResult { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'server', + 'data' + ]) + && isString(value?.server) + && isString(value?.data) + ); +} + +export function explainWhoisLookupResult (value: any): string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'server', + 'data' + ]), + explainProperty("server", explainString(value?.server)), + explainProperty("data", explainString(value?.data)) + ] + ); +} + +export function stringifyWhoisLookupResult (value: WhoisLookupResult): string { + if ( !isWhoisLookupResult(value) ) throw new TypeError(`Not WhoisLookupResult: ${value}`); + return `WhoisLookupResult(${value})`; +} + +export function parseWhoisLookupResult (value: any): WhoisLookupResult | undefined { + if ( isWhoisLookupResult(value) ) return value; + return undefined; +} diff --git a/whois/types/WhoisServerList.ts b/whois/types/WhoisServerList.ts new file mode 100644 index 0000000..18c047d --- /dev/null +++ b/whois/types/WhoisServerList.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { WhoisServerOptions } from "./WhoisServerOptions"; + +export interface WhoisServerList { + readonly [key: string]: string | WhoisServerOptions | null | undefined; +} diff --git a/whois/types/WhoisServerOptions.ts b/whois/types/WhoisServerOptions.ts new file mode 100644 index 0000000..d86d478 --- /dev/null +++ b/whois/types/WhoisServerOptions.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explain, explainProperty } from "../../types/explain"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../types/Boolean"; +import { explainStringOrUndefined, isStringOrUndefined } from "../../types/String"; +import { explainNumberOrUndefined, isNumberOrUndefined } from "../../types/Number"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeys } from "../../types/OtherKeys"; + +export interface WhoisServerOptions { + readonly host?: string; + readonly port?: number; + readonly query?: string; + readonly punycode?: boolean; +} + +export function createWhoisServerOptions ( + host?: string, + port?: number, + query?: string, + punycode?: boolean +): WhoisServerOptions { + return { + host, + port, + query, + punycode + }; +} + +export function isWhoisServerOptions (value: any): value is WhoisServerOptions { + return ( + isRegularObject(value) + && hasNoOtherKeys(value, [ + 'host', + 'port', + 'query', + 'punycode', + ]) + && isStringOrUndefined(value?.host) + && isNumberOrUndefined(value?.port) + && isStringOrUndefined(value?.query) + && isBooleanOrUndefined(value?.punycode) + ); +} + +export function explainWhoisServerOptions (value: any): string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'host', + 'port', + 'query', + 'punycode', + ]), + explainProperty("host", explainStringOrUndefined(value?.host)), + explainProperty("port", explainNumberOrUndefined(value?.port)), + explainProperty("query", explainStringOrUndefined(value?.query)), + explainProperty("punycode", explainBooleanOrUndefined(value?.punycode)) + ] + ); +} + +export function stringifyWhoisServerOptions (value: WhoisServerOptions): string { + if ( !isWhoisServerOptions(value) ) throw new TypeError(`Not WhoisServerOptions: ${value}`); + return `WhoisServerOptions(${value})`; +} + +export function parseWhoisServerOptions (value: any): WhoisServerOptions | undefined { + if ( isWhoisServerOptions(value) ) return value; + return undefined; +} diff --git a/wordpress/WpClient.ts b/wordpress/WpClient.ts new file mode 100644 index 0000000..41e1177 --- /dev/null +++ b/wordpress/WpClient.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { LogLevel } from "../types/LogLevel"; +import { LogService } from "../LogService"; +import { HttpService } from "../HttpService"; +import { explainWpPageListDTO, isWpPageListDTO, WpPageListDTO } from "./dto/WpPageListDTO"; +import { explainWpPostListDTO, isWpPostListDTO, WpPostListDTO } from "./dto/WpPostListDTO"; +import { explainWpReferenceListDTO, isWpReferenceListDTO, WpReferenceListDTO } from "./dto/WpReferenceListDTO"; +import { explainWpUserProfileListDTO, isWpUserProfileListDTO, WpUserProfileListDTO } from "./dto/WpUserProfileListDTO"; +import { + WORD_PRESS_API_V2_PAGES, + WORD_PRESS_API_V2_POSTS, + WORD_PRESS_API_V3_REFERENCES, + WORD_PRESS_API_V3_USERPROFILES +} from "./wordpress-api"; + +const LOG = LogService.createLogger('WpClient'); + +export class WpClient { + + public static setLogLevel(level: LogLevel) { + LOG.setLogLevel(level); + HttpService.setLogLevel(level); + } + + + private static _defaultUrl: string = '/'; + + private readonly _url: string; + + + public static setDefaultUrl(url: string) { + this._defaultUrl = url; + } + + public static getDefaultUrl(): string { + return this._defaultUrl; + } + + public static create( + url: string = WpClient._defaultUrl + ): WpClient { + return new WpClient(url); + } + + public constructor( + url: string = WpClient._defaultUrl, + ) { + this._url = url; + } + + /** + * Fetches 100 pages from /wp-json/wp/v2/pages?per_page=100 + */ + public async getPages (): Promise { + if (this._url.length < 1) return []; + const result = await HttpService.getJson(`${this._url}${WORD_PRESS_API_V2_PAGES}`); + if (!isWpPageListDTO(result)) { + LOG.debug(`getPages: result = `, result); + throw new TypeError(`Result was not WpPageListDTO: ${explainWpPageListDTO(result)}`); + } + return result; + } + + /** + * Fetches 100 posts from /wp-json/wp/v2/posts?per_page=100 + */ + public async getPosts (): Promise { + if (this._url.length < 1) return []; + const result = await HttpService.getJson(`${this._url}${WORD_PRESS_API_V2_POSTS}`); + if (!isWpPostListDTO(result)) { + LOG.debug(`getPosts: result = `, result); + throw new TypeError(`Result was not WpPostListDTO: ${explainWpPostListDTO(result)}`); + } + return result; + } + + /** + * Fetches references from /wp-json/wp/v3/references + */ + public async getReferences (): Promise { + if (this._url.length < 1) return []; + const result = await HttpService.getJson(`${this._url}${WORD_PRESS_API_V3_REFERENCES}`); + if (!isWpReferenceListDTO(result)) { + LOG.debug(`getReferences: result = `, result); + throw new TypeError(`Result was not WpReferenceListDTO: ${explainWpReferenceListDTO(result)}`); + } + return result; + } + + /** + * Fetches user profiles from /wp-json/wp/v3/userprofiles + */ + public async getUserProfiles (): Promise { + if (this._url.length < 1) return []; + const result = await HttpService.getJson(`${this._url}${WORD_PRESS_API_V3_USERPROFILES}`); + if (!isWpUserProfileListDTO(result)) { + LOG.debug(`getUserProfiles: result = `, result); + throw new TypeError(`Result was not WpUserProfileListDTO: ${explainWpUserProfileListDTO(result)}`); + } + return result; + } + +} diff --git a/wordpress/dto/WpImageDTO.test.ts b/wordpress/dto/WpImageDTO.test.ts new file mode 100644 index 0000000..4a9efdf --- /dev/null +++ b/wordpress/dto/WpImageDTO.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpImageDTO, isWpImageDTO } from "./WpImageDTO"; +import { explainOk } from "../../types/explain"; + +describe('WpImageDTO', () => { + + describe('isWpImageDTO', () => { + + it('can test valid DTO without image', () => { + expect( isWpImageDTO( + { + "thumbnail": false, + "medium": false, + "large": false + } + ) ).toBe(true); + }); + + it('can test valid DTO with URLs', () => { + expect( isWpImageDTO( + { + "thumbnail": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva-150x150.jpeg", + "medium": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva-300x300.jpeg", + "large": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva.jpeg" + } + ) ).toBe(true); + }); + + }); + + describe('explainWpImageDTO', () => { + + it('can explain valid DTO without image', () => { + expect( explainWpImageDTO( + { + "thumbnail": false, + "medium": false, + "large": false + } + ) ).toBe(explainOk()); + }); + + it('can explain valid DTO with URLs', () => { + expect( explainWpImageDTO( + { + "thumbnail": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva-150x150.jpeg", + "medium": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva-300x300.jpeg", + "large": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva.jpeg" + } + ) ).toBe(explainOk()); + }); + + }); + +}); diff --git a/wordpress/dto/WpImageDTO.ts b/wordpress/dto/WpImageDTO.ts new file mode 100644 index 0000000..2cad599 --- /dev/null +++ b/wordpress/dto/WpImageDTO.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainStringOrFalse, isStringOrFalse } from "../../types/String"; +import { explain, explainProperty } from "../../types/explain"; + +/** + * This is the image object used in the /wp-json/wp/v3/userprofiles API for featured image + */ +export interface WpImageDTO { + readonly thumbnail : string | false; + readonly medium : string | false; + readonly large : string | false; +} + +export function isWpImageDTO (value: unknown) : value is WpImageDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'thumbnail', + 'medium', + 'large' + ]) + && isStringOrFalse(value?.thumbnail) + && isStringOrFalse(value?.medium) + && isStringOrFalse(value?.large) + ); +} + +export function explainWpImageDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'thumbnail', + 'medium', + 'large' + ]) + , explainProperty("thumbnail", explainStringOrFalse(value?.thumbnail)) + , explainProperty("medium", explainStringOrFalse(value?.medium)) + , explainProperty("large", explainStringOrFalse(value?.large)) + ] + ); +} + +export function stringifyWpImageDTO (value : WpImageDTO) : string { + return `WpImageDTO(${value})`; +} + +export function parseWpImageDTO (value: unknown) : WpImageDTO | undefined { + if (isWpImageDTO(value)) return value; + return undefined; +} diff --git a/wordpress/dto/WpPageDTO.test.ts b/wordpress/dto/WpPageDTO.test.ts new file mode 100644 index 0000000..48d5dc2 --- /dev/null +++ b/wordpress/dto/WpPageDTO.test.ts @@ -0,0 +1,422 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpPageDTO, isWpPageDTO } from "./WpPageDTO"; +import { explainOk } from "../../types/explain"; + +const TEST_DATA_PAGES = [ + { + "id": 298, + "date": "2023-02-14T09:03:25", + "date_gmt": "2023-02-14T07:03:25", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=298"}, + "modified": "2023-02-26T08:47:13", + "modified_gmt": "2023-02-26T06:47:13", + "slug": "en-contact-text", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-contact-text\/", + "title": {"rendered": "Contact Us"}, + "content": { + "rendered": "

Contact Us<\/h1>\n

Heusala Group Ltd<\/h3>\n

Business ID: 3091818-9<\/p>\n

Heusala Group Ltd
\nAleksis Kiven katu 11 B 29
\n33100 Tampere
\nFinland<\/p>\n

tel. 010 517 50 70<\/a>
\n
info@heusalagroup.fi<\/a><\/p>\n

 <\/p>\n

\"\"<\/a> \"\"<\/a> \"\"<\/a><\/p>\n", + "protected": false + }, + "excerpt": {"rendered": "

Contact Us Heusala Group Ltd Business ID: 3091818-9 Heusala Group Ltd Aleksis Kiven katu 11 B 29 33100 Tampere Finland tel. 010 517 50 70 info@heusalagroup.fi  <\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 165, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/298"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=298"} ], + "version-history": [ {"count": 13, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/298\/revisions"} ], + "predecessor-version": [ {"id": 354, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/298\/revisions\/354"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/165"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=298"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 296, + "date": "2023-02-14T09:02:01", + "date_gmt": "2023-02-14T07:02:01", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=296"}, + "modified": "2023-02-26T08:46:05", + "modified_gmt": "2023-02-26T06:46:05", + "slug": "fi-contact-text", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-contact-text\/", + "title": {"rendered": "Ota yhteytt\u00e4"}, + "content": { + "rendered": "

Ota yhteytt\u00e4<\/h1>\n

Heusala Group Oy<\/h3>\n

Y-tunnus: 3091818-9<\/p>\n

Heusala Group Oy
\nAleksis Kiven katu 11 B 29
\n33100 Tampere<\/p>\n

p. 010 517 50 70<\/a>
\n
info@heusalagroup.fi<\/a><\/p>\n

 <\/p>\n

\"\"<\/a> \"\"<\/a> \"\"<\/a><\/p>\n", + "protected": false + }, + "excerpt": {"rendered": "

Ota yhteytt\u00e4 Heusala Group Oy Y-tunnus: 3091818-9 Heusala Group Oy Aleksis Kiven katu 11 B 29 33100 Tampere p. 010 517 50 70 info@heusalagroup.fi  <\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 163, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/296"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=296"} ], + "version-history": [ {"count": 4, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/296\/revisions"} ], + "predecessor-version": [ {"id": 353, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/296\/revisions\/353"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/163"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=296"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 276, + "date": "2023-02-08T19:24:39", + "date_gmt": "2023-02-08T17:24:39", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=276"}, + "modified": "2023-02-08T19:24:39", + "modified_gmt": "2023-02-08T17:24:39", + "slug": "en-index-introduction-button-about-us", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-index\/en-index-introduction\/en-index-introduction-button-about-us\/", + "title": {"rendered": "We are a software company specialized in web technology – Read more about us"}, + "content": {"rendered": "

Read more about us<\/p>\n", "protected": false}, + "excerpt": {"rendered": "

Read more about us<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 161, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/276"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=276"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/276\/revisions"} ], + "predecessor-version": [ {"id": 277, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/276\/revisions\/277"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/161"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=276"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 274, + "date": "2023-02-08T19:22:35", + "date_gmt": "2023-02-08T17:22:35", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=274"}, + "modified": "2023-02-08T19:23:06", + "modified_gmt": "2023-02-08T17:23:06", + "slug": "en-index-introduction-button-references", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-index\/en-index-introduction\/en-index-introduction-button-references\/", + "title": {"rendered": "We are a software company specialized in web technology – Review our references"}, + "content": {"rendered": "

Review our references<\/p>\n", "protected": false}, + "excerpt": {"rendered": "

Review our references<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 161, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/274"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=274"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/274\/revisions"} ], + "predecessor-version": [ {"id": 275, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/274\/revisions\/275"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/161"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=274"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 272, + "date": "2023-02-08T19:19:04", + "date_gmt": "2023-02-08T17:19:04", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=272"}, + "modified": "2023-02-10T08:36:36", + "modified_gmt": "2023-02-10T06:36:36", + "slug": "fi-index-introduction-button-references", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-index\/fi-index-introduction\/fi-index-introduction-button-references\/", + "title": {"rendered": "Olemme web-teknologiaan erikoistunut ohjelmistotalo – Katso referenssit"}, + "content": {"rendered": "

Katso referenssit<\/p>\n", "protected": false}, + "excerpt": {"rendered": "

Katso referenssit<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 147, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/272"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=272"} ], + "version-history": [ {"count": 3, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/272\/revisions"} ], + "predecessor-version": [ {"id": 283, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/272\/revisions\/283"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/147"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=272"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 270, + "date": "2023-02-08T19:16:35", + "date_gmt": "2023-02-08T17:16:35", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=270"}, + "modified": "2023-02-08T19:25:11", + "modified_gmt": "2023-02-08T17:25:11", + "slug": "fi-index-introduction-button-about-us", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-index\/fi-index-introduction\/fi-index-introduction-button-about-us\/", + "title": {"rendered": "Olemme web-teknologiaan erikoistunut ohjelmistotalo – Lue lis\u00e4\u00e4 meist\u00e4"}, + "content": {"rendered": "

Lue lis\u00e4\u00e4 meist\u00e4<\/p>\n", "protected": false}, + "excerpt": {"rendered": "

Lue lis\u00e4\u00e4 meist\u00e4<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 147, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/270"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=270"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/270\/revisions"} ], + "predecessor-version": [ {"id": 271, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/270\/revisions\/271"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/147"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=270"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 268, + "date": "2023-02-07T13:56:15", + "date_gmt": "2023-02-07T11:56:15", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=268"}, + "modified": "2023-02-09T14:38:03", + "modified_gmt": "2023-02-09T12:38:03", + "slug": "fi-index-references-projects-link", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-index\/fi-index-references\/fi-index-references-projects-link\/", + "title": {"rendered": "Referenssit – Tutustu vapaan l\u00e4hdekoodin projekteihimme t\u00e4st\u00e4!"}, + "content": {"rendered": "

Tutustu vapaan l\u00e4hdekoodin projekteihimme t\u00e4st\u00e4!<\/h3>\n", "protected": false}, + "excerpt": {"rendered": "

Tutustu vapaan l\u00e4hdekoodin projekteihimme t\u00e4st\u00e4!<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 179, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/268"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=268"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/268\/revisions"} ], + "predecessor-version": [ {"id": 269, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/268\/revisions\/269"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/179"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=268"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 265, + "date": "2023-02-07T13:51:01", + "date_gmt": "2023-02-07T11:51:01", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=265"}, + "modified": "2023-02-09T14:37:18", + "modified_gmt": "2023-02-09T12:37:18", + "slug": "en-index-references-projects-link", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-index\/en-index-references\/en-index-references-projects-link\/", + "title": {"rendered": "References – Check our open source projects here!"}, + "content": {"rendered": "

Check our open source projects here!<\/h3>\n", "protected": false}, + "excerpt": {"rendered": "

Check our open source projects here!<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 181, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/265"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=265"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/265\/revisions"} ], + "predecessor-version": [ {"id": 266, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/265\/revisions\/266"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/181"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=265"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 246, + "date": "2023-02-05T16:19:28", + "date_gmt": "2023-02-05T14:19:28", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=246"}, + "modified": "2023-02-15T10:49:40", + "modified_gmt": "2023-02-15T08:49:40", + "slug": "fi-about-about-us", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-about\/fi-about-about-us\/", + "title": {"rendered": "Meist\u00e4 – Meist\u00e4"}, + "content": { + "rendered": "

Olemme web-teknologiaan erikoistunut ohjelmistotalo. Haluamme auttaa yrityksi\u00e4 digitaalisen tulevaisuuden tuomien ongelmien ratkaisuissa. Nykyaikana yritysten t\u00e4ytyy p\u00e4\u00e4st\u00e4 tietoihiinsa ymp\u00e4ri vuorokauden ja mist\u00e4 p\u00e4in maailmaa tahansa. Web-pohjaisten sovellusten my\u00f6t\u00e4 t\u00e4m\u00e4 on mahdollista. Usein tarjolla olevat ratkaisut ovat liian laajoja tai joustamattomia. Meill\u00e4 kuuntelemme asiakasta ja r\u00e4\u00e4t\u00e4l\u00f6imme ratkaisut toiveiden mukaisesti ilman turhuuksia. N\u00e4in varmistamme, ett\u00e4 tuotteemme toteutetaan kustannustehokkaasti ja asiakkaan liiketoimintaa palvellen.<\/p>\r\n\r\n\r\n\r\n

Tarjoamme ketter\u00e4t ja luotettavat ratkaisut alustasta riippumatta. Tekniikoihin, joita k\u00e4yt\u00e4mme sis\u00e4ltyy mm. ReactJS, TypeScript, NodeJS, Docker ja GitHub.<\/p>\r\n\r\n\r\n\r\n

T\u00e4m\u00e4n lis\u00e4ksi tarjoamme testausta, tietoturvakonsultaatiota, UI\/UX-suunnitelua ja markkinoinnin optimointia.<\/p>\r\n\r\n\r\n\r\n

Meille avoin kommunikointi ja l\u00e4pin\u00e4kyvyys on ylpeyden aihe. K\u00e4yt\u00e4nn\u00f6ss\u00e4 se n\u00e4kyy mm. siten, ett\u00e4 emme suosi suljetun l\u00e4hdekoodin tekniikoita ja j\u00e4t\u00e4 asiakasta tulevaisuudessa pulaan.<\/p>", + "protected": false + }, + "excerpt": { + "rendered": "

Olemme web-teknologiaan erikoistunut ohjelmistotalo. Haluamme auttaa yrityksi\u00e4 digitaalisen tulevaisuuden tuomien ongelmien ratkaisuissa. Nykyaikana yritysten t\u00e4ytyy p\u00e4\u00e4st\u00e4 tietoihiinsa ymp\u00e4ri vuorokauden ja mist\u00e4 p\u00e4in maailmaa tahansa. Web-pohjaisten sovellusten my\u00f6t\u00e4 t\u00e4m\u00e4 on mahdollista. Usein tarjolla olevat ratkaisut ovat liian laajoja tai joustamattomia. Meill\u00e4 kuuntelemme asiakasta ja r\u00e4\u00e4t\u00e4l\u00f6imme ratkaisut toiveiden mukaisesti ilman turhuuksia. N\u00e4in varmistamme, ett\u00e4 tuotteemme toteutetaan kustannustehokkaasti […]<\/p>\n", + "protected": false + }, + "author": 9, + "featured_media": 0, + "parent": 155, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/246"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=246"} ], + "version-history": [ {"count": 3, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/246\/revisions"} ], + "predecessor-version": [ {"id": 306, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/246\/revisions\/306"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/155"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=246"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 239, + "date": "2023-02-02T13:24:24", + "date_gmt": "2023-02-02T11:24:24", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=239"}, + "modified": "2023-02-09T14:35:30", + "modified_gmt": "2023-02-09T12:35:30", + "slug": "en-about-services-marketing", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-about\/en-about-services\/en-about-services-marketing\/", + "title": {"rendered": "About Us – Services – Marketing Optimization"}, + "content": { + "rendered": "

ENGLANNIKSI:<\/p>\r\n

Marketing Optimization<\/h3>\r\n\r\n\r\n\r\n

Markkinoinnin optimointi on oiva tapa lis\u00e4t\u00e4 n\u00e4kyvyytt\u00e4 ja sit\u00e4 kautta my\u00f6s myynti\u00e4. Hakukoneoptimointi on yleisin tapa lis\u00e4t\u00e4 yrityksen n\u00e4kyvyytt\u00e4, kun potentiaalinen asiakas hakee tietoa internetist\u00e4. Toinen tapa on Google-mainonta, jossa hakukone n\u00e4ytt\u00e4\u00e4 yrityksen mainoksia. Mainosten klikkauksia pystyy seuraamaan ja klikkausten analysoinnilla mainokset voi kohdistaa oikein.<\/p>", + "protected": false + }, + "excerpt": { + "rendered": "

ENGLANNIKSI: Marketing Optimization Markkinoinnin optimointi on oiva tapa lis\u00e4t\u00e4 n\u00e4kyvyytt\u00e4 ja sit\u00e4 kautta my\u00f6s myynti\u00e4. Hakukoneoptimointi on yleisin tapa lis\u00e4t\u00e4 yrityksen n\u00e4kyvyytt\u00e4, kun potentiaalinen asiakas hakee tietoa internetist\u00e4. Toinen tapa on Google-mainonta, jossa hakukone n\u00e4ytt\u00e4\u00e4 yrityksen mainoksia. Mainosten klikkauksia pystyy seuraamaan ja klikkausten analysoinnilla mainokset voi kohdistaa oikein.<\/p>\n", + "protected": false + }, + "author": 9, + "featured_media": 0, + "parent": 208, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/239"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=239"} ], + "version-history": [ {"count": 2, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/239\/revisions"} ], + "predecessor-version": [ {"id": 261, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/239\/revisions\/261"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/208"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=239"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + } +]; + +describe('WpPageDTO', () => { + + describe('isWpPageDTO', () => { + + it('can test valid pages', () => { + expect( isWpPageDTO(TEST_DATA_PAGES[0]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[1]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[2]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[3]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[4]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[5]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[6]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[7]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[8]) ).toBe(true); + expect( isWpPageDTO(TEST_DATA_PAGES[9]) ).toBe(true); + }); + + it('can test invalid pages', () => { + expect( isWpPageDTO(null) ).toBe(false); + expect( isWpPageDTO(123) ).toBe(false); + expect( isWpPageDTO({}) ).toBe(false); + expect( isWpPageDTO([]) ).toBe(false); + expect( isWpPageDTO("hello") ).toBe(false); + expect( isWpPageDTO(false) ).toBe(false); + expect( isWpPageDTO(undefined) ).toBe(false); + expect( isWpPageDTO(true) ).toBe(false); + }); + + }); + + describe('isWpPageDTO', () => { + it('can explain valid pages', () => { + expect( explainWpPageDTO(TEST_DATA_PAGES[0]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[1]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[2]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[3]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[4]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[5]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[6]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[7]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[8]) ).toBe(explainOk()); + expect( explainWpPageDTO(TEST_DATA_PAGES[9]) ).toBe(explainOk()); + }); + }); + +}); diff --git a/wordpress/dto/WpPageDTO.ts b/wordpress/dto/WpPageDTO.ts new file mode 100644 index 0000000..cb43bdf --- /dev/null +++ b/wordpress/dto/WpPageDTO.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpPageStatus, isWpPageStatus, WpPageStatus } from "./WpPageStatus"; +import { explainString, explainStringOrNull, isString, isStringOrNull } from "../../types/String"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainReadonlyJsonArray, explainReadonlyJsonObject, isReadonlyJsonArray, isReadonlyJsonObject, ReadonlyJsonArray, ReadonlyJsonObject } from "../../Json"; +import { explain, explainProperty } from "../../types/explain"; +import { explainWpRenderedDTO, isWpRenderedDTO, WpRenderedDTO } from "./WpRenderedDTO"; +import { explainNumber, isNumber } from "../../types/Number"; + +/** + * Wordpress API page object for /wp-json/wp/v2/pages + */ +export interface WpPageDTO { + readonly title : WpRenderedDTO; + readonly content : WpRenderedDTO; + readonly excerpt : WpRenderedDTO; + readonly guid : WpRenderedDTO; + readonly type : string; + readonly id : number; + readonly modified : string; + readonly modified_gmt : string; + readonly date : string | null; + readonly date_gmt : string | null; + readonly status : WpPageStatus; + readonly parent : number; + readonly author : number; + readonly featured_media : number; + readonly comment_status : string; + readonly ping_status : string; + readonly menu_order : number; + readonly meta : ReadonlyJsonArray; + readonly template : string; + readonly link : string; + readonly slug : string; + /** + * @fixme Add correct typing before using this property! + * @deprecated (just so that IDE highlights and you read above comment) + */ + readonly _links : ReadonlyJsonObject; + +} + +export function isWpPageDTO (value:any): value is WpPageDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'title', + 'content', + 'excerpt', + 'guid', + 'type', + 'id', + 'modified', + 'modified_gmt', + 'date', + 'date_gmt', + 'status', + 'parent', + 'author', + 'featured_media', + 'comment_status', + 'ping_status', + 'menu_order', + 'meta', + 'template', + 'link', + 'slug', + '_links' + ]) + && isWpRenderedDTO(value?.title) + && isWpRenderedDTO(value?.content) + && isWpRenderedDTO(value?.excerpt) + && isWpRenderedDTO(value?.guid) + && isString(value?.type) + && isNumber(value?.id) + && isString(value?.modified) + && isString(value?.modified_gmt) + && isStringOrNull(value?.date) + && isStringOrNull(value?.date_gmt) + && isWpPageStatus(value?.status) + && isNumber(value?.parent) + && isNumber(value?.author) + && isNumber(value?.featured_media) + && isString(value?.comment_status) + && isString(value?.ping_status) + && isNumber(value?.menu_order) + && isReadonlyJsonArray(value?.meta) + && isString(value?.template) + && isString(value?.link) + && isString(value?.slug) + && isReadonlyJsonObject(value?._links) + ) +} + +export function explainWpPageDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'title', + 'content', + 'excerpt', + 'guid', + 'type', + 'id', + 'modified', + 'modified_gmt', + 'date', + 'date_gmt', + 'status', + 'parent', + 'author', + 'featured_media', + 'comment_status', + 'ping_status', + 'menu_order', + 'meta', + 'template', + 'link', + 'slug', + '_links' + ]) + , explainProperty("title", explainWpRenderedDTO(value?.title)) + , explainProperty("content", explainWpRenderedDTO(value?.content)) + , explainProperty("excerpt", explainWpRenderedDTO(value?.excerpt)) + , explainProperty("guid", explainWpRenderedDTO(value?.guid)) + , explainProperty("type", explainString(value?.type)) + , explainProperty("id", explainNumber(value?.id)) + , explainProperty("modified", explainStringOrNull(value?.modified)) + , explainProperty("modified_gmt", explainStringOrNull(value?.modified_gmt)) + , explainProperty("date", explainStringOrNull(value?.date)) + , explainProperty("date_gmt", explainStringOrNull(value?.date_gmt)) + , explainProperty("status", explainWpPageStatus(value?.status)) + , explainProperty("parent", explainNumber(value?.parent)) + , explainProperty("author", explainNumber(value?.author)) + , explainProperty("featured_media", explainNumber(value?.featured_media)) + , explainProperty("comment_status", explainString(value?.comment_status)) + , explainProperty("ping_status", explainString(value?.ping_status)) + , explainProperty("menu_order", explainNumber(value?.menu_order)) + , explainProperty("meta", explainReadonlyJsonArray(value?.meta)) + , explainProperty("template", explainString(value?.template)) + , explainProperty("link", explainString(value?.link)) + , explainProperty("slug", explainString(value?.slug)) + , explainProperty("_links", explainReadonlyJsonObject(value?._links)) + ] + ); +} diff --git a/wordpress/dto/WpPageListDTO.test.ts b/wordpress/dto/WpPageListDTO.test.ts new file mode 100644 index 0000000..1e94420 --- /dev/null +++ b/wordpress/dto/WpPageListDTO.test.ts @@ -0,0 +1,382 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpPageListDTO, isWpPageListDTO } from "./WpPageListDTO"; +import { explainOk } from "../../types/explain"; + +const TEST_DATA_PAGES = [ + { + "id": 298, + "date": "2023-02-14T09:03:25", + "date_gmt": "2023-02-14T07:03:25", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=298"}, + "modified": "2023-02-26T08:47:13", + "modified_gmt": "2023-02-26T06:47:13", + "slug": "en-contact-text", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-contact-text\/", + "title": {"rendered": "Contact Us"}, + "content": { + "rendered": "

Contact Us<\/h1>\n

Heusala Group Ltd<\/h3>\n

Business ID: 3091818-9<\/p>\n

Heusala Group Ltd
\nAleksis Kiven katu 11 B 29
\n33100 Tampere
\nFinland<\/p>\n

tel. 010 517 50 70<\/a>
\n
info@heusalagroup.fi<\/a><\/p>\n

 <\/p>\n

\"\"<\/a> \"\"<\/a> \"\"<\/a><\/p>\n", + "protected": false + }, + "excerpt": {"rendered": "

Contact Us Heusala Group Ltd Business ID: 3091818-9 Heusala Group Ltd Aleksis Kiven katu 11 B 29 33100 Tampere Finland tel. 010 517 50 70 info@heusalagroup.fi  <\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 165, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/298"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=298"} ], + "version-history": [ {"count": 13, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/298\/revisions"} ], + "predecessor-version": [ {"id": 354, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/298\/revisions\/354"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/165"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=298"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 296, + "date": "2023-02-14T09:02:01", + "date_gmt": "2023-02-14T07:02:01", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=296"}, + "modified": "2023-02-26T08:46:05", + "modified_gmt": "2023-02-26T06:46:05", + "slug": "fi-contact-text", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-contact-text\/", + "title": {"rendered": "Ota yhteytt\u00e4"}, + "content": { + "rendered": "

Ota yhteytt\u00e4<\/h1>\n

Heusala Group Oy<\/h3>\n

Y-tunnus: 3091818-9<\/p>\n

Heusala Group Oy
\nAleksis Kiven katu 11 B 29
\n33100 Tampere<\/p>\n

p. 010 517 50 70<\/a>
\n
info@heusalagroup.fi<\/a><\/p>\n

 <\/p>\n

\"\"<\/a> \"\"<\/a> \"\"<\/a><\/p>\n", + "protected": false + }, + "excerpt": {"rendered": "

Ota yhteytt\u00e4 Heusala Group Oy Y-tunnus: 3091818-9 Heusala Group Oy Aleksis Kiven katu 11 B 29 33100 Tampere p. 010 517 50 70 info@heusalagroup.fi  <\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 163, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/296"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=296"} ], + "version-history": [ {"count": 4, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/296\/revisions"} ], + "predecessor-version": [ {"id": 353, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/296\/revisions\/353"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/163"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=296"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 276, + "date": "2023-02-08T19:24:39", + "date_gmt": "2023-02-08T17:24:39", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=276"}, + "modified": "2023-02-08T19:24:39", + "modified_gmt": "2023-02-08T17:24:39", + "slug": "en-index-introduction-button-about-us", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-index\/en-index-introduction\/en-index-introduction-button-about-us\/", + "title": {"rendered": "We are a software company specialized in web technology – Read more about us"}, + "content": {"rendered": "

Read more about us<\/p>\n", "protected": false}, + "excerpt": {"rendered": "

Read more about us<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 161, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/276"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=276"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/276\/revisions"} ], + "predecessor-version": [ {"id": 277, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/276\/revisions\/277"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/161"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=276"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 274, + "date": "2023-02-08T19:22:35", + "date_gmt": "2023-02-08T17:22:35", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=274"}, + "modified": "2023-02-08T19:23:06", + "modified_gmt": "2023-02-08T17:23:06", + "slug": "en-index-introduction-button-references", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-index\/en-index-introduction\/en-index-introduction-button-references\/", + "title": {"rendered": "We are a software company specialized in web technology – Review our references"}, + "content": {"rendered": "

Review our references<\/p>\n", "protected": false}, + "excerpt": {"rendered": "

Review our references<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 161, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/274"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=274"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/274\/revisions"} ], + "predecessor-version": [ {"id": 275, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/274\/revisions\/275"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/161"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=274"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 272, + "date": "2023-02-08T19:19:04", + "date_gmt": "2023-02-08T17:19:04", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=272"}, + "modified": "2023-02-10T08:36:36", + "modified_gmt": "2023-02-10T06:36:36", + "slug": "fi-index-introduction-button-references", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-index\/fi-index-introduction\/fi-index-introduction-button-references\/", + "title": {"rendered": "Olemme web-teknologiaan erikoistunut ohjelmistotalo – Katso referenssit"}, + "content": {"rendered": "

Katso referenssit<\/p>\n", "protected": false}, + "excerpt": {"rendered": "

Katso referenssit<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 147, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/272"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=272"} ], + "version-history": [ {"count": 3, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/272\/revisions"} ], + "predecessor-version": [ {"id": 283, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/272\/revisions\/283"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/147"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=272"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 270, + "date": "2023-02-08T19:16:35", + "date_gmt": "2023-02-08T17:16:35", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=270"}, + "modified": "2023-02-08T19:25:11", + "modified_gmt": "2023-02-08T17:25:11", + "slug": "fi-index-introduction-button-about-us", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-index\/fi-index-introduction\/fi-index-introduction-button-about-us\/", + "title": {"rendered": "Olemme web-teknologiaan erikoistunut ohjelmistotalo – Lue lis\u00e4\u00e4 meist\u00e4"}, + "content": {"rendered": "

Lue lis\u00e4\u00e4 meist\u00e4<\/p>\n", "protected": false}, + "excerpt": {"rendered": "

Lue lis\u00e4\u00e4 meist\u00e4<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 147, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/270"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=270"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/270\/revisions"} ], + "predecessor-version": [ {"id": 271, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/270\/revisions\/271"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/147"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=270"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 268, + "date": "2023-02-07T13:56:15", + "date_gmt": "2023-02-07T11:56:15", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=268"}, + "modified": "2023-02-09T14:38:03", + "modified_gmt": "2023-02-09T12:38:03", + "slug": "fi-index-references-projects-link", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-index\/fi-index-references\/fi-index-references-projects-link\/", + "title": {"rendered": "Referenssit – Tutustu vapaan l\u00e4hdekoodin projekteihimme t\u00e4st\u00e4!"}, + "content": {"rendered": "

Tutustu vapaan l\u00e4hdekoodin projekteihimme t\u00e4st\u00e4!<\/h3>\n", "protected": false}, + "excerpt": {"rendered": "

Tutustu vapaan l\u00e4hdekoodin projekteihimme t\u00e4st\u00e4!<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 179, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/268"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=268"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/268\/revisions"} ], + "predecessor-version": [ {"id": 269, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/268\/revisions\/269"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/179"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=268"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 265, + "date": "2023-02-07T13:51:01", + "date_gmt": "2023-02-07T11:51:01", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=265"}, + "modified": "2023-02-09T14:37:18", + "modified_gmt": "2023-02-09T12:37:18", + "slug": "en-index-references-projects-link", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-index\/en-index-references\/en-index-references-projects-link\/", + "title": {"rendered": "References – Check our open source projects here!"}, + "content": {"rendered": "

Check our open source projects here!<\/h3>\n", "protected": false}, + "excerpt": {"rendered": "

Check our open source projects here!<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "parent": 181, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/265"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=265"} ], + "version-history": [ {"count": 1, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/265\/revisions"} ], + "predecessor-version": [ {"id": 266, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/265\/revisions\/266"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/181"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=265"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 246, + "date": "2023-02-05T16:19:28", + "date_gmt": "2023-02-05T14:19:28", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=246"}, + "modified": "2023-02-15T10:49:40", + "modified_gmt": "2023-02-15T08:49:40", + "slug": "fi-about-about-us", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/fi\/fi-about\/fi-about-about-us\/", + "title": {"rendered": "Meist\u00e4 – Meist\u00e4"}, + "content": { + "rendered": "

Olemme web-teknologiaan erikoistunut ohjelmistotalo. Haluamme auttaa yrityksi\u00e4 digitaalisen tulevaisuuden tuomien ongelmien ratkaisuissa. Nykyaikana yritysten t\u00e4ytyy p\u00e4\u00e4st\u00e4 tietoihiinsa ymp\u00e4ri vuorokauden ja mist\u00e4 p\u00e4in maailmaa tahansa. Web-pohjaisten sovellusten my\u00f6t\u00e4 t\u00e4m\u00e4 on mahdollista. Usein tarjolla olevat ratkaisut ovat liian laajoja tai joustamattomia. Meill\u00e4 kuuntelemme asiakasta ja r\u00e4\u00e4t\u00e4l\u00f6imme ratkaisut toiveiden mukaisesti ilman turhuuksia. N\u00e4in varmistamme, ett\u00e4 tuotteemme toteutetaan kustannustehokkaasti ja asiakkaan liiketoimintaa palvellen.<\/p>\r\n\r\n\r\n\r\n

Tarjoamme ketter\u00e4t ja luotettavat ratkaisut alustasta riippumatta. Tekniikoihin, joita k\u00e4yt\u00e4mme sis\u00e4ltyy mm. ReactJS, TypeScript, NodeJS, Docker ja GitHub.<\/p>\r\n\r\n\r\n\r\n

T\u00e4m\u00e4n lis\u00e4ksi tarjoamme testausta, tietoturvakonsultaatiota, UI\/UX-suunnitelua ja markkinoinnin optimointia.<\/p>\r\n\r\n\r\n\r\n

Meille avoin kommunikointi ja l\u00e4pin\u00e4kyvyys on ylpeyden aihe. K\u00e4yt\u00e4nn\u00f6ss\u00e4 se n\u00e4kyy mm. siten, ett\u00e4 emme suosi suljetun l\u00e4hdekoodin tekniikoita ja j\u00e4t\u00e4 asiakasta tulevaisuudessa pulaan.<\/p>", + "protected": false + }, + "excerpt": { + "rendered": "

Olemme web-teknologiaan erikoistunut ohjelmistotalo. Haluamme auttaa yrityksi\u00e4 digitaalisen tulevaisuuden tuomien ongelmien ratkaisuissa. Nykyaikana yritysten t\u00e4ytyy p\u00e4\u00e4st\u00e4 tietoihiinsa ymp\u00e4ri vuorokauden ja mist\u00e4 p\u00e4in maailmaa tahansa. Web-pohjaisten sovellusten my\u00f6t\u00e4 t\u00e4m\u00e4 on mahdollista. Usein tarjolla olevat ratkaisut ovat liian laajoja tai joustamattomia. Meill\u00e4 kuuntelemme asiakasta ja r\u00e4\u00e4t\u00e4l\u00f6imme ratkaisut toiveiden mukaisesti ilman turhuuksia. N\u00e4in varmistamme, ett\u00e4 tuotteemme toteutetaan kustannustehokkaasti […]<\/p>\n", + "protected": false + }, + "author": 9, + "featured_media": 0, + "parent": 155, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/246"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=246"} ], + "version-history": [ {"count": 3, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/246\/revisions"} ], + "predecessor-version": [ {"id": 306, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/246\/revisions\/306"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/155"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=246"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, { + "id": 239, + "date": "2023-02-02T13:24:24", + "date_gmt": "2023-02-02T11:24:24", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?page_id=239"}, + "modified": "2023-02-09T14:35:30", + "modified_gmt": "2023-02-09T12:35:30", + "slug": "en-about-services-marketing", + "status": "publish", + "type": "page", + "link": "https:\/\/cms.hg.fi\/en\/en-about\/en-about-services\/en-about-services-marketing\/", + "title": {"rendered": "About Us – Services – Marketing Optimization"}, + "content": { + "rendered": "

ENGLANNIKSI:<\/p>\r\n

Marketing Optimization<\/h3>\r\n\r\n\r\n\r\n

Markkinoinnin optimointi on oiva tapa lis\u00e4t\u00e4 n\u00e4kyvyytt\u00e4 ja sit\u00e4 kautta my\u00f6s myynti\u00e4. Hakukoneoptimointi on yleisin tapa lis\u00e4t\u00e4 yrityksen n\u00e4kyvyytt\u00e4, kun potentiaalinen asiakas hakee tietoa internetist\u00e4. Toinen tapa on Google-mainonta, jossa hakukone n\u00e4ytt\u00e4\u00e4 yrityksen mainoksia. Mainosten klikkauksia pystyy seuraamaan ja klikkausten analysoinnilla mainokset voi kohdistaa oikein.<\/p>", + "protected": false + }, + "excerpt": { + "rendered": "

ENGLANNIKSI: Marketing Optimization Markkinoinnin optimointi on oiva tapa lis\u00e4t\u00e4 n\u00e4kyvyytt\u00e4 ja sit\u00e4 kautta my\u00f6s myynti\u00e4. Hakukoneoptimointi on yleisin tapa lis\u00e4t\u00e4 yrityksen n\u00e4kyvyytt\u00e4, kun potentiaalinen asiakas hakee tietoa internetist\u00e4. Toinen tapa on Google-mainonta, jossa hakukone n\u00e4ytt\u00e4\u00e4 yrityksen mainoksia. Mainosten klikkauksia pystyy seuraamaan ja klikkausten analysoinnilla mainokset voi kohdistaa oikein.<\/p>\n", + "protected": false + }, + "author": 9, + "featured_media": 0, + "parent": 208, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/239"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/page"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=239"} ], + "version-history": [ {"count": 2, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/239\/revisions"} ], + "predecessor-version": [ {"id": 261, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/239\/revisions\/261"} ], + "up": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/pages\/208"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=239"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + } +]; + +describe('WpPageListDTO', () => { + + describe('isWpPageListDTO', () => { + it('can test valid list', () => { + expect( isWpPageListDTO(TEST_DATA_PAGES) ).toBe(true); + }); + }); + + describe('explainWpPageListDTO', () => { + it('can explain valid list', () => { + expect( explainWpPageListDTO(TEST_DATA_PAGES) ).toBe(explainOk()); + }); + }); + +}); diff --git a/wordpress/dto/WpPageListDTO.ts b/wordpress/dto/WpPageListDTO.ts new file mode 100644 index 0000000..a6de583 --- /dev/null +++ b/wordpress/dto/WpPageListDTO.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainWpPageDTO, isWpPageDTO, WpPageDTO } from "./WpPageDTO"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; + +/** + * Wordpress API page object for /wp-json/wp/v2/pages + */ +export type WpPageListDTO = readonly WpPageDTO[]; + +/** + * Tests that the value is DTO for /wp-json/wp/v2/pages + */ +export function isWpPageListDTO (value: unknown): value is WpPageListDTO { + return isArrayOf(value, isWpPageDTO); +} + +export function explainWpPageListDTO (value: any): string { + return explainArrayOf("WpPageDTO", explainWpPageDTO, value, isWpPageDTO); +} + +export function stringifyWpPageDTO (value: WpPageListDTO): string { + return `WpPageListDTO(${value})`; +} + +export function parseWpPagesDTO (value: any): WpPageListDTO | undefined { + if ( isWpPageListDTO(value)) return value; + return undefined; +} \ No newline at end of file diff --git a/wordpress/dto/WpPageStatus.test.ts b/wordpress/dto/WpPageStatus.test.ts new file mode 100644 index 0000000..d05cac7 --- /dev/null +++ b/wordpress/dto/WpPageStatus.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { isWpPageStatus, parseWpPageStatus, WpPageStatus } from "./WpPageStatus"; + +describe('WpPageStatus', () => { + + describe('isWpPageStatus', () => { + + it('can test correct values', () => { + expect( isWpPageStatus('publish') ).toBe(true); + expect( isWpPageStatus('future') ).toBe(true); + expect( isWpPageStatus('draft') ).toBe(true); + expect( isWpPageStatus('pending') ).toBe(true); + expect( isWpPageStatus('private') ).toBe(true); + }); + + it('can test invalid values', () => { + expect( isWpPageStatus(null) ).toBe(false); + expect( isWpPageStatus(undefined) ).toBe(false); + expect( isWpPageStatus(123) ).toBe(false); + expect( isWpPageStatus('') ).toBe(false); + expect( isWpPageStatus(false) ).toBe(false); + expect( isWpPageStatus(true) ).toBe(false); + expect( isWpPageStatus('hello') ).toBe(false); + expect( isWpPageStatus('PUBLISH') ).toBe(false); + expect( isWpPageStatus('FUTURE') ).toBe(false); + expect( isWpPageStatus('DRAFT') ).toBe(false); + expect( isWpPageStatus('PENDING') ).toBe(false); + expect( isWpPageStatus('PRIVATE') ).toBe(false); + }); + + }); + + describe('parseWpPageStatus', () => { + + it('can parse correct values', () => { + expect( parseWpPageStatus('publish') ).toBe(WpPageStatus.PUBLISH); + expect( parseWpPageStatus('future') ).toBe(WpPageStatus.FUTURE); + expect( parseWpPageStatus('draft') ).toBe(WpPageStatus.DRAFT); + expect( parseWpPageStatus('pending') ).toBe(WpPageStatus.PENDING); + expect( parseWpPageStatus('private') ).toBe(WpPageStatus.PRIVATE); + expect( parseWpPageStatus('PUBLISH') ).toBe(WpPageStatus.PUBLISH); + expect( parseWpPageStatus('FUTURE') ).toBe(WpPageStatus.FUTURE); + expect( parseWpPageStatus('DRAFT') ).toBe(WpPageStatus.DRAFT); + expect( parseWpPageStatus('PENDING') ).toBe(WpPageStatus.PENDING); + expect( parseWpPageStatus('PRIVATE') ).toBe(WpPageStatus.PRIVATE); + }); + + it('can parse incorrect values as undefined', () => { + expect( parseWpPageStatus('hello') ).toBeUndefined(); + expect( parseWpPageStatus('') ).toBeUndefined(); + expect( parseWpPageStatus('123') ).toBeUndefined(); + expect( parseWpPageStatus(123) ).toBeUndefined(); + expect( parseWpPageStatus(null) ).toBeUndefined(); + expect( parseWpPageStatus(undefined) ).toBeUndefined(); + expect( parseWpPageStatus(false) ).toBeUndefined(); + expect( parseWpPageStatus(true) ).toBeUndefined(); + }); + + }); + +}); diff --git a/wordpress/dto/WpPageStatus.ts b/wordpress/dto/WpPageStatus.ts new file mode 100644 index 0000000..49743cc --- /dev/null +++ b/wordpress/dto/WpPageStatus.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainEnum } from "../../types/Enum"; + +export enum WpPageStatus { + PUBLISH = "publish", + FUTURE = "future", + DRAFT = "draft", + PENDING = "pending", + PRIVATE = "private" +} + +export function isWpPageStatus (value: unknown) : value is WpPageStatus { + switch (value) { + case WpPageStatus.PUBLISH: + case WpPageStatus.FUTURE: + case WpPageStatus.DRAFT: + case WpPageStatus.PENDING: + case WpPageStatus.PRIVATE: + return true; + default: + return false; + } +} + +export function explainWpPageStatus (value : unknown) : string { + return explainEnum("WpPageStatus", WpPageStatus, isWpPageStatus, value); +} + +export function stringifyWpPageStatus (value : WpPageStatus) : string { + switch (value) { + case WpPageStatus.PUBLISH : return 'PUBLISH'; + case WpPageStatus.FUTURE : return 'FUTURE'; + case WpPageStatus.DRAFT : return 'DRAFT'; + case WpPageStatus.PENDING : return 'PENDING'; + case WpPageStatus.PRIVATE : return 'PRIVATE'; + } + throw new TypeError(`Unsupported WordpressPageStatus value: ${value}`) +} + +export function parseWpPageStatus (value: unknown) : WpPageStatus | undefined { + if (value === undefined) return undefined; + switch(`${value}`.toUpperCase()) { + case 'PUBLISH' : return WpPageStatus.PUBLISH; + case 'FUTURE' : return WpPageStatus.FUTURE; + case 'DRAFT' : return WpPageStatus.DRAFT; + case 'PENDING' : return WpPageStatus.PENDING; + case 'PRIVATE' : return WpPageStatus.PRIVATE; + default : return undefined; + } +} diff --git a/wordpress/dto/WpPostDTO.test.ts b/wordpress/dto/WpPostDTO.test.ts new file mode 100644 index 0000000..902e797 --- /dev/null +++ b/wordpress/dto/WpPostDTO.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpPostDTO, isWpPostDTO } from "./WpPostDTO"; +import { explainOk } from "../../types/explain"; + +/** + * Test data from https://cms.hg.fi/wp-json/wp/v2/posts + */ +const TEST_DATA_POSTS = [ + { + "id": 287, + "date": "2022-12-31T14:18:38", + "date_gmt": "2022-12-31T12:18:38", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?p=287"}, + "modified": "2023-02-16T15:18:29", + "modified_gmt": "2023-02-16T13:18:29", + "slug": "31-12-2022", + "status": "publish", + "type": "post", + "link": "https:\/\/cms.hg.fi\/2022\/12\/31\/31-12-2022\/", + "title": {"rendered": "31.12.2022"}, + "content": {"rendered": "

31.12.2022<\/strong><\/p>\r\n\r\n\r\n\r\n

Happy New Year 2023!<\/p>", "protected": false}, + "excerpt": {"rendered": "

31.12.2022 Happy New Year 2023!<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "comment_status": "open", + "ping_status": "open", + "sticky": false, + "template": "", + "format": "standard", + "meta": [], + "categories": [ 4 ], + "tags": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/287"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/post"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=287"} ], + "version-history": [ {"count": 4, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/287\/revisions"} ], + "predecessor-version": [ {"id": 309, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/287\/revisions\/309"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=287"} ], + "wp:term": [ {"taxonomy": "category", "embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/categories?post=287"}, {"taxonomy": "post_tag", "embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/tags?post=287"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 193, + "date": "2022-05-08T13:53:56", + "date_gmt": "2022-05-08T10:53:56", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?p=193"}, + "modified": "2023-02-12T10:56:46", + "modified_gmt": "2023-02-12T08:56:46", + "slug": "8-5-2022", + "status": "publish", + "type": "post", + "link": "https:\/\/cms.hg.fi\/2022\/05\/08\/8-5-2022\/", + "title": {"rendered": "8.5.2022"}, + "content": { + "rendered": "\r\n

8.5.2022<\/strong><\/p>\r\n\r\n\r\n\r\n

We have been featured on This week in Matrix:<\/a><\/p>\r\n\r\n\r\n\r\n

We’ve started work on our HG HomeServer written in pure TypeScript, compilable as a single JS file, with no dependencies except NodeJS. It’s intended for a special use cases when Matrix is used as a backbone for custom apps. It’s lightweight, minimal and for the moment isn’t even planned to support full Matrix spec. We might make it possible to run it on browser later.\u00a0https:\/\/github.com\/heusalagroup\/hghs<\/a><\/p>\r\n\r\n\r\n\r\n

https:\/\/matrix.org\/blog\/2022\/08\/05\/this-week-in-matrix-2022-08-05#hghs-website<\/a> \u00a0<\/p>", + "protected": false + }, + "excerpt": {"rendered": "

8.5.2022 We have been featured on This week in Matrix: We’ve started work on our HG HomeServer written in pure TypeScript, compilable as a single JS file, with no dependencies except NodeJS. It’s intended for a special use cases when Matrix is used as a backbone for custom apps. It’s lightweight, minimal and for the […]<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "comment_status": "open", + "ping_status": "open", + "sticky": false, + "template": "", + "format": "standard", + "meta": [], + "categories": [ 4 ], + "tags": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/193"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/post"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=193"} ], + "version-history": [ {"count": 3, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/193\/revisions"} ], + "predecessor-version": [ {"id": 295, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/193\/revisions\/295"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=193"} ], + "wp:term": [ {"taxonomy": "category", "embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/categories?post=193"}, {"taxonomy": "post_tag", "embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/tags?post=193"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + } +]; + +describe('WpPostDTO', () => { + + describe('isWpPostDTO', () => { + it('can test correct DTOs', () => { + expect(isWpPostDTO(TEST_DATA_POSTS[0])).toBe(true); + expect(isWpPostDTO(TEST_DATA_POSTS[1])).toBe(true); + }); + }); + + describe('explainWpPostDTO', () => { + it('can explain correct DTOs', () => { + expect(explainWpPostDTO(TEST_DATA_POSTS[0])).toBe(explainOk()); + expect(explainWpPostDTO(TEST_DATA_POSTS[1])).toBe(explainOk()); + }); + }); + +}); diff --git a/wordpress/dto/WpPostDTO.ts b/wordpress/dto/WpPostDTO.ts new file mode 100644 index 0000000..f17fe3d --- /dev/null +++ b/wordpress/dto/WpPostDTO.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpPageStatus, isWpPageStatus, WpPageStatus } from "./WpPageStatus"; +import { explainString, explainStringOrNull, isString, isStringOrNull } from "../../types/String"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeysInDevelopment, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainWpRenderedDTO, isWpRenderedDTO, WpRenderedDTO } from "./WpRenderedDTO"; +import { explainNumber, isNumber } from "../../types/Number"; +import { explainReadonlyJsonArray, explainReadonlyJsonObject, isReadonlyJsonArray, isReadonlyJsonObject, ReadonlyJsonArray, ReadonlyJsonObject } from "../../Json"; +import { explain, explainProperty } from "../../types/explain"; +import { explainBoolean, isBoolean } from "../../types/Boolean"; +import { explainNumberArray, isNumberArray } from "../../types/NumberArray"; + +/** + * Wordpress v2 JSON API object for /wp-json/wp/v2/posts + */ +export interface WpPostDTO { + readonly title : WpRenderedDTO; + readonly content : WpRenderedDTO; + readonly excerpt : WpRenderedDTO; + readonly guid : WpRenderedDTO; + readonly type : string; + readonly modified : string; + readonly modified_gmt : string; + readonly link : string; + readonly id : number; + readonly date : string | null; + readonly status : WpPageStatus; + readonly author : number; + readonly featured_media : number; + readonly comment_status : string; + readonly ping_status : string; + + /** + * @fixme Add correct typing before using this property! + * @deprecated (just so that IDE highlights and you read above comment) + */ + readonly meta : ReadonlyJsonArray; + + readonly template : string; + readonly date_gmt : string | null; + readonly slug : string; + readonly format : string; + readonly sticky : boolean; + readonly categories : readonly number[]; + readonly tags : readonly number[]; + + /** + * @fixme Add correct typing before using this property! + * @deprecated (just so that IDE highlights and you read above comment) + */ + readonly _links : ReadonlyJsonObject; + +} + +export function isWpPostDTO (value:any): value is WpPostDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'title', + 'content', + 'excerpt', + 'guid', + 'type', + 'id', + 'date', + 'modified', + 'modified_gmt', + 'link', + 'status', + 'author', + 'featured_media', + 'comment_status', + 'ping_status', + 'meta', + 'template', + 'date_gmt', + 'format', + 'sticky', + 'categories', + 'tags', + '_links', + 'slug' + ]) + && isWpRenderedDTO(value?.title) + && isWpRenderedDTO(value?.content) + && isWpRenderedDTO(value?.excerpt) + && isWpRenderedDTO(value?.guid) + && isString(value?.type) + && isNumber(value?.id) + && isStringOrNull(value?.date) + && isWpPageStatus(value?.status) + && isNumber(value?.author) + && isNumber(value?.featured_media) + && isString(value?.comment_status) + && isString(value?.modified) + && isString(value?.modified_gmt) + && isString(value?.link) + && isString(value?.ping_status) + && isReadonlyJsonArray(value?.meta) + && isString(value?.template) + && isStringOrNull(value?.date_gmt) + && isString(value?.format) + && isString(value?.slug) + && isBoolean(value?.sticky) + && isNumberArray(value?.tags) + && isNumberArray(value?.categories) + && isReadonlyJsonObject(value?._links) + ) +} + +export function explainWpPostDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeysInDevelopment(value, [ + 'title', + 'content', + 'excerpt', + 'guid', + 'type', + 'id', + 'date', + 'modified', + 'modified_gmt', + 'link', + 'status', + 'author', + 'featured_media', + 'comment_status', + 'ping_status', + 'meta', + 'template', + 'date_gmt', + 'format', + 'sticky', + 'categories', + 'tags', + '_links', + 'slug' + ]) + , explainProperty("title", explainWpRenderedDTO(value?.title)) + , explainProperty("content", explainWpRenderedDTO(value?.content)) + , explainProperty("excerpt", explainWpRenderedDTO(value?.excerpt)) + , explainProperty("guid", explainWpRenderedDTO(value?.guid)) + , explainProperty("type", explainString(value?.type)) + , explainProperty("id", explainNumber(value?.id)) + , explainProperty("modified", explainString(value?.modified)) + , explainProperty("modified_gmt", explainString(value?.modified_gmt)) + , explainProperty("link", explainString(value?.link)) + , explainProperty("date", explainStringOrNull(value?.date)) + , explainProperty("status", explainWpPageStatus(value?.status)) + , explainProperty("author", explainNumber(value?.author)) + , explainProperty("featured_media", explainNumber(value?.featured_media)) + , explainProperty("comment_status", explainString(value?.comment_status)) + , explainProperty("ping_status", explainString(value?.ping_status)) + , explainProperty("meta", explainReadonlyJsonArray(value?.meta)) + , explainProperty("template", explainString(value?.template)) + , explainProperty("date_gmt", explainStringOrNull(value?.date_gmt)) + , explainProperty("format", explainString(value?.format)) + , explainProperty("slug", explainString(value?.slug)) + , explainProperty("sticky", explainBoolean(value?.sticky)) + , explainProperty("categories", explainNumberArray(value?.categories)) + , explainProperty("tags", explainNumberArray(value?.tags)) + , explainProperty("_links", explainReadonlyJsonObject(value?._links)) + ] + ); +} diff --git a/wordpress/dto/WpPostListDTO.test.ts b/wordpress/dto/WpPostListDTO.test.ts new file mode 100644 index 0000000..18dbe57 --- /dev/null +++ b/wordpress/dto/WpPostListDTO.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpPostListDTO, isWpPostListDTO } from "./WpPostListDTO"; +import { explainOk } from "../../types/explain"; + +/** + * Test data from https://cms.hg.fi/wp-json/wp/v2/posts + */ +const TEST_DATA_POSTS = [ + { + "id": 287, + "date": "2022-12-31T14:18:38", + "date_gmt": "2022-12-31T12:18:38", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?p=287"}, + "modified": "2023-02-16T15:18:29", + "modified_gmt": "2023-02-16T13:18:29", + "slug": "31-12-2022", + "status": "publish", + "type": "post", + "link": "https:\/\/cms.hg.fi\/2022\/12\/31\/31-12-2022\/", + "title": {"rendered": "31.12.2022"}, + "content": {"rendered": "

31.12.2022<\/strong><\/p>\r\n\r\n\r\n\r\n

Happy New Year 2023!<\/p>", "protected": false}, + "excerpt": {"rendered": "

31.12.2022 Happy New Year 2023!<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "comment_status": "open", + "ping_status": "open", + "sticky": false, + "template": "", + "format": "standard", + "meta": [], + "categories": [ 4 ], + "tags": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/287"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/post"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=287"} ], + "version-history": [ {"count": 4, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/287\/revisions"} ], + "predecessor-version": [ {"id": 309, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/287\/revisions\/309"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=287"} ], + "wp:term": [ {"taxonomy": "category", "embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/categories?post=287"}, {"taxonomy": "post_tag", "embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/tags?post=287"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + }, + { + "id": 193, + "date": "2022-05-08T13:53:56", + "date_gmt": "2022-05-08T10:53:56", + "guid": {"rendered": "https:\/\/cms.hg.fi\/?p=193"}, + "modified": "2023-02-12T10:56:46", + "modified_gmt": "2023-02-12T08:56:46", + "slug": "8-5-2022", + "status": "publish", + "type": "post", + "link": "https:\/\/cms.hg.fi\/2022\/05\/08\/8-5-2022\/", + "title": {"rendered": "8.5.2022"}, + "content": { + "rendered": "\r\n

8.5.2022<\/strong><\/p>\r\n\r\n\r\n\r\n

We have been featured on This week in Matrix:<\/a><\/p>\r\n\r\n\r\n\r\n

We’ve started work on our HG HomeServer written in pure TypeScript, compilable as a single JS file, with no dependencies except NodeJS. It’s intended for a special use cases when Matrix is used as a backbone for custom apps. It’s lightweight, minimal and for the moment isn’t even planned to support full Matrix spec. We might make it possible to run it on browser later.\u00a0https:\/\/github.com\/heusalagroup\/hghs<\/a><\/p>\r\n\r\n\r\n\r\n

https:\/\/matrix.org\/blog\/2022\/08\/05\/this-week-in-matrix-2022-08-05#hghs-website<\/a> \u00a0<\/p>", + "protected": false + }, + "excerpt": {"rendered": "

8.5.2022 We have been featured on This week in Matrix: We’ve started work on our HG HomeServer written in pure TypeScript, compilable as a single JS file, with no dependencies except NodeJS. It’s intended for a special use cases when Matrix is used as a backbone for custom apps. It’s lightweight, minimal and for the […]<\/p>\n", "protected": false}, + "author": 9, + "featured_media": 0, + "comment_status": "open", + "ping_status": "open", + "sticky": false, + "template": "", + "format": "standard", + "meta": [], + "categories": [ 4 ], + "tags": [], + "_links": { + "self": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/193"} ], + "collection": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts"} ], + "about": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/types\/post"} ], + "author": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/users\/9"} ], + "replies": [ {"embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/comments?post=193"} ], + "version-history": [ {"count": 3, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/193\/revisions"} ], + "predecessor-version": [ {"id": 295, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/posts\/193\/revisions\/295"} ], + "wp:attachment": [ {"href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/media?parent=193"} ], + "wp:term": [ {"taxonomy": "category", "embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/categories?post=193"}, {"taxonomy": "post_tag", "embeddable": true, "href": "https:\/\/cms.hg.fi\/wp-json\/wp\/v2\/tags?post=193"} ], + "curies": [ {"name": "wp", "href": "https:\/\/api.w.org\/{rel}", "templated": true} ] + } + } +]; + +describe('WpPostListDTO', () => { + + describe('isWpPostListDTO', () => { + it('can test valid DTOs', () => { + expect( isWpPostListDTO(TEST_DATA_POSTS) ).toBe(true); + }); + }); + + describe('explainWpPostListDTO', () => { + it('can explain valid DTOs', () => { + expect( explainWpPostListDTO(TEST_DATA_POSTS) ).toBe(explainOk()); + }); + }); + +}); diff --git a/wordpress/dto/WpPostListDTO.ts b/wordpress/dto/WpPostListDTO.ts new file mode 100644 index 0000000..2f47365 --- /dev/null +++ b/wordpress/dto/WpPostListDTO.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpPostDTO, isWpPostDTO, WpPostDTO } from "./WpPostDTO"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; + +/** + * Wordpress v2 JSON API object for /wp-json/wp/v2/posts + */ +export type WpPostListDTO = readonly WpPostDTO[]; + +/** + * Tests that the value is DTO for /wp-json/wp/v2/posts + * @param value + */ +export function isWpPostListDTO (value: any): value is WpPostListDTO { + return isArrayOf(value, isWpPostDTO); +} + +export function explainWpPostListDTO (value: any): string { + return explainArrayOf("WpPostDTO", explainWpPostDTO, value, isWpPostDTO); +} + +export function stringifyWpPostListDTO (value: WpPostListDTO): string { + return `WordpressPostDTO(${value})`; +} + +export function parseWpPostListDTO (value: any): WpPostListDTO | undefined { + if ( isWpPostListDTO(value)) return value; + return undefined; +} diff --git a/wordpress/dto/WpReferenceDTO.test.ts b/wordpress/dto/WpReferenceDTO.test.ts new file mode 100644 index 0000000..fc6c58a --- /dev/null +++ b/wordpress/dto/WpReferenceDTO.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpReferenceDTO, isWpReferenceDTO } from "./WpReferenceDTO"; +import { explainOk } from "../../types/explain"; + +/** + * The test DTO data from /wp-json/wp/v3/references + */ +const TEST_REFERENCE_DATA = [ + { + "id": 106, + "author": "3", + "date": "2022-10-12 12:36:17", + "excerpt": "", + "title": "ProcureNode Oy (FI)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

ProcureNode Oy<\/h3>\r\n\r\n\r\n\r\n

Kehit\u00e4mme ProcureNodelle web-pohjaista hankintaj\u00e4rjestelm\u00e4\u00e4.<\/p>\r\n\r\n\r\n\r\n

www.procurenode.com<\/a><\/p>\r\n", + "slug": "fi-reference-procurenode-oy", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 107, + "author": "3", + "date": "2022-10-12 12:36:57", + "excerpt": "", + "title": "Sendanor (FI)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

Sendanor<\/h3>\r\n\r\n\r\n\r\n

Toimme verkkokaupan suoraan Sendanorin uusille kotisivuille.<\/p>\r\n\r\n\r\n\r\n

www.sendanor.fi<\/a><\/p>\r\n", + "slug": "fi-reference-sendanor", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 108, + "author": "3", + "date": "2022-10-12 12:37:33", + "excerpt": "", + "title": "Promentor Solutions Oy (FI)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

Promentor Solutions Oy<\/h3>\r\n\r\n\r\n\r\n

Teemme yhteisty\u00f6t\u00e4 Promentorin digitaalisten tuotteiden kehityksess\u00e4.<\/p>\r\n\r\n\r\n\r\n

www.promentor.fi<\/a><\/p>\r\n", + "slug": "fi-reference-promentor-solutions-oy", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 280, + "author": "9", + "date": "2023-02-09 14:44:24", + "excerpt": "", + "title": "Sendanor (EN)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

Sendanor<\/h3>\r\n\r\n\r\n\r\n

ENGLANNIKSI:<\/p>\r\n

Toimme verkkokaupan suoraan Sendanorin uusille kotisivuille.<\/p>\r\n

We brought webshop straightforwardly on the Sendanor's new website.<\/p>\r\n\r\n\r\n\r\n

www.sendanor.fi<\/a><\/p>", + "slug": "en-reference-sendanor", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 285, + "author": "9", + "date": "2023-02-10 10:29:05", + "excerpt": "", + "title": "ProcureNode Oy (EN)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

ProcureNode Oy<\/h3>\r\n\r\n\r\n\r\n

ENGLANNIKSI:<\/p>\r\n

Kehit\u00e4mme ProcureNodelle web-pohjaista hankintaj\u00e4rjestelm\u00e4\u00e4.<\/p>\r\n

We are developing a web-based procurement system for ProcureNode.<\/p>\r\n\r\n\r\n\r\n

www.procurenode.com<\/a><\/p>", + "slug": "en-reference-procurenode-oy", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 286, + "author": "9", + "date": "2023-02-10 10:30:06", + "excerpt": "", + "title": "Promentor Solutions Oy (EN)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

Promentor Solutions Oy<\/h3>\r\n\r\n\r\n\r\n

ENGLANNIKSI:<\/p>\r\n

Teemme yhteisty\u00f6t\u00e4 Promentorin digitaalisten tuotteiden kehityksess\u00e4.<\/p>\r\n

We cooperate with Promentor in digital product development.<\/p>\r\n\r\n\r\n\r\n

www.promentor.fi<\/a><\/p>", + "slug": "en-reference-promentor-solutions-oy", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + } +]; + +describe('WpReferenceDTO', () => { + + describe('isWpReferenceDTO', () => { + it('can test valid DTOs', () => { + expect( isWpReferenceDTO(TEST_REFERENCE_DATA[0]) ).toBe(true); + expect( isWpReferenceDTO(TEST_REFERENCE_DATA[1]) ).toBe(true); + expect( isWpReferenceDTO(TEST_REFERENCE_DATA[2]) ).toBe(true); + expect( isWpReferenceDTO(TEST_REFERENCE_DATA[3]) ).toBe(true); + expect( isWpReferenceDTO(TEST_REFERENCE_DATA[4]) ).toBe(true); + expect( isWpReferenceDTO(TEST_REFERENCE_DATA[5]) ).toBe(true); + }); + }); + + describe('explainWpReferenceDTO', () => { + it('can explain valid DTOs', () => { + expect( explainWpReferenceDTO(TEST_REFERENCE_DATA[0]) ).toBe(explainOk()); + expect( explainWpReferenceDTO(TEST_REFERENCE_DATA[1]) ).toBe(explainOk()); + expect( explainWpReferenceDTO(TEST_REFERENCE_DATA[2]) ).toBe(explainOk()); + expect( explainWpReferenceDTO(TEST_REFERENCE_DATA[3]) ).toBe(explainOk()); + expect( explainWpReferenceDTO(TEST_REFERENCE_DATA[4]) ).toBe(explainOk()); + expect( explainWpReferenceDTO(TEST_REFERENCE_DATA[5]) ).toBe(explainOk()); + }); + }); + +}); diff --git a/wordpress/dto/WpReferenceDTO.ts b/wordpress/dto/WpReferenceDTO.ts new file mode 100644 index 0000000..2110d87 --- /dev/null +++ b/wordpress/dto/WpReferenceDTO.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { WpPageStatus } from "./WpPageStatus"; +import { explainString, isString } from "../../types/String"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explain, explainProperty } from "../../types/explain"; +import { explainWpImageDTO, isWpImageDTO, WpImageDTO } from "./WpImageDTO"; +import { explainNumber, isNumber } from "../../types/Number"; + +/** + * The DTO for single item in /wp-json/wp/v3/references + */ +export interface WpReferenceDTO { + readonly title : string; + readonly content : string; + readonly id : number; + readonly date : string; + readonly status : WpPageStatus; + readonly author : number; + readonly excerpt :object; + readonly featured_image : WpImageDTO; + readonly slug : string; +} + +export function isWpReferenceDTO (value:any): value is WpReferenceDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'title', + 'content', + 'id', + 'date', + 'status', + 'author', + 'excerpt', + 'featured_image', + 'slug' + ]) + && isString(value?.title) + && isString(value?.content) + && isNumber(value?.id) + && isString(value?.date) + && isString(value?.status) + && isString(value?.author) + && isString(value?.excerpt) + && isWpImageDTO(value?.featured_image) + && isString(value?.slug) + ) +} + +export function explainWpReferenceDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'title', + 'content', + 'id', + 'date', + 'status', + 'author', + 'excerpt', + 'featured_image', + 'slug' + ]) + , explainProperty("title", explainString(value?.title)) + , explainProperty("content", explainString(value?.content)) + , explainProperty("id", explainNumber(value?.id)) + , explainProperty("date", explainString(value?.date)) + , explainProperty("status", explainString(value?.status)) + , explainProperty("author", explainString(value?.author)) + , explainProperty("excerpt", explainString(value?.excerpt)) + , explainProperty("featured_image", explainWpImageDTO(value?.featured_image)) + , explainProperty("slug", explainString(value?.slug)) + ] + ); +} diff --git a/wordpress/dto/WpReferenceListDTO.test.ts b/wordpress/dto/WpReferenceListDTO.test.ts new file mode 100644 index 0000000..4bd218d --- /dev/null +++ b/wordpress/dto/WpReferenceListDTO.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpReferenceListDTO, isWpReferenceListDTO } from "./WpReferenceListDTO"; +import { explainOk } from "../../types/explain"; + +/** + * The test DTO data from /wp-json/wp/v3/references + */ +const TEST_REFERENCE_DATA = [ + { + "id": 106, + "author": "3", + "date": "2022-10-12 12:36:17", + "excerpt": "", + "title": "ProcureNode Oy (FI)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

ProcureNode Oy<\/h3>\r\n\r\n\r\n\r\n

Kehit\u00e4mme ProcureNodelle web-pohjaista hankintaj\u00e4rjestelm\u00e4\u00e4.<\/p>\r\n\r\n\r\n\r\n

www.procurenode.com<\/a><\/p>\r\n", + "slug": "fi-reference-procurenode-oy", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 107, + "author": "3", + "date": "2022-10-12 12:36:57", + "excerpt": "", + "title": "Sendanor (FI)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

Sendanor<\/h3>\r\n\r\n\r\n\r\n

Toimme verkkokaupan suoraan Sendanorin uusille kotisivuille.<\/p>\r\n\r\n\r\n\r\n

www.sendanor.fi<\/a><\/p>\r\n", + "slug": "fi-reference-sendanor", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 108, + "author": "3", + "date": "2022-10-12 12:37:33", + "excerpt": "", + "title": "Promentor Solutions Oy (FI)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

Promentor Solutions Oy<\/h3>\r\n\r\n\r\n\r\n

Teemme yhteisty\u00f6t\u00e4 Promentorin digitaalisten tuotteiden kehityksess\u00e4.<\/p>\r\n\r\n\r\n\r\n

www.promentor.fi<\/a><\/p>\r\n", + "slug": "fi-reference-promentor-solutions-oy", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 280, + "author": "9", + "date": "2023-02-09 14:44:24", + "excerpt": "", + "title": "Sendanor (EN)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

Sendanor<\/h3>\r\n\r\n\r\n\r\n

ENGLANNIKSI:<\/p>\r\n

Toimme verkkokaupan suoraan Sendanorin uusille kotisivuille.<\/p>\r\n

We brought webshop straightforwardly on the Sendanor's new website.<\/p>\r\n\r\n\r\n\r\n

www.sendanor.fi<\/a><\/p>", + "slug": "en-reference-sendanor", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 285, + "author": "9", + "date": "2023-02-10 10:29:05", + "excerpt": "", + "title": "ProcureNode Oy (EN)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

ProcureNode Oy<\/h3>\r\n\r\n\r\n\r\n

ENGLANNIKSI:<\/p>\r\n

Kehit\u00e4mme ProcureNodelle web-pohjaista hankintaj\u00e4rjestelm\u00e4\u00e4.<\/p>\r\n

We are developing a web-based procurement system for ProcureNode.<\/p>\r\n\r\n\r\n\r\n

www.procurenode.com<\/a><\/p>", + "slug": "en-reference-procurenode-oy", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 286, + "author": "9", + "date": "2023-02-10 10:30:06", + "excerpt": "", + "title": "Promentor Solutions Oy (EN)", + "content": "\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

Promentor Solutions Oy<\/h3>\r\n\r\n\r\n\r\n

ENGLANNIKSI:<\/p>\r\n

Teemme yhteisty\u00f6t\u00e4 Promentorin digitaalisten tuotteiden kehityksess\u00e4.<\/p>\r\n

We cooperate with Promentor in digital product development.<\/p>\r\n\r\n\r\n\r\n

www.promentor.fi<\/a><\/p>", + "slug": "en-reference-promentor-solutions-oy", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + } +]; + +describe('WpReferenceListDTO', () => { + + describe('isWpReferenceListDTO', () => { + it('can test valid DTO array', () => { + expect( isWpReferenceListDTO(TEST_REFERENCE_DATA) ).toBe(true); + }); + }); + + describe('explainWpReferenceListDTO', () => { + it('can explain valid DTO array', () => { + expect( explainWpReferenceListDTO(TEST_REFERENCE_DATA) ).toBe(explainOk()); + }); + }); + +}); diff --git a/wordpress/dto/WpReferenceListDTO.ts b/wordpress/dto/WpReferenceListDTO.ts new file mode 100644 index 0000000..a249c51 --- /dev/null +++ b/wordpress/dto/WpReferenceListDTO.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2022. Heusala Group Oy . All rights reserved. + +import { explainWpReferenceDTO, isWpReferenceDTO, WpReferenceDTO } from "./WpReferenceDTO"; +import { explainArrayOf, isArrayOf } from "../../types/Array"; + +/** + * DTO for GET /wp-json/wp/v3/references + */ +export type WpReferenceListDTO = readonly WpReferenceDTO[]; + +/** + * Checks that the value is DTO for /wp-json/wp/v3/references + * @param value + */ +export function isWpReferenceListDTO (value: unknown): value is WpReferenceListDTO { + return isArrayOf(value, isWpReferenceDTO); +} + +export function explainWpReferenceListDTO (value: any) : string { + return explainArrayOf("WpReferenceDTO", explainWpReferenceDTO, value, isWpReferenceDTO); +} + +export function stringifyWpReferenceListDTO (value: WpReferenceListDTO): string { + return `WpReferenceListDTO(${value})`; +} + +export function parseWpReferenceListDTO (value: any): WpReferenceListDTO | undefined { + if ( isWpReferenceListDTO(value)) return value; + return undefined; +} \ No newline at end of file diff --git a/wordpress/dto/WpRenderedDTO.test.ts b/wordpress/dto/WpRenderedDTO.test.ts new file mode 100644 index 0000000..c0e934e --- /dev/null +++ b/wordpress/dto/WpRenderedDTO.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { createWpRenderedDTO, isWpRenderedDTO } from "./WpRenderedDTO"; + +describe('WpRenderedDTO', () => { + + describe('createWpRenderedDTO', () => { + + it('can create DTO with false protected', () => { + const test = createWpRenderedDTO('

Contact Us

\n', false); + expect(test.rendered).toBe('

Contact Us

\n'); + expect(test.protected).toBe(false); + }); + + it('can create DTO with true protected', () => { + const test = createWpRenderedDTO('

Contact Us

\n', true); + expect(test.rendered).toBe('

Contact Us

\n'); + expect(test.protected).toBe(true); + }); + + it('can create DTO without protected argument', () => { + const test = createWpRenderedDTO('

Contact Us

\n'); + expect(test.rendered).toBe('

Contact Us

\n'); + expect(test.protected).toBeUndefined(); + }); + + it('can create DTO with protected argument as undefined', () => { + const test = createWpRenderedDTO('

Contact Us

\n', undefined); + expect(test.rendered).toBe('

Contact Us

\n'); + expect(test.protected).toBeUndefined(); + }); + + }); + + describe('isWpRenderedDTO', () => { + + it('can test DTO with protected as false', () => { + const test = {rendered: '

Contact Us

\n', protected: false}; + expect(isWpRenderedDTO(test)).toBe(true); + }); + + it('can test DTO with protected as true', () => { + const test = {rendered: '

Contact Us

\n', protected: true}; + expect(isWpRenderedDTO(test)).toBe(true); + }); + + it('can test DTO with protected as undefined', () => { + const test = {rendered: '

Contact Us

\n', protected: undefined}; + expect(isWpRenderedDTO(test)).toBe(true); + }); + + it('can test DTO with protected missing', () => { + const test = {rendered: '

Contact Us

\n'}; + expect(isWpRenderedDTO(test)).toBe(true); + }); + + }); + +}); diff --git a/wordpress/dto/WpRenderedDTO.ts b/wordpress/dto/WpRenderedDTO.ts new file mode 100644 index 0000000..f37f600 --- /dev/null +++ b/wordpress/dto/WpRenderedDTO.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainString, isString } from "../../types/String"; +import { explainBooleanOrUndefined, isBooleanOrUndefined } from "../../types/Boolean"; +import { explainNoOtherKeys, hasNoOtherKeysInDevelopment } from "../../types/OtherKeys"; +import { explainRegularObject, isRegularObject } from "../../types/RegularObject"; +import { explain, explainProperty } from "../../types/explain"; + +/** + * Wordpress object used in the v2 JSON API + */ +export interface WpRenderedDTO { + readonly rendered : string; + readonly protected ?: boolean; +} + +export function createWpRenderedDTO ( + rendered : string, + protected_ ?: boolean | undefined +) : WpRenderedDTO { + return { + rendered, + ...( protected_ !== undefined ? {protected: protected_} : {}) + }; +} + +export function isWpRenderedDTO (value: unknown) : value is WpRenderedDTO { + return ( + isRegularObject(value) + && hasNoOtherKeysInDevelopment(value, [ + 'rendered', + 'protected' + ]) + && isString(value?.rendered) + && isBooleanOrUndefined(value?.protected) + ); +} + +export function explainWpRenderedDTO (value: any) : string { + return explain( + [ + explainRegularObject(value), + explainNoOtherKeys(value, [ + 'rendered', + 'protected' + ]) + , explainProperty("rendered", explainString(value?.rendered)) + , explainProperty("protected", explainBooleanOrUndefined(value?.protected)) + ] + ); +} + +export function stringifyWpRenderedDTO (value : WpRenderedDTO) : string { + return `WpRenderedDTO(${value})`; +} + +export function parseWpRenderedDTO (value: unknown) : WpRenderedDTO | undefined { + if (isWpRenderedDTO(value)) return value; + return undefined; +} diff --git a/wordpress/dto/WpUserProfileDTO.test.ts b/wordpress/dto/WpUserProfileDTO.test.ts new file mode 100644 index 0000000..8eb39da --- /dev/null +++ b/wordpress/dto/WpUserProfileDTO.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2023. Heusala Group Oy . All rights reserved. + +import { explainWpUserProfileDTO, isWpUserProfileDTO } from "./WpUserProfileDTO"; +import { explainOk } from "../../types/explain"; + +const TEST_PROFILES = [ + { + "id": 78, + "author": "3", + "date": "2022-09-12 13:53:53", + "excerpt": "Ohjelmistokehitt\u00e4j\u00e4", + "title": "fi-Erik Vesa", + "content": "\r\n

Lorem<\/strong> ipsum<\/em> dolor sit amet, consectetur adipiscing elit. Etiam urna dolor, ultricies ac elementum a, feugiat vitae purus. Donec non ipsum efficitur odio tempus maximus. Duis ultrices nibh vel ligula convallis, sit amet gravida massa lobortis.<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

Maecenas<\/a> condimentum ligula porta, pulvinar neque nec, auctor dui. Mauris non sem condimentum, suscipit leo vel, convallis ante. Praesent lorem ligula, maximus sit amet varius sit amet, ullamcorper eu magna. Cras ut diam orci. Sed euismod justo ut massa finibus, nec tempus nibh laoreet. Duis faucibus tellus tellus, a consequat felis facilisis in. Aenean vitae mauris eget tortor molestie porttitor.<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

Aenean tortor<\/span> elit, fermentum ut risus at, convallis condimentum elit. Proin ut tortor vitae enim consequat volutpat. Donec eu faucibus nibh. Morbi ac ex lacus. Suspendisse varius mi at ullamcorper rutrum. Praesent lacinia vitae ante vel condimentum. Suspendisse molestie, lacus nec vestibulum dictum, erat enim dictum diam, ut varius odio augue eget nunc. Nullam dapibus euismod vulputate. Sed vulputate rutrum pulvinar. Praesent id malesuada orci. In hac habitasse platea dictumst.<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

Etiam nisl erat, consectetur in dictum ut, maximus non metus. Nunc vitae lorem eget felis egestas cursus. Praesent eleifend turpis id est vulputate, at ultricies justo facilisis. In maximus, lectus eu faucibus semper, lorem purus consectetur velit, id interdum dui libero et quam. Ut dignissim dolor eu odio interdum ullamcorper. Fusce euismod odio vitae sodales pellentesque. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean at tincidunt justo. Phasellus tincidunt, orci et tristique laoreet, ante ligula vehicula mi, vel consequat arcu enim elementum sem.<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

Osaaminen<\/h3>\r\n\r\n\r\n\r\n

\u2714 Javascript<\/p>\r\n\r\n\r\n\r\n

\u2714 ReactJS<\/p>\r\n\r\n\r\n\r\n

\u2714 TypeScript<\/p>\r\n\r\n\r\n\r\n

\u2714 CSS<\/p>\r\n\r\n\r\n\r\n

\u2714 Anything I can google<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

\"\"\"\"\"\"\"\" \r\n

 <\/p>\r\n\r\n

test image<\/p>\r\n<\/figure>", + "description": [ "Hey, I am Junior Software Developer, I would describe myself as constant learner and brainstormer who keeps chasing that flow state relentlessly. \r\n\r\nMy main focus is within React world (JS, TS, css, Redux, Jest).\r\nAs for my other skills: I like to brainstorm, Search and figure things out on my own, and generally read documentations to broaden my knowledge (especially about related but unfamiliar topics).\r\n\r\nI tend to work independently 90% of the time, but I do like helping others too when my skillset if sufficient to do so. As a part of a project, I like to pick a component \/ section todo and start working on it. Before starting at Heusala Group I'd say that I would rather search everything on my own even when I don't know where to start, but nowdays when it comes to topics that go over my head like technical difficulties etc. I will end up asking aid immediately so I can get to actual working (Senior developers are much appreciated)." ], + "extra Information": [ "Github:https:\/\/github.com\/EVCareeria\r\nLinkedIn: https:\/\/www.linkedin.com\/in\/erikvesa\/\r\n" ], + "slug": "fi-120-erik-v", + "status": "publish", + "featured_image": {"thumbnail": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva-150x150.jpeg", "medium": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva-300x300.jpeg", "large": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva.jpeg"} + }, + { + "id": 121, + "author": "3", + "date": "2023-01-12 11:23:48", + "excerpt": "Software Developer", + "title": "en-Erik Vesa", + "content": "\r\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam urna dolor, ultricies ac elementum a, feugiat vitae purus. Donec non ipsum efficitur odio tempus maximus. Duis ultrices nibh vel ligula convallis, sit amet gravida massa lobortis.<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

Maecenas condimentum ligula porta, pulvinar neque nec, auctor dui. Mauris non sem condimentum, suscipit leo vel, convallis ante. Praesent lorem ligula, maximus sit amet varius sit amet, ullamcorper eu magna. Cras ut diam orci. Sed euismod justo ut massa finibus, nec tempus nibh laoreet. Duis faucibus tellus tellus, a consequat felis facilisis in. Aenean vitae mauris eget tortor molestie porttitor.<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

Aenean tortor elit, fermentum ut risus at, convallis condimentum elit. Proin ut tortor vitae enim consequat volutpat. Donec eu faucibus nibh. Morbi ac ex lacus. Suspendisse varius mi at ullamcorper rutrum. Praesent lacinia vitae ante vel condimentum. Suspendisse molestie, lacus nec vestibulum dictum, erat enim dictum diam, ut varius odio augue eget nunc. Nullam dapibus euismod vulputate. Sed vulputate rutrum pulvinar. Praesent id malesuada orci. In hac habitasse platea dictumst.<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

Etiam nisl erat, consectetur in dictum ut, maximus non metus. Nunc vitae lorem eget felis egestas cursus. Praesent eleifend turpis id est vulputate, at ultricies justo facilisis. In maximus, lectus eu faucibus semper, lorem purus consectetur velit, id interdum dui libero et quam. Ut dignissim dolor eu odio interdum ullamcorper. Fusce euismod odio vitae sodales pellentesque. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean at tincidunt justo. Phasellus tincidunt, orci et tristique laoreet, ante ligula vehicula mi, vel consequat arcu enim elementum sem.<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

Osaaminen<\/h3>\r\n\r\n\r\n\r\n

\u2714 Javascript<\/p>\r\n\r\n\r\n\r\n

\u2714 ReactJS<\/p>\r\n\r\n\r\n\r\n

\u2714 TypeScript<\/p>\r\n\r\n\r\n\r\n

\u2714 CSS<\/p>\r\n\r\n\r\n\r\n

\u2714 Anything I can google<\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

 <\/p>\r\n\r\n\r\n\r\n

\"\"<\/figure>\r\n\r\n\r\n\r\n

test image<\/p>\r\n", + "description": [ "Hey, I am Junior Software Developer, I would describe myself as constant learner and brainstormer who keeps chasing that flow state relentlessly. \r\n\r\nMy main focus is within React world (JS, TS, css, Redux, Jest).\r\nAs for my other skills: I like to brainstorm, Search and figure things out on my own, and generally read documentations to broaden my knowledge (especially about related but unfamiliar topics).\r\n\r\nI tend to work independently 90% of the time, but I do like helping others too when my skillset if sufficient to do so. As a part of a project, I like to pick a component \/ section todo and start working on it. Before starting at Heusala Group I'd say that I would rather search everything on my own even when I don't know where to start, but nowdays when it comes to topics that go over my head like technical difficulties etc. I will end up asking aid immediately so I can get to actual working (Senior developers are much appreciated)." ], + "extra Information": [ "Github:https:\/\/github.com\/EVCareeria\r\nLinkedIn: https:\/\/www.linkedin.com\/in\/erikvesa\/\r\n" ], + "slug": "en-120-erik-v", + "status": "publish", + "featured_image": {"thumbnail": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva-150x150.jpeg", "medium": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva-300x300.jpeg", "large": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2022\/09\/omakuva.jpeg"} + }, + { + "id": 130, + "author": "5", + "date": "2023-01-23 11:56:45", + "excerpt": "Ammattinimike", + "title": "fi-Jarmo Hoo", + "content": "Moi, onko t\u00e4m\u00e4 teko\u00e4lyn tuottamaa teksti\u00e4? Lorem Ipsumia jnejnejne", + "description": [ "Intohimoinen visual designer. Innokas valokuvaaja ja amat\u00f6\u00f6rimuusikko. Seuraan suunnittelutrendej\u00e4, kuin haukka. \u00c4skett\u00e4in sukellettu teko\u00e4lytaiteen ihmeelliseen maailmaan.\r\n\r\nAdobe & Affinity" ], + "extra Information": [], + "slug": "fi-130-jarmo-h", + "status": "publish", + "featured_image": {"thumbnail": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2023\/01\/knight-rider-david-hasselhoff-1-150x150.jpg", "medium": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2023\/01\/knight-rider-david-hasselhoff-1-300x300.jpg", "large": "https:\/\/cms.hg.fi\/wp-content\/uploads\/2023\/01\/knight-rider-david-hasselhoff-1.jpg"} + }, + { + "id": 142, + "author": "6", + "date": "2023-01-25 14:56:32", + "excerpt": "Ohjelmistokehitt\u00e4j\u00e4", + "title": "fi-Taija", + "content": "", + "description": [ "facebook: \"https:\/\/www.facebook.com\/people\/Heusala-Group-Oy\/100084715241503\/\", linkedin:\"https:\/\/www.linkedin.com\/company\/hgoy\/\"" ], + "extra Information": [ "Taija is solving customers' problems with modern web applications \u2014 ReactJS, HTML5, TypeScript, and NodeJS." ], + "slug": "fi-140-taija-l", + "status": "publish", + "featured_image": {"thumbnail": false, "medium": false, "large": false} + }, + { + "id": 139, + "author": "9", + "date": "2023-01-25 16:18:12", + "excerpt": "Ohjelmistokehitt\u00e4j\u00e4", + "title": "fi-Paul H", + "content": "

Heip\u00e4 hei!<\/h2>\r\nNykyisin k\u00e4yt\u00f6ss\u00e4 olevia ohjelmointikieli\u00e4:\r\n