diff --git a/Package.resolved b/Package.resolved index 99d8e7a6..df94c277 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cab653450351fd36fab275010bb6040dc8bd58a3206acccead8ae9e0603d1020", + "originHash" : "218da6b2adb631d44b1b97f5c24e6ab8328280cb5c905230f57301a7c758e60f", "pins" : [ { "identity" : "async-http-client", @@ -384,7 +384,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "26e8ae3515d1ff3607e924ac96fc0094775f55e8", + "revision" : "daa2fb54fe4a7f5187d7286047d5144c8cb97477", "version" : "1.6.0" } }, diff --git a/Package.swift b/Package.swift index ca8d8fd8..51b10e8b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,7 @@ // swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. +import CompilerPluginSupport import PackageDescription let package = Package( @@ -36,8 +37,22 @@ let package = Package( // dependency injection .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.4.1"), + .package( + url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest" + ), ], targets: [ + .macro( + name: "swift-macrosMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + path: "src/swift-server-macrosMacros" + ), + .target( + name: "swift-macros", dependencies: ["swift-macrosMacros"], + path: "src/swift-server-macros"), .executableTarget( name: "MrScroogeServer", dependencies: [ @@ -57,6 +72,7 @@ let package = Package( package: "vapor-queues-fluent-driver"), "SwiftSoup", .product(name: "CSV", package: "CSV.swift"), + "swift-macros", ], path: "src/swift-server", resources: [ diff --git a/src/@typespec/openapi3/openapi.yaml b/src/@typespec/openapi3/openapi.yaml index 9c642798..346e2bc8 100644 --- a/src/@typespec/openapi3/openapi.yaml +++ b/src/@typespec/openapi3/openapi.yaml @@ -528,6 +528,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Label' + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': description: Unauthorized, usually when using a group that the user doesn't have access to content: @@ -542,33 +548,130 @@ paths: application/json: schema: $ref: '#/components/schemas/CreateLabel' + /labels/{labelId}: + put: + operationId: ApiLabels_update + parameters: + - name: labelId + in: path + required: true + schema: + $ref: '#/components/schemas/UUID' + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - labels + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateLabel' + delete: + operationId: ApiLabels_delete + parameters: + - name: labelId + in: path + required: true + schema: + $ref: '#/components/schemas/UUID' + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - labels /rules: get: operationId: ApiRule_list - parameters: [] + parameters: + - name: cursor + in: query + required: false + schema: + type: string + explode: false + - name: limit + in: query + required: false + schema: + type: integer + format: int16 + explode: false responses: '200': description: The request has succeeded. content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Rule' + type: object + required: + - results + properties: + results: + type: array + items: + $ref: '#/components/schemas/Rule' + next: + type: string tags: - rule post: operationId: ApiRule_create parameters: [] responses: - '200': - description: The request has succeeded. + '201': + description: The request has succeeded and a new resource has been created as a result. content: application/json: schema: $ref: '#/components/schemas/Rule' '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': description: Unauthorized, usually when using a group that the user doesn't have access to + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidGroupOwnerId' + '404': + description: The server cannot find the requested resource. content: application/json: schema: @@ -598,17 +701,29 @@ paths: schema: $ref: '#/components/schemas/Rule' '400': - description: Unauthorized, usually when using a group that the user doesn't have access to + description: Bad request, usually when providing an invalid string as UUID content: application/json: schema: $ref: '#/components/schemas/Error' + '401': + description: Unauthorized, usually when using a group that the user doesn't have access to + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidGroupOwnerId' '404': description: The server cannot find the requested resource. content: application/json: schema: $ref: '#/components/schemas/Error' + '422': + description: Unprocessable entity, assigning as parent a direct or indirect child + content: + application/json: + schema: + $ref: '#/components/schemas/Error' tags: - rule requestBody: @@ -631,7 +746,25 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Date' + type: boolean + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/HasChildren' tags: - rule /rules/{ruleId}/apply: @@ -651,17 +784,25 @@ paths: schema: $ref: '#/components/schemas/Date' '400': - description: Unauthorized, usually when using a group that the user doesn't have access to + description: Bad request, usually when providing an invalid string as UUID content: application/json: schema: $ref: '#/components/schemas/Error' + '401': + description: Unauthorized, usually when using a group that the user doesn't have access to + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidGroupOwnerId' '404': description: The server cannot find the requested resource. content: application/json: schema: - $ref: '#/components/schemas/Error' + anyOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/Error' tags: - rule /rules/{ruleId}/condition: @@ -679,11 +820,32 @@ paths: content: application/json: schema: - anyOf: - - $ref: '#/components/schemas/ConditionFloat' - - $ref: '#/components/schemas/ConditionString' + $ref: '#/components/schemas/Rule' + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' tags: - rule + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + condition: + $ref: '#/components/schemas/ConditionParams' + required: + - condition /rules/{ruleId}/condition/{condId}: put: operationId: ApiRule_updateCondition @@ -704,13 +866,34 @@ paths: content: application/json: schema: - anyOf: - - $ref: '#/components/schemas/ConditionFloat' - - $ref: '#/components/schemas/ConditionString' + $ref: '#/components/schemas/Rule' + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' tags: - rule + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + condition: + $ref: '#/components/schemas/ConditionParams' + required: + - condition delete: - operationId: ApiRule_deleteCondition + operationId: ApiRule_removeCondition parameters: - name: ruleId in: path @@ -728,7 +911,94 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Date' + $ref: '#/components/schemas/Rule' + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - rule + /rules/{ruleId}/label/{labelId}: + put: + operationId: ApiRule_addLabel + parameters: + - name: ruleId + in: path + required: true + schema: + $ref: '#/components/schemas/UUID' + - name: labelId + in: path + required: true + schema: + $ref: '#/components/schemas/UUID' + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: The request conflicts with the current state of the server. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - rule + delete: + operationId: ApiRule_removeLabel + parameters: + - name: ruleId + in: path + required: true + schema: + $ref: '#/components/schemas/UUID' + - name: labelId + in: path + required: true + schema: + $ref: '#/components/schemas/UUID' + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + '400': + description: Bad request, usually when providing an invalid string as UUID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' tags: - rule /session: @@ -801,7 +1071,7 @@ paths: schema: $ref: '#/components/schemas/UserProfile' '400': - description: Unauthorized, usually when using a group that the user doesn't have access to + description: Bad request, usually when providing an invalid string as UUID content: application/json: schema: @@ -917,7 +1187,7 @@ paths: schema: $ref: '#/components/schemas/UserProfile' '400': - description: Unauthorized, usually when using a group that the user doesn't have access to + description: Bad request, usually when providing an invalid string as UUID content: application/json: schema: @@ -1083,9 +1353,9 @@ components: anonymous: '#/components/schemas/NotIdentified' Condition: oneOf: - - $ref: '#/components/schemas/ConditionFloat' + - $ref: '#/components/schemas/ConditionDouble' - $ref: '#/components/schemas/ConditionString' - ConditionFloat: + ConditionDouble: type: object required: - id @@ -1095,9 +1365,35 @@ components: id: $ref: '#/components/schemas/UUID' operation: - $ref: '#/components/schemas/OperationFloat' + $ref: '#/components/schemas/OperationDouble' + value: + type: number + format: double + ConditionParamDouble: + type: object + required: + - operation + - value + properties: + operation: + $ref: '#/components/schemas/OperationDouble' value: type: number + format: double + ConditionParamString: + type: object + required: + - operation + - value + properties: + operation: + $ref: '#/components/schemas/OperationString' + value: + type: string + ConditionParams: + oneOf: + - $ref: '#/components/schemas/ConditionParamDouble' + - $ref: '#/components/schemas/ConditionParamString' ConditionString: type: object required: @@ -1114,8 +1410,8 @@ components: ConditionalRelation: type: string enum: - - and - - notOr + - or + - notAnd CreateLabel: type: object required: @@ -1330,6 +1626,21 @@ components: type: array items: $ref: '#/components/schemas/UUID' + HasChildren: + type: object + required: + - message + - code + - childrenIds + properties: + message: + type: string + code: + $ref: '#/components/schemas/ErrorCode' + childrenIds: + type: array + items: + $ref: '#/components/schemas/UUID' HorizontalGroup: type: object required: @@ -1451,20 +1762,20 @@ components: type: string enum: - anonymous - OperationFloat: + OperationDouble: type: string enum: - greater - greaterEqual - - lower - - lowerEqual + - less + - lessEqual OperationString: type: string enum: + - suffix - contains - prefix - regularExpression - - suffix Rule: type: object required: @@ -1473,10 +1784,11 @@ components: - name - relations - conditions + - labelIds properties: id: $ref: '#/components/schemas/UUID' - parentRule: + parentRuleId: $ref: '#/components/schemas/UUID' groupOwnerId: $ref: '#/components/schemas/UUID' @@ -1488,6 +1800,10 @@ components: type: array items: $ref: '#/components/schemas/Condition' + labelIds: + type: array + items: + $ref: '#/components/schemas/UUID' RuleParam: type: object required: @@ -1495,7 +1811,7 @@ components: - name - relations properties: - parentRule: + parentRuleId: $ref: '#/components/schemas/UUID' groupOwnerId: $ref: '#/components/schemas/UUID' @@ -1513,6 +1829,13 @@ components: UUID: type: string format: uuid + UpdateLabel: + type: object + required: + - name + properties: + name: + type: string UpdateMyProfile: type: object required: diff --git a/src/cli/cli.module.ts b/src/cli/cli.module.ts deleted file mode 100644 index 3f2bc104..00000000 --- a/src/cli/cli.module.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SequelizeModule } from '@nestjs/sequelize'; -import { CommandModule } from 'nestjs-command'; - -import { BankTransactionModule } from '../server/bank-transaction/bank-transaction.module'; -import { getDatabaseModule } from '../server/core/database'; -import { MyLoggerModule } from '../server/core/logger.module'; -import { GraphsModule } from '../server/graphs/graphs.module'; -import { SessionModule } from '../server/session/session.module'; -import { CliConfigModule } from './config/cli-config.module'; -import { DemoCommand } from './demo.command'; -import { DemoDataService } from './demo-data.service'; -import { LogsCommands } from './logs.command'; -import { MigrationsCommand } from './migrations.command'; -import { ImporterModule } from '../server/importer/importer.module'; - -@Module({ - imports: [ - CommandModule, - SequelizeModule.forRoot({ - ...getDatabaseModule(), - models: [], - autoLoadModels: true, - synchronize: true, - }), - SessionModule, - MyLoggerModule, - CliConfigModule, - GraphsModule, - BankTransactionModule, - ImporterModule, - ], - providers: [DemoCommand, LogsCommands, MigrationsCommand, DemoDataService], -}) -export class CliModule {} diff --git a/src/cli/config/cli-config.module.ts b/src/cli/config/cli-config.module.ts deleted file mode 100644 index bf98ab45..00000000 --- a/src/cli/config/cli-config.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { dotenvLoader, TypedConfigModule } from 'nest-typed-config'; - -import { CliConfig } from './cli-config'; - -export const CliConfigModule = TypedConfigModule.forRoot({ - schema: CliConfig, - load: dotenvLoader({ - /* options */ - }), -}); diff --git a/src/cli/config/cli-config.ts b/src/cli/config/cli-config.ts deleted file mode 100644 index a8da2e57..00000000 --- a/src/cli/config/cli-config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsString } from 'class-validator'; - -export class CliConfig { - @IsString() - migrationsFolder = './migrations'; -} diff --git a/src/cli/demo-data.service.ts b/src/cli/demo-data.service.ts deleted file mode 100644 index 83393d7b..00000000 --- a/src/cli/demo-data.service.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { isBefore, sub } from 'date-fns'; -import { match } from 'ts-pattern'; - -import { BankTransactionService } from '../server/bank-transaction/bank-transaction.service'; -import { DateOnly } from '../server/common/custom-types/date-only'; -import { GraphDateRange, GraphGroup, GraphKind } from '../server/graphs/models/graph.model'; -import { GraphService } from '../server/graphs/services/graph.service'; -import { LabelService } from '../server/graphs/services/label.service'; - -interface Range { - max: number; - min: number; -} - -@Injectable() -export class DemoDataService { - constructor( - private readonly transactionService: BankTransactionService, - private readonly labelService: LabelService, - private readonly graphService: GraphService, - ) {} - - async generateTagAndTransactions( - groupOwnerId: number, - label: string, - amountRange: Range, - periodicity: 'days' | 'montly', - ) { - const labelData = await this.labelService.createLabel({ name: label, groupOwnerId }); - const nextFn = match(periodicity) - .with('days', () => () => ({ days: Math.floor(Math.random() * 10 + 1) })) - .with('montly', () => () => ({ months: 1, days: Math.floor(Math.random() * 2 - 1) })) - .exhaustive(); - const getAmount = (() => { - const { min, max } = amountRange; - const diff = max - min; - return () => Math.random() * diff + min; - })(); - - let date = match(periodicity) - .with('days', () => sub(new Date(), nextFn())) - .with('montly', () => { - const newDate = new Date(); - newDate.setDate(5); - return newDate; - }) - .exhaustive(); - - const finish = sub(new Date(), { months: 6 }); - finish.setDate(0); - - while (isBefore(finish, date)) { - // eslint-disable-next-line no-await-in-loop - const transaction = await this.transactionService.addTransaction({ - date: new DateOnly(date).toString(), - groupOwnerId, - kind: 'demo', - movementName: `transaction ${label}`, - value: getAmount(), - }); - // eslint-disable-next-line no-await-in-loop - await this.labelService.addTransaction({ labelId: labelData.dataValues.id, transactionId: transaction.id }); - date = sub(date, nextFn()); - } - return labelData.id; - } - - async generateGraphs(groupOwnerId: number, labelIdMap: Record) { - await this.graphService.createGraph({ - dateRange: GraphDateRange.halfYear, - group: { - group: GraphGroup.Sign, - }, - groupOwnerId, - horizontalGroup: { - group: GraphGroup.Month, - }, - kind: GraphKind.Bar, - name: 'Income vs expenses', - }); - await this.graphService.createGraph({ - dateRange: GraphDateRange.twoYears, - group: { - group: GraphGroup.Labels, - labels: [labelIdMap.groceries, labelIdMap.gasoline], - }, - groupOwnerId, - horizontalGroup: { - group: GraphGroup.Month, - }, - kind: GraphKind.Line, - name: 'Compare labels', - }); - } - - async generateAll(groupOwnerId: number) { - const labelConfigMap: Record = { - gasoline: { amount: { max: -30, min: -100 }, periodicity: 'days' }, - groceries: { - amount: { max: -10, min: -100 }, - periodicity: 'days', - }, - mortgage: { - amount: { max: -400, min: -400 }, - periodicity: 'montly', - }, - phone: { - amount: { max: -10, min: -15 }, - periodicity: 'montly', - }, - salary: { - amount: { max: 2100, min: 1800 }, - periodicity: 'montly', - }, - }; - const labelsList = (await Promise.all( - Object.keys(labelConfigMap).map(async label => { - const { amount, periodicity } = labelConfigMap[label] ?? {}; - if (amount && periodicity) { - const labelId = await this.generateTagAndTransactions(groupOwnerId, label, amount, periodicity); - return [label, labelId]; - } - - return undefined; - }), - )) as Array<[string, number]>; - - const labelIdMap = labelsList.reduce((acc, [label, labelId]) => { - acc[label] = labelId; - return acc; - }, {} as Record); - await this.generateGraphs(groupOwnerId, labelIdMap); - } -} diff --git a/src/cli/demo.command.ts b/src/cli/demo.command.ts deleted file mode 100644 index d0fdc6a7..00000000 --- a/src/cli/demo.command.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Command, Option } from 'nestjs-command'; - -import { UserProfileService } from '../server/session/services/user-profile.service'; -import { DemoDataService } from './demo-data.service'; - -@Injectable() -export class DemoCommand { - constructor(readonly userService: UserProfileService, readonly demoDataService: DemoDataService) {} - - @Command({ - command: 'demouser', - describe: 'create a user demo', - }) - async demoUser( - @Option({ name: 'user', alias: 'u', default: 'demo' }) username: string, - @Option({ name: 'password', alias: 'p', default: 'demo' }) password: string, - ) { - const userInfo = await this.userService.addUser(username, password, { isActive: true }); - // eslint-disable-next-line no-console - console.log(`User added ${userInfo.id}: ${userInfo.username} with groupId: ${userInfo.groupId}`); - } - - @Command({ - command: 'demodata', - describe: 'will generate demo data for a group', - }) - async demoData(@Option({ name: 'group', alias: 'g', requiresArg: true, type: 'number' }) groupId: number) { - await this.demoDataService.generateAll(groupId); - } -} diff --git a/src/cli/logs.command.ts b/src/cli/logs.command.ts deleted file mode 100644 index a727eae2..00000000 --- a/src/cli/logs.command.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Command } from 'nestjs-command'; - -import { Exception } from '../server/core/errors/exception'; - -@Injectable() -export class LogsCommands { - private logger = new Logger('LogsCommands'); - - @Command({ - command: 'show-logger-examples', - describe: 'Get some examples of logging', - }) - getLogs() { - this.logger.log('Getting logs...'); - this.logger.debug({ something: 'extra' }, 'Debug log'); - this.logger.warn('This is some warning with %s', 'extra'); - this.logger.error({ err: new Exception('E10000', 'Test message', { context: 'extra' }) }, 'we have some error'); - } -} diff --git a/src/cli/migrations.command.ts b/src/cli/migrations.command.ts deleted file mode 100644 index 21d15ff7..00000000 --- a/src/cli/migrations.command.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectConnection } from '@nestjs/sequelize'; -import fs from 'fs'; -import { Command, Option } from 'nestjs-command'; -import path from 'path'; -import * as migrate from 'sequelize-auto-migrations-vk/lib/migrate'; -import { Sequelize } from 'sequelize-typescript'; - -import { CliConfig } from './config/cli-config'; -import { Exception } from '../server/core/errors/exception'; - -interface migrationState { - revision: number; - tables: Record; - version: number; -} - -@Injectable() -export class MigrationsCommand { - private logger = new Logger('MigrationsCommand'); - - constructor(@InjectConnection() private readonly sequelize: Sequelize, private readonly config: CliConfig) {} - - // eslint-disable-next-line max-lines-per-function - @Command({ - command: 'makemigrations', - }) - makeMigrations( - @Option({ name: 'preview', default: false, type: 'boolean' }) preview: boolean, - @Option({ name: 'name', default: 'no-name' }) name: string, - ) { - const { sequelize, config } = this; - const migrationsDir = config.migrationsFolder; - if (!fs.existsSync(migrationsDir)) { - throw new Exception('E10005', `Migrations folder does not exists`, { migrationsDir }); - } - - // current state - const currentState: migrationState = { - tables: {}, - revision: 0, - version: 0, - }; - - // load last state - let previousState: migrationState = { - revision: 0, - version: 1, - tables: {}, - }; - - try { - previousState = JSON.parse(fs.readFileSync(path.join(migrationsDir, '_current.json')).toString()); - // eslint-disable-next-line id-length, no-empty - } catch (e) {} - - const { models } = sequelize; - - currentState.tables = migrate.reverseModels(sequelize, models); - const actions = migrate.parseDifference(previousState.tables, currentState.tables); - // sort actions - migrate.sortActions(actions); - const migration = migrate.getMigration(actions); - if (migration.commandsUp.length === 0) { - this.logger.log('No changes found'); - process.exit(0); - } - - /* - * log migration actions - * _.each(migration.consoleOut, (v) => { console.log("[Actions] " + v) }); - */ - if (preview) { - migration.commandsUp.forEach(commandStr => { - // const command : migrate.MigrationCmd = eval(commandStr) - this.logger.log(commandStr, 'Migration:'); - }); - process.exit(0); - } - - // backup _current file - if (fs.existsSync(path.join(migrationsDir, '_current.json'))) - fs.writeFileSync( - path.join(migrationsDir, '_current_bak.json'), - fs.readFileSync(path.join(migrationsDir, '_current.json')), - ); - - // save current state - currentState.revision = previousState.revision + 1; - fs.writeFileSync(path.join(migrationsDir, '_current.json'), JSON.stringify(currentState, null, 4)); - - // write migration to file - const info = migrate.writeMigration(Date.now(), migration, migrationsDir, name, ''); - - this.logger.log(`New migration to revision ${currentState.revision} has been saved to file '${info.filename}'`); - } -} diff --git a/src/swift-server-macros/entrypoint.swift b/src/swift-server-macros/entrypoint.swift new file mode 100644 index 00000000..dd47bc07 --- /dev/null +++ b/src/swift-server-macros/entrypoint.swift @@ -0,0 +1,13 @@ +@attached(accessor) +public macro ServiceDependency() = + #externalMacro(module: "swift_macrosMacros", type: "ServiceDependencyMacro") + +public protocol StringEnumType: Hashable & RawRepresentable where Self.RawValue == String {} + +@freestanding(expression) +public macro BasicBadRequest(msg: String, code: any StringEnumType) -> T = + #externalMacro(module: "swift_macrosMacros", type: "BasicBadRequest") + +@freestanding(expression) +public macro BasicNotFound(msg: String, code: any StringEnumType) -> T = + #externalMacro(module: "swift_macrosMacros", type: "BasicNotFound") diff --git a/src/swift-server-macrosMacros/app-dependency.swift b/src/swift-server-macrosMacros/app-dependency.swift new file mode 100644 index 00000000..12119931 --- /dev/null +++ b/src/swift-server-macrosMacros/app-dependency.swift @@ -0,0 +1,48 @@ +import Foundation +import SwiftCompilerPlugin +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct ServiceDependencyMacro: AccessorMacro { + + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + providingAccessorsOf declaration: some SwiftSyntax.DeclSyntaxProtocol, + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.AccessorDeclSyntax] { + guard let varDecl = declaration.as(VariableDeclSyntax.self) else { + let invalidUsageErr = Diagnostic( + node: node.root, + message: "Invalid declaration" as! DiagnosticMessage) + context.diagnose(invalidUsageErr) + return [] + } + + guard let nodeType = varDecl.bindings.first?.typeAnnotation?.type else { + let missingAnnotationErr = Diagnostic( + node: node.root, message: "Missing anotation" as! DiagnosticMessage) + context.diagnose(missingAnnotationErr) + return [] + } + return [ + """ + get { + guard let service = self.storage[\(nodeType).self] else { + let newService = \(nodeType)(app: self) + self.storage[\(nodeType).self] = newService + return newService + } + return service + } + """, + """ + set { + self.storage[\(nodeType).self] = newValue + } + """, + ] + } + +} diff --git a/src/swift-server-macrosMacros/basic-errors.swift b/src/swift-server-macrosMacros/basic-errors.swift new file mode 100644 index 00000000..c344651f --- /dev/null +++ b/src/swift-server-macrosMacros/basic-errors.swift @@ -0,0 +1,67 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +enum CustomError: Error { + case message(String) +} + +public struct BasicBadRequest: ExpressionMacro { + public static func expansion( + of node: some SwiftSyntax.FreestandingMacroExpansionSyntax, + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> ExprSyntax { + guard + let message = node.arguments.first?.expression.as( + StringLiteralExprSyntax.self)?.representedLiteralValue + else { + throw CustomError.message("First parameter should be an string") + } + + guard + let expression = node.arguments.dropFirst().first?.expression.as( + MemberAccessExprSyntax.self) + else { + throw CustomError.message("Invalid code") + } + let text = expression.declName.baseName.text + return """ + .badRequest( + .init( + body: .json( + .init( + message: "\(raw: message)", + code: "\(raw: text)")))) + """ + } +} + + +public struct BasicNotFound: ExpressionMacro { + public static func expansion( + of node: some SwiftSyntax.FreestandingMacroExpansionSyntax, + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> ExprSyntax { + guard + let message = node.arguments.first?.expression.as( + StringLiteralExprSyntax.self)?.representedLiteralValue + else { + throw CustomError.message("First parameter should be an string") + } + + guard + let expression = node.arguments.dropFirst().first?.expression.as( + MemberAccessExprSyntax.self) + else { + throw CustomError.message("Invalid code") + } + let text = expression.declName.baseName.text + return """ + .notFound( + .init( + body: .json( + .init( + message: "\(raw: message)", + code: "\(raw: text)")))) + """ + } +} diff --git a/src/swift-server-macrosMacros/entrypoint.swift b/src/swift-server-macrosMacros/entrypoint.swift new file mode 100644 index 00000000..bd11ba3a --- /dev/null +++ b/src/swift-server-macrosMacros/entrypoint.swift @@ -0,0 +1,15 @@ +import Foundation +import SwiftCompilerPlugin +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +@main +struct swift_macrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + ServiceDependencyMacro.self, + BasicBadRequest.self, + BasicNotFound.self, + ] +} diff --git a/src/swift-server-tests/app-test.swift b/src/swift-server-tests/app-test.swift index f5e0ede6..4fc9a6d4 100644 --- a/src/swift-server-tests/app-test.swift +++ b/src/swift-server-tests/app-test.swift @@ -39,6 +39,7 @@ class AbstractBaseTestsClass: XCTestCase { var testAdmin: User! var testGroup: UserGroup! var testGroup2: UserGroup! + var testGroup3: UserGroup! var labels: [Label]! var testIds: [String: UUID]! @@ -68,6 +69,7 @@ class AbstractBaseTestsClass: XCTestCase { // Create a test group testGroup2 = try await createGroup(app: app, name: "Other Group") testGroup = try await createGroup(app: app, name: "Test Group") + testGroup3 = try await createGroup(app: app, name: "Other group for main") let testGroupId = try testGroup.requireID() let testGroupId2 = try testGroup2.requireID() @@ -76,6 +78,7 @@ class AbstractBaseTestsClass: XCTestCase { app: app, username: "test-user", email: "test@example.com", password: "test-password", defaultGroupId: testGroupId) try await testUser.$groups.attach(testGroup, on: app.db) + try await testUser.$groups.attach(testGroup3, on: app.db) testAdmin = try await createUser( app: app, username: "admin", email: "admin@example.com", diff --git a/src/swift-server-tests/importers/importer-caixa-enginyers.tests.swift b/src/swift-server-tests/importers/importer-caixa-enginyers.tests.swift index dad63c2d..48e6fc1e 100644 --- a/src/swift-server-tests/importers/importer-caixa-enginyers.tests.swift +++ b/src/swift-server-tests/importers/importer-caixa-enginyers.tests.swift @@ -27,12 +27,12 @@ final class CaixaEnginyersImporterTests: BaseImporterTests { fileName: "MovimientosCuenta.xls", filePath: filePath.path) let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) XCTAssertEqual(reports.list.first?.status, .ok) let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 4) // Check specific transactions @@ -76,14 +76,14 @@ final class CaixaEnginyersImporterTests: BaseImporterTests { fileName: "MovimientosTarjetaCredito.xls", filePath: filePath.path) let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) XCTAssertEqual(reports.list.first?.status, .ok) XCTAssertEqual(reports.list.first?.description, "") XCTAssertNil(reports.list.first?.context) let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 1) // Test specific transaction details diff --git a/src/swift-server-tests/importers/importer-commerz-bank.swift b/src/swift-server-tests/importers/importer-commerz-bank.swift index 18cadb28..af802e07 100644 --- a/src/swift-server-tests/importers/importer-commerz-bank.swift +++ b/src/swift-server-tests/importers/importer-commerz-bank.swift @@ -33,14 +33,14 @@ final class CommerzBankEnImporterTests: BaseImporterTests { fileName: "CommerzBank", filePath: filePath.path) let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) XCTAssertEqual(reports.list.first?.status, .ok) XCTAssertEqual(reports.list.first?.description, "") XCTAssertNil(reports.list.first?.context) let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 12) checkMovement( diff --git a/src/swift-server-tests/importers/importer-helper.swift b/src/swift-server-tests/importers/importer-helper.swift index 06598ef0..1f4cb4cd 100644 --- a/src/swift-server-tests/importers/importer-helper.swift +++ b/src/swift-server-tests/importers/importer-helper.swift @@ -107,9 +107,9 @@ class BaseImporterTests: XCTestCase { try await self.group.save(on: app.db) let testParsers: [ParserFactory] = try getParsers() - importerService = NewImportService(parsers: testParsers) - bankTransactionService = BankTransactionService() - statusReportsService = FileImportService() + importerService = NewImportService(parsers: testParsers, withApp: app) + bankTransactionService = app.bankTransactionService + statusReportsService = app.fileImportService } override func tearDown() async throws { diff --git a/src/swift-server-tests/importers/importer-n26.tests.swift b/src/swift-server-tests/importers/importer-n26.tests.swift index f9661314..18ac771d 100644 --- a/src/swift-server-tests/importers/importer-n26.tests.swift +++ b/src/swift-server-tests/importers/importer-n26.tests.swift @@ -24,12 +24,12 @@ final class N26ImporterTests: BaseImporterTests { fileName: "n26-file.csv", filePath: filePath.path) let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) XCTAssertEqual(reports.list.first?.status, .ok) let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 3) // Check specific transaction @@ -50,7 +50,7 @@ final class N26ImporterTests: BaseImporterTests { fileName: "n26-file.csv", filePath: filePath) let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) XCTAssertEqual(reports.list.first?.status, .error) XCTAssertEqual(reports.list.first?.fileName, "n26-file.csv") diff --git a/src/swift-server-tests/importers/importer-service.tests.swift b/src/swift-server-tests/importers/importer-service.tests.swift index a3487e04..5e65240a 100644 --- a/src/swift-server-tests/importers/importer-service.tests.swift +++ b/src/swift-server-tests/importers/importer-service.tests.swift @@ -22,14 +22,14 @@ final class ImporterServiceTests: BaseImporterTests { fileName: "test-file.csv", filePath: "whatever") let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) XCTAssertEqual(reports.list.first?.status, .ok) XCTAssertEqual(reports.list.first?.description, "") XCTAssertEqual(reports.list.first?.context, nil) let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 4) } @@ -43,7 +43,7 @@ final class ImporterServiceTests: BaseImporterTests { fileName: "some-file", filePath: "someother") let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) print(reports) XCTAssertEqual(reports.list.count, 1) let importReport = reports.list.first @@ -61,7 +61,7 @@ final class ImporterServiceTests: BaseImporterTests { let repeatedTransaction = try TestBasicImporter().transformHelper.map(repeatedInfo) .toBankTransaction(kind: "test-account", groupOwnerId: groupOwnerId) let _ = try await bankTransactionService.addTransaction( - on: db, withQueue: getQueue(), transaction: repeatedTransaction) + transaction: repeatedTransaction) // Import the file let _ = try await importerService.importFromFile( @@ -71,13 +71,13 @@ final class ImporterServiceTests: BaseImporterTests { // Check import status let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) XCTAssertEqual(reports.list.first?.status, .ok) // Check transactions let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 5) // Check status report rows @@ -103,9 +103,9 @@ final class ImporterServiceTests: BaseImporterTests { SAMPLE_DATA[2] ).toBankTransaction(kind: "test-account", groupOwnerId: groupOwnerId) let _ = try await bankTransactionService.addTransaction( - on: db, withQueue: getQueue(), transaction: repeatedTransaction) + transaction: repeatedTransaction) let _ = try await bankTransactionService.addTransaction( - on: db, withQueue: getQueue(), transaction: repeatedTransaction2) + transaction: repeatedTransaction2) // Import the file let _ = try await importerService.importFromFile( @@ -115,13 +115,13 @@ final class ImporterServiceTests: BaseImporterTests { // Check import status let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) XCTAssertEqual(reports.list.first?.status, .warn) // Check transactions let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 4) // Check status report rows @@ -151,7 +151,7 @@ final class ImporterServiceTests: BaseImporterTests { // Check import status let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 2) XCTAssertEqual(reports.list[1].status, .ok) XCTAssertEqual(reports.list[1].description, "") @@ -160,7 +160,7 @@ final class ImporterServiceTests: BaseImporterTests { // Check transactions let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 4) // Check status report rows @@ -180,7 +180,7 @@ final class ImporterServiceTests: BaseImporterTests { // Check import status let reports = try await statusReportsService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(reports.list.count, 1) let report = reports.list.first @@ -194,7 +194,7 @@ final class ImporterServiceTests: BaseImporterTests { // Check that no transactions were imported let (transactions, _) = try await bankTransactionService.getAll( - on: db, groupIds: [groupOwnerId]) + groupIds: [groupOwnerId]) XCTAssertEqual(transactions.list.count, 0) // Check status report rows diff --git a/src/swift-server-tests/rules.tests.swift b/src/swift-server-tests/rules.tests.swift index e8ddf4d9..af5fca1c 100644 --- a/src/swift-server-tests/rules.tests.swift +++ b/src/swift-server-tests/rules.tests.swift @@ -6,62 +6,22 @@ import XCTest @testable import MrScroogeServer -struct BaseCondition { - let operation: ConditionOperation - let valueStr: String? - let valueDouble: Double? - - init(operation: ConditionOperation, valueStr: String) { - self.operation = operation - self.valueStr = valueStr - self.valueDouble = nil - } - - init(operation: ConditionOperation, valueDouble: Double) { - self.operation = operation - self.valueStr = nil - self.valueDouble = valueDouble - } -} - final class RulesTests: AbstractBaseTestsClass { var operationToLabel: [ConditionOperation: UUID] = [:] - private func createRule( - on db: Database, for userGroup: UserGroup, - with conditions: BaseCondition..., toApply labels: Label... - ) async throws -> Rule { - let rule = ruleFactory.build() - rule.$groupOwner.id = try userGroup.requireID() - try await rule.save(on: db) - let ruleId = try rule.requireID() - - for cond in conditions { - try await rule.$conditions.create( - .init( - ruleId: ruleId, - operation: cond.operation, - valueStr: cond.valueStr, - valueDouble: cond.valueDouble - ), on: db) - } - for label in labels { - try await rule.$labels.create( - .init( - ruleId: rule.id!, - labelId: label.id! - ), on: db) - } + var bankTransactionService: BankTransactionService! - return rule + override func setUp() { + super.setUp() + bankTransactionService = app!.bankTransactionService } private func createBasicRuleAndRegister( - on db: Database, for userGroup: UserGroup, with condition: BaseCondition, + on db: Database, for userGroup: UserGroup, with condition: Rule.BaseCondition, toApply label: Label ) async throws -> Rule { - let rule = try await self.createRule( - on: db, for: userGroup, with: condition, toApply: label) + let rule = try await Rule.createRule( + on: db, for: try userGroup.requireID(), with: condition, toApply: label) operationToLabel[condition.operation] = label.id! return rule } @@ -71,30 +31,30 @@ final class RulesTests: AbstractBaseTestsClass { // String operations let _ = try await createBasicRuleAndRegister( - on: db, for: testGroup, with: .init(operation: .prefix, valueStr: "needle"), + on: db, for: testGroup, with: .init(.prefix, valueStr: "needle"), toApply: labels[0]) let _ = try await createBasicRuleAndRegister( on: db, for: testGroup, - with: .init(operation: .regularExpression, valueStr: "A{2}B{2}"), + with: .init(.regularExpression, valueStr: "A{2}B{2}"), toApply: labels[1]) let _ = try await createBasicRuleAndRegister( - on: db, for: testGroup, with: .init(operation: .suffix, valueStr: "needle"), + on: db, for: testGroup, with: .init(.suffix, valueStr: "needle"), toApply: labels[2]) let _ = try await createBasicRuleAndRegister( on: db, for: testGroup, - with: .init(operation: .contains, valueStr: "needle"), toApply: labels[3]) + with: .init(.contains, valueStr: "needle"), toApply: labels[3]) // Float operations let _ = try await createBasicRuleAndRegister( - on: db, for: testGroup, with: .init(operation: .greater, valueDouble: 1), + on: db, for: testGroup, with: .init(.greater, valueDouble: 1), toApply: labels[4]) let _ = try await createBasicRuleAndRegister( on: db, for: testGroup, - with: .init(operation: .greaterEqual, valueDouble: 1), toApply: labels[5]) + with: .init(.greaterEqual, valueDouble: 1), toApply: labels[5]) let _ = try await createBasicRuleAndRegister( - on: db, for: testGroup, with: .init(operation: .less, valueDouble: -1), + on: db, for: testGroup, with: .init(.less, valueDouble: -1), toApply: labels[6]) let _ = try await createBasicRuleAndRegister( - on: db, for: testGroup, with: .init(operation: .lessEqual, valueDouble: -1), + on: db, for: testGroup, with: .init(.lessEqual, valueDouble: -1), toApply: labels[7]) } @@ -160,7 +120,7 @@ final class RulesTests: AbstractBaseTestsClass { for transaction in transactions { let _ = try await bankTransactionService.addTransaction( - on: app.db, withQueue: app.queues.queue, transaction: transaction) + transaction: transaction) } try await app.queues.queue.worker.run() @@ -218,15 +178,15 @@ final class RulesTests: AbstractBaseTestsClass { let app = try getApp() let testGroupId = try testGroup.requireID() - let _ = try await createRule( - on: app.db, for: testGroup, - with: .init(operation: .prefix, valueStr: "needle"), - .init(operation: .less, valueDouble: 0), + let _ = try await Rule.createRule( + on: app.db, for: try testGroup.requireID(), + with: .init(.prefix, valueStr: "needle"), + .init(.less, valueDouble: 0), toApply: labels[0]) - let rule = try await createRule( - on: app.db, for: testGroup, - with: .init(operation: .prefix, valueStr: "needle"), - .init(operation: .less, valueDouble: 0), + let rule = try await Rule.createRule( + on: app.db, for: try testGroup.requireID(), + with: .init(.prefix, valueStr: "needle"), + .init(.less, valueDouble: 0), toApply: labels[1]) rule.conditionsRelation = .notAnd try await rule.save(on: app.db) @@ -260,7 +220,7 @@ final class RulesTests: AbstractBaseTestsClass { for transaction in transactions { let _ = try await bankTransactionService.addTransaction( - on: app.db, withQueue: app.queues.queue, transaction: transaction) + transaction: transaction) } try await app.queues.queue.worker.run() @@ -290,13 +250,13 @@ final class RulesTests: AbstractBaseTestsClass { let app = try getApp() let testGroupId = try testGroup.requireID() - let rule1 = try await createRule( - on: app.db, for: testGroup, - with: .init(operation: .prefix, valueStr: "needle"), + let rule1 = try await Rule.createRule( + on: app.db, for: try testGroup.requireID(), + with: .init(.prefix, valueStr: "needle"), toApply: labels[0]) - let rule2 = try await createRule( - on: app.db, for: testGroup, - with: .init(operation: .less, valueDouble: 0), + let rule2 = try await Rule.createRule( + on: app.db, for: try testGroup.requireID(), + with: .init(.less, valueDouble: 0), toApply: labels[1]) rule2.$parent.id = try rule1.requireID() try await rule2.save(on: app.db) @@ -330,7 +290,7 @@ final class RulesTests: AbstractBaseTestsClass { for transaction in transactions { let _ = try await bankTransactionService.addTransaction( - on: app.db, withQueue: app.queues.queue, transaction: transaction) + transaction: transaction) } try await app.queues.queue.worker.run() @@ -350,4 +310,444 @@ final class RulesTests: AbstractBaseTestsClass { XCTAssertEqual(labelsForTransactions[2].count, 1) XCTAssertEqual(labelsForTransactions[3].count, 0) } + + func testGetRules() async throws { + let app = try getApp() + + try await createBasicRules() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + + let response = try await app.sendRequest(.GET, "/api/rules", headers: headers) + + XCTAssertEqual(response.status, .ok) + + let data = try response.content.decode( + Operations.ApiRule_list.Output.Ok.Body.jsonPayload.self) + + let cursorResults = data.results + XCTAssertEqual(cursorResults.count, 8) + XCTAssertEqual(cursorResults.first?.conditions.count, 1) + XCTAssertEqual(cursorResults.first?.labelIds.count, 1) + } + + func testCreateRule() async throws { + let app = try getApp() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + + let NewRule = Components.Schemas.RuleParam( + groupOwnerId: testGroup.id!.uuidString, + name: "Some rule", + relations: .or + ) + + let response = try await app.sendRequest( + .POST, "/api/rules", body: NewRule, headers: headers) + + XCTAssertEqual(response.status, .created) + + let rule = try? response.content.decode(Components.Schemas.Rule.self) + + XCTAssertNotNil(rule) + XCTAssertEqual(rule?.name, "Some rule") + XCTAssertEqual(rule?.relations, .or) + XCTAssertEqual(rule?.conditions.count, 0) + XCTAssertEqual(rule?.labelIds.count, 0) + } + + func testUpdateRule() async throws { + let app = try getApp() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + + let ruleToChange = try await Rule.createRule( + on: app.db, for: try testGroup3.requireID(), name: "Rule to change", + with: .init(.less, valueDouble: 0), toApply: labels[0]) + let otherRuleId = try await Rule.createRule( + on: app.db, for: try testGroup3.requireID(), name: "Rule to not change", + with: .init(.less, valueDouble: 1), .init(.contains, valueStr: "something"), + toApply: labels[1], labels[2], labels[3] + ).requireID() + let ruleId = try ruleToChange.requireID() + let groupOwnerId = try testGroup.requireID().uuidString + + let NewRule = Components.Schemas.RuleParam( + groupOwnerId: groupOwnerId, + name: "Change Rule", + relations: .notAnd + ) + + let response = try await app.sendRequest( + .PUT, "/api/rules/\(ruleId.uuidString)", body: NewRule, headers: headers) + + XCTAssertEqual(response.status, .ok) + + let rule = try? response.content.decode(Components.Schemas.Rule.self) + + XCTAssertNotNil(rule) + XCTAssertEqual(rule?.name, "Change Rule") + XCTAssertEqual(rule?.relations, .notAnd) + XCTAssertEqual(rule?.conditions.count, 1) + XCTAssertEqual(rule?.labelIds.count, 1) + + let otherRule = try await Rule.query(on: app.db).with(\.$conditions).with(\.$labels) + .filter(\.$id == otherRuleId).first() + + XCTAssertNotNil(otherRule) + XCTAssertEqual(otherRule?.name, "Rule to not change") + XCTAssertEqual(otherRule?.conditionsRelation, .or) + XCTAssertEqual(otherRule?.conditions.count, 2) + XCTAssertEqual(otherRule?.labels.count, 3) + } + + func testDeleteRule() async throws { + let app = try getApp() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + let groupOwnerId = try testGroup.requireID() + + let ruleToChange = try await Rule.createRule( + on: app.db, for: groupOwnerId, name: "Rule to delete", + with: .init(.less, valueDouble: 0), toApply: labels[0]) + + let otherRuleId = try await Rule.createRule( + on: app.db, for: try testGroup3.requireID(), name: "Rule to not change", + with: .init(.less, valueDouble: 1), .init(.contains, valueStr: "something"), + toApply: labels[1], labels[2], labels[3] + ).requireID() + let ruleId = try ruleToChange.requireID() + + let transaction = try await bankTransactionService.addTransaction( + transaction: .init( + groupOwnerId: groupOwnerId, movementName: "Test", + date: DateOnly(year: 2020, month: 07, day: 20)!, value: -10, + kind: "test")) + + try await app.queues.queue.worker.run() + + let response = try await app.sendRequest( + .DELETE, "/api/rules/\(ruleId.uuidString)", headers: headers) + + XCTAssertEqual(response.status, .ok) + + let exist = try await Rule.query(on: app.db).filter(\.$id == ruleId).count() > 0 + XCTAssertFalse(exist) + + let otherRule = try await Rule.query(on: app.db).with(\.$conditions).with(\.$labels) + .filter(\.$id == otherRuleId).first() + + XCTAssertNotNil(otherRule) + XCTAssertEqual(otherRule?.name, "Rule to not change") + XCTAssertEqual(otherRule?.conditionsRelation, .or) + XCTAssertEqual(otherRule?.conditions.count, 2) + XCTAssertEqual(otherRule?.labels.count, 3) + + let transactionId = try transaction.requireID() + let labels = try await LabelTransaction.query(on: app.db).filter( + \.$transaction.$id == transactionId + ).all() + XCTAssertEqual(labels.count, 1) + let label = labels.first + XCTAssertEqual(label?.linkReason, .manualEnabled) + } + + func testAddRuleCondition() async throws { + + let app = try getApp() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + let groupOwnerId = try testGroup.requireID() + + let ruleToAddCond = try await Rule.createRule( + on: app.db, for: groupOwnerId, name: "Rule to delete", + toApply: labels[0]) + + let otherRuleId = try await Rule.createRule( + on: app.db, for: try testGroup3.requireID(), name: "Rule to not change", + with: .init(.less, valueDouble: 1), .init(.contains, valueStr: "something"), + toApply: labels[1], labels[2], labels[3] + ).requireID() + let ruleId = try ruleToAddCond.requireID() + + let conditionInput = Components.Schemas.ConditionParams.ConditionParamString( + .init(operation: .contains, value: "condition")) + + let response = try await app.sendRequest( + .POST, "/api/rules/\(ruleId.uuidString)/condition", + body: ["condition": conditionInput], headers: headers) + + XCTAssertEqual(response.status, .ok) + let error = try? response.content.decode(Components.Schemas._Error.self) + XCTAssertNil(error) + + let ruleResponse = try response.content.decode(Components.Schemas.Rule.self) + + XCTAssertEqual(ruleResponse.conditions.count, 1) + let condition = ruleResponse.conditions.first + switch condition { + case .ConditionString(let condition): + XCTAssertEqual(condition.operation, .contains) + XCTAssertEqual(condition.value, "condition") + case .ConditionDouble: + XCTAssertTrue(false) + case .none: + XCTAssertTrue(false) + } + + // Verify otherRule was not changed + let otherRule = try await Rule.query(on: app.db).with(\.$conditions).with(\.$labels) + .filter(\.$id == otherRuleId).first() + + XCTAssertNotNil(otherRule) + XCTAssertEqual(otherRule?.name, "Rule to not change") + XCTAssertEqual(otherRule?.conditions.count, 2) + XCTAssertEqual(otherRule?.labels.count, 3) + } + + func testUpdateRuleCondition() async throws { + let app = try getApp() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + let groupOwnerId = try testGroup.requireID() + + let ruleToUpdateCond = try await Rule.createRule( + on: app.db, for: groupOwnerId, name: "Rule to delete", + with: .init(.contains, valueStr: "Something"), + toApply: labels[0]) + + try await ruleToUpdateCond.$conditions.load(on: app.db) + + let otherRule = try await Rule.createRule( + on: app.db, for: try testGroup3.requireID(), name: "Rule to not change", + with: .init(.less, valueDouble: 1), .init(.contains, valueStr: "something"), + toApply: labels[1], labels[2], labels[3] + ) + let otherRuleId = try otherRule.requireID() + let ruleId = try ruleToUpdateCond.requireID() + let conditionId = try ruleToUpdateCond.conditions.first!.requireID() + + let conditionInput = Components.Schemas.ConditionParams.ConditionParamDouble( + .init(operation: .greaterEqual, value: 0)) + + let response = try await app.sendRequest( + .PUT, "/api/rules/\(ruleId.uuidString)/condition/\(conditionId.uuidString)", + body: ["condition": conditionInput], headers: headers) + + XCTAssertEqual(response.status, .ok) + let error = try? response.content.decode(Components.Schemas._Error.self) + XCTAssertNil(error) + + let ruleResponse = try response.content.decode(Components.Schemas.Rule.self) + + XCTAssertEqual(ruleResponse.conditions.count, 1) + let condition = ruleResponse.conditions.first + switch condition { + case .ConditionDouble(let condition): + XCTAssertEqual(condition.operation, .greaterEqual) + XCTAssertEqual(condition.value, 0) + case .ConditionString: + XCTAssertTrue(false) + case .none: + XCTAssertTrue(false) + } + + // Verify other rule was not changed + let unchangedRule = try await Rule.query(on: app.db).with(\.$conditions).with( + \.$labels + ) + .filter(\.$id == otherRuleId).first() + XCTAssertEqual(unchangedRule?.name, "Rule to not change") + XCTAssertEqual(unchangedRule?.conditions.count, 2) + XCTAssertEqual(unchangedRule?.labels.count, 3) + } + + func testDeleteCondition() async throws { + let app = try getApp() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + let groupOwnerId = try testGroup.requireID() + + let ruleToUpdateCond = try await Rule.createRule( + on: app.db, for: groupOwnerId, name: "Rule to delete", + with: .init(.contains, valueStr: "Something"), + .init(.greater, valueDouble: 5), + toApply: labels[0]) + + try await ruleToUpdateCond.$conditions.load(on: app.db) + + let otherRuleId = try await Rule.createRule( + on: app.db, for: try testGroup3.requireID(), name: "Rule to not change", + with: .init(.less, valueDouble: 1), .init(.contains, valueStr: "something"), + toApply: labels[1], labels[2], labels[3] + ).requireID() + let ruleId = try ruleToUpdateCond.requireID() + let conditionId = try ruleToUpdateCond.conditions.filter { + $0.operation == .contains + }.first!.requireID() + + let response = try await app.sendRequest( + .DELETE, + "/api/rules/\(ruleId.uuidString)/condition/\(conditionId.uuidString)", + headers: headers) + + XCTAssertEqual(response.status, .ok) + let error = try? response.content.decode(Components.Schemas._Error.self) + XCTAssertNil(error) + + let ruleResponse = try response.content.decode(Components.Schemas.Rule.self) + + XCTAssertEqual(ruleResponse.conditions.count, 1) + let condition = ruleResponse.conditions.first + switch condition { + case .ConditionDouble(let condition): + XCTAssertEqual(condition.operation, .greater) + XCTAssertEqual(condition.value, 5) + case .ConditionString: + XCTAssertTrue(false) + case .none: + XCTAssertTrue(false) + } + + // Verify other rule was not changed + let unchangedRule = try await Rule.query(on: app.db).with(\.$conditions).with( + \.$labels + ) + .filter(\.$id == otherRuleId).first() + + XCTAssertNotNil(unchangedRule) + XCTAssertEqual(unchangedRule?.name, "Rule to not change") + XCTAssertEqual(unchangedRule?.conditions.count, 2) + XCTAssertEqual(unchangedRule?.labels.count, 3) + } + + func testAddLabelToRule() async throws { + let app = try getApp() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + let groupOwnerId = try testGroup.requireID() + + let ruleToUpdate = try await Rule.createRule( + on: app.db, for: groupOwnerId, name: "Rule to update", + with: .init(.contains, valueStr: "Something"), + toApply: labels[0]) + + try await ruleToUpdate.$conditions.load(on: app.db) + + let otherRuleId = try await Rule.createRule( + on: app.db, for: try testGroup3.requireID(), name: "Rule to not change", + with: .init(.less, valueDouble: 1), .init(.contains, valueStr: "something"), + toApply: labels[1], labels[2], labels[3] + ).requireID() + + let ruleId = try ruleToUpdate.requireID() + let labelId = try labels[4].requireID() + + // Add label to rule + let response = try await app.sendRequest( + .PUT, + "/api/rules/\(ruleId.uuidString)/label/\(labelId.uuidString)", + headers: headers) + + XCTAssertEqual(response.status, .ok) + let error = try? response.content.decode(Components.Schemas._Error.self) + XCTAssertNil(error) + + let ruleResponse = try response.content.decode(Components.Schemas.Rule.self) + + XCTAssertEqual(ruleResponse.labelIds.count, 2) + XCTAssertTrue(ruleResponse.labelIds.contains(try labels[0].requireID().uuidString)) + XCTAssertTrue(ruleResponse.labelIds.contains(labelId.uuidString)) + + // Try adding same label again + let secondResponse = try await app.sendRequest( + .PUT, + "/api/rules/\(ruleId.uuidString)/label/\(labelId.uuidString)", + headers: headers) + + XCTAssertEqual(secondResponse.status, .ok) + let secondRuleResponse = try secondResponse.content.decode( + Components.Schemas.Rule.self) + XCTAssertEqual(secondRuleResponse.labelIds.count, 2) + + // Verify other rule was not changed + let unchangedRule = try await Rule.query(on: app.db).with(\.$conditions).with( + \.$labels + ) + .filter(\.$id == otherRuleId).first() + + XCTAssertNotNil(unchangedRule) + XCTAssertEqual(unchangedRule?.name, "Rule to not change") + XCTAssertEqual(unchangedRule?.conditions.count, 2) + XCTAssertEqual(unchangedRule?.labels.count, 3) + } + + func testRemoveLabelFromRule() async throws { + let app = try getApp() + + let headers = try await app.getHeaders( + forUser: .init(username: testUser.username, password: "test-password") + ) + let groupOwnerId = try testGroup.requireID() + + let ruleToUpdate = try await Rule.createRule( + on: app.db, for: groupOwnerId, name: "Rule to update", + with: .init(.contains, valueStr: "Something"), + toApply: labels[0], labels[4]) // Add both labels initially + + try await ruleToUpdate.$conditions.load(on: app.db) + + let otherRuleId = try await Rule.createRule( + on: app.db, for: try testGroup3.requireID(), name: "Rule to not change", + with: .init(.less, valueDouble: 1), .init(.contains, valueStr: "something"), + toApply: labels[1], labels[2], labels[3] + ).requireID() + + let ruleId = try ruleToUpdate.requireID() + let labelId = try labels[4].requireID() + + // Remove label from rule + let response = try await app.sendRequest( + .DELETE, + "/api/rules/\(ruleId.uuidString)/label/\(labelId.uuidString)", + headers: headers) + + XCTAssertEqual(response.status, .ok) + let error = try? response.content.decode(Components.Schemas._Error.self) + XCTAssertNil(error) + + let ruleResponse = try response.content.decode(Components.Schemas.Rule.self) + + XCTAssertEqual(ruleResponse.labelIds.count, 1) + XCTAssertTrue(ruleResponse.labelIds.contains(try labels[0].requireID().uuidString)) + XCTAssertFalse(ruleResponse.labelIds.contains(labelId.uuidString)) + + // Verify other rule was not changed + let unchangedRule = try await Rule.query(on: app.db).with(\.$conditions).with( + \.$labels + ) + .filter(\.$id == otherRuleId).first() + + XCTAssertNotNil(unchangedRule) + XCTAssertEqual(unchangedRule?.name, "Rule to not change") + XCTAssertEqual(unchangedRule?.conditions.count, 2) + XCTAssertEqual(unchangedRule?.labels.count, 3) + } + } diff --git a/src/swift-server/bank-transaction/bank-transaction-api-impl.swift b/src/swift-server/bank-transaction/bank-transaction-api-impl.swift index 1a6f5ea4..ac5452d4 100644 --- a/src/swift-server/bank-transaction/bank-transaction-api-impl.swift +++ b/src/swift-server/bank-transaction/bank-transaction-api-impl.swift @@ -1,14 +1,13 @@ import Foundation import OpenAPIRuntime -import OpenAPIVapor import Vapor -let bankTransactionService = BankTransactionService() extension MrScroogeAPIImpl { func ApiBankTransactions_linkLabel(_ input: Operations.ApiBankTransactions_linkLabel.Input) async throws -> Operations.ApiBankTransactions_linkLabel.Output { + let bankTransactionService = request.application.bankTransactionService let user = try await getUser(fromRequest: request) let validGroupsIds = try user.groups.map { return try $0.requireID() } guard let transactionId = UUID(uuidString: input.path.transactionId), @@ -17,12 +16,12 @@ extension MrScroogeAPIImpl { return .undocumented(statusCode: 400, UndocumentedPayload()) } let linkState = try await bankTransactionService.link( - on: request.db, transaction: transactionId, toLabel: labelId, + transaction: transactionId, toLabel: labelId, withValidGroups: validGroupsIds) switch linkState { case .ok: let (transaction, labelIds) = try await bankTransactionService.getAll( - on: request.db, groupIds: validGroupsIds, + groupIds: validGroupsIds, transactionIds: [transactionId]) return .ok( .init( @@ -66,6 +65,7 @@ extension MrScroogeAPIImpl { func ApiBankTransactions_unlinkLabel( _ input: Operations.ApiBankTransactions_unlinkLabel.Input ) async throws -> Operations.ApiBankTransactions_unlinkLabel.Output { + let bankTransactionService = request.application.bankTransactionService let user = try await getUser(fromRequest: request) let validGroupsIds = try user.groups.map { return try $0.requireID() } guard let transactionId = UUID(uuidString: input.path.transactionId), @@ -74,12 +74,12 @@ extension MrScroogeAPIImpl { return .undocumented(statusCode: 400, UndocumentedPayload()) } let unlinkState = try await bankTransactionService.unlink( - on: request.db, transaction: transactionId, fromLabel: labelId, + transaction: transactionId, fromLabel: labelId, withValidGroups: validGroupsIds) switch unlinkState { case .ok: let (transaction, labelIds) = try await bankTransactionService.getAll( - on: request.db, groupIds: validGroupsIds, + groupIds: validGroupsIds, transactionIds: [transactionId]) return .ok( .init( @@ -119,6 +119,7 @@ extension MrScroogeAPIImpl { func ApiBankTransactions_list(_ input: Operations.ApiBankTransactions_list.Input) async throws -> Operations.ApiBankTransactions_list.Output { + let bankTransactionService = request.application.bankTransactionService let req = request let user = try await getUser(fromRequest: req) let validGroupsIds = try user.groups.map { return try $0.requireID() } @@ -133,7 +134,7 @@ extension MrScroogeAPIImpl { } let (data, labelIds) = try await bankTransactionService.getAll( - on: req.db, groupIds: groupIds, + groupIds: groupIds, pageQuery: .init( limit: input.query.limit ?? 100, cursor: input.query.cursor)) let results = try data.list.map { movement in @@ -149,6 +150,7 @@ extension MrScroogeAPIImpl { func ApiBankTransactions_comment(_ input: Operations.ApiBankTransactions_comment.Input) async throws -> Operations.ApiBankTransactions_comment.Output { + let bankTransactionService = request.application.bankTransactionService let req = request let user = try await getUser(fromRequest: req) let validGroupsIds = try user.groups.map { return try $0.requireID() } @@ -169,7 +171,7 @@ extension MrScroogeAPIImpl { } let result = try await bankTransactionService.setComment( - on: request.db, for: transactionId, andComment: comment, + for: transactionId, andComment: comment, withValidGroups: validGroupsIds) switch result { case .notFound: @@ -183,7 +185,7 @@ extension MrScroogeAPIImpl { .transactionId)))) case .ok: let (transaction, labelIds) = try await bankTransactionService.getAll( - on: request.db, groupIds: validGroupsIds, + groupIds: validGroupsIds, transactionIds: [transactionId]) return .ok( .init( diff --git a/src/swift-server/bank-transaction/bank-transaction.service.swift b/src/swift-server/bank-transaction/bank-transaction.service.swift index 337ee401..4d366ed6 100644 --- a/src/swift-server/bank-transaction/bank-transaction.service.swift +++ b/src/swift-server/bank-transaction/bank-transaction.service.swift @@ -1,19 +1,11 @@ import Fluent -import Queues import Vapor -struct ListWithCursor { - let list: [T] - let next: String? -} - -class BankTransactionService { +class BankTransactionService: ServiceWithQueueAndDb { private let logger = Logger(label: "BankTransactionService") private let cursorHandler = CursorHandler(["date", "id"]) - init() {} - - func getActiveLabels(on db: Database, transactionsIds: [UUID]) async throws -> [UUID: + func getActiveLabels(transactionsIds: [UUID]) async throws -> [UUID: [UUID]] { let labelsPivot = try await LabelTransaction.query(on: db).filter( @@ -33,7 +25,7 @@ class BankTransactionService { } func getAll( - on db: Database, groupIds: [UUID], transactionIds: [UUID]? = nil, + groupIds: [UUID], transactionIds: [UUID]? = nil, pageQuery: PageQuery = .init(), query: [String: Any] = [:] ) async throws -> (ListWithCursor, [UUID: [UUID]]) { @@ -67,17 +59,8 @@ class BankTransactionService { .limit(pageQuery.limit) .all() - /*let hasMore = data.count >= limit - let cursorElement = hasMore ? data.last : nil - let nextCursor = cursorElement.map { - self.cursorHandler.stringify([ - "date": $0.date.toString(), - "id": $0.id?.uuidString ?? "", - ]) - }*/ - let relations = try await getActiveLabels( - on: db, transactionsIds: data.map { try $0.requireID() }) + transactionsIds: data.map { try $0.requireID() }) return ( pageQuery.getListWithCursor( @@ -93,7 +76,7 @@ class BankTransactionService { } func existsSimilar( - on db: Database, transaction: BankTransaction + transaction: BankTransaction ) async throws -> Bool { let similarCount = try await BankTransaction.query(on: db) .filter(\.$groupOwner.$id == transaction.$groupOwner.id) @@ -106,13 +89,13 @@ class BankTransactionService { return similarCount > 0 } - func insertBatch(on db: Database, movements: [BankTransaction]) -> EventLoopFuture { + func insertBatch(movements: [BankTransaction]) -> EventLoopFuture { return movements.create(on: db).map { _ in self.logger.info("Insert batch", metadata: ["sql": "Bulk insert"]) } } - func addTransaction(on db: Database, withQueue queue: Queue, transaction: BankTransaction) + func addTransaction(transaction: BankTransaction) async throws -> BankTransaction { @@ -133,7 +116,7 @@ class BankTransactionService { } func link( - on db: Database, transaction transactionId: UUID, toLabel labelId: UUID, + transaction transactionId: UUID, toLabel labelId: UUID, withValidGroups groupIds: [UUID] ) async throws -> LinkReturn { let transaction = try await BankTransaction.query(on: db).filter( @@ -179,7 +162,7 @@ class BankTransactionService { } func unlink( - on db: Database, transaction transactionId: UUID, fromLabel labelId: UUID, + transaction transactionId: UUID, fromLabel labelId: UUID, withValidGroups groupIds: [UUID] ) async throws -> UnlinkReturn { let transaction = try await BankTransaction.query(on: db).filter( @@ -209,7 +192,7 @@ class BankTransactionService { } func setComment( - on db: Database, for transactionId: UUID, andComment comment: String?, + for transactionId: UUID, andComment comment: String?, withValidGroups groupIds: [UUID] ) async throws -> setCommentReturn { let transaction = try await BankTransaction.query(on: db).filter( diff --git a/src/swift-server/core/cursor-handler.swift b/src/swift-server/core/cursor-handler.swift index d829ebe7..d65e72f0 100644 --- a/src/swift-server/core/cursor-handler.swift +++ b/src/swift-server/core/cursor-handler.swift @@ -34,3 +34,8 @@ struct PageQuery { return ListWithCursor(list: data, next: hasMore ? generateCursor(data.last!) : nil) } } + +struct ListWithCursor { + let list: [T] + let next: String? +} diff --git a/src/swift-server/core/demo-data.command.swift b/src/swift-server/core/demo-data.command.swift index 5b3401ae..2011ca4e 100644 --- a/src/swift-server/core/demo-data.command.swift +++ b/src/swift-server/core/demo-data.command.swift @@ -49,10 +49,33 @@ struct DemoDataCommand: AsyncCommand { try await generateGraphs( app: app, groupOwnerId: groupOwnerId, labelIdMap: labelIdMap) + try await addRulesAndLabels(app: app, groupOwnerId: groupOwnerId) + // When not executed, the group attach seems to not work let _ = try await Graph.query(on: app.db).first() } + private func addRulesAndLabels(app: Application, groupOwnerId: UUID) async throws { + let income = Label(groupOwnerId: groupOwnerId, name: "income") + try await income.save(on: app.db) + + let expenses = Label(groupOwnerId: groupOwnerId, name: "expenses") + try await expenses.save(on: app.db) + + let aral = Label(groupOwnerId: groupOwnerId, name: "aral") + try await aral.save(on: app.db) + + let _ = try await Rule.createRule( + on: app.db, for: groupOwnerId, with: .init(.less, valueDouble: 0), + toApply: expenses) + let _ = try await Rule.createRule( + on: app.db, for: groupOwnerId, with: .init(.greater, valueDouble: 0), + toApply: income) + let _ = try await Rule.createRule( + on: app.db, for: groupOwnerId, with: .init(.contains, valueStr: "aral"), + toApply: aral) + } + private func generateLabelsAndTransactions( app: Application, groupOwnerId: UUID, label: String, amountRange: ClosedRange, periodicity: String diff --git a/src/swift-server/core/error.swift b/src/swift-server/core/error.swift index 5447ab16..e3b44f93 100644 --- a/src/swift-server/core/error.swift +++ b/src/swift-server/core/error.swift @@ -1,14 +1,21 @@ +import swift_macros + enum ErrorCode: String, CaseIterable { case E10000, E10001, E10002, E10003, E10004, E10005, E10006, E10007, E10008, E10009 case E10010, E10011, E10012, E10013, E10014, E10015, E10016 } -enum ApiError: String { +enum ApiError: String, StringEnumType { case API10000, API10001, API10002, API10003, API10004, API10005, API10006, API10007, API10008, API10009 case API10010, API10011, API10012, API10013, API10014, API10015, API10016, API10017, API10018, API10019 - case API10020 + case API10020, API10021, API10022, API10023, API10024, API10025, API10026, API10027, + API10028, API10029 + case API10030, API10031, API10032, API10033, API10034, API10035, API10036, API10037, + API10038, API10039 + case API10040, API10041, API10042, API10043, API10044, API10045, API10046, API10047, + API10048, API10049 } class ErrorInfo { @@ -41,7 +48,7 @@ let errorDictionary: [ErrorCode: ErrorInfo] = [ .E10013: ErrorInfo(message: "Csv is invalid and doesn't contain the original value row"), .E10014: ErrorInfo( message: "Retrieving an String from a condition when it doesn't have it"), - .E10014: ErrorInfo( + .E10015: ErrorInfo( message: "Retrieving the Double from a condition when it doesn't have it"), .E10016: ErrorInfo( message: "Rule parent cannot be found by the ID", diff --git a/src/swift-server/core/services.swift b/src/swift-server/core/services.swift new file mode 100644 index 00000000..10f4e71b --- /dev/null +++ b/src/swift-server/core/services.swift @@ -0,0 +1,71 @@ +import Fluent +import Queues +import Vapor +import swift_macros + +class ServiceWithDb { + let db: Database + init(app: Application) { + db = app.db + } +} + +class ServiceWithQueueAndDb: ServiceWithDb { + let queue: Queue + + override init(app: Application) { + queue = app.queues.queue + super.init(app: app) + } +} + +extension UserService: StorageKey { + typealias Value = UserService +} + +extension UserGroupService: StorageKey { + typealias Value = UserGroupService +} + +extension BankTransactionService: StorageKey { + typealias Value = BankTransactionService +} + +extension LabelService: StorageKey { + typealias Value = LabelService +} + +extension RuleService: StorageKey { + typealias Value = RuleService +} + +extension FileImportService: StorageKey { + typealias Value = FileImportService +} + +extension GraphService: StorageKey { + typealias Value = GraphService +} + +extension Application { + @ServiceDependency() + var userService: UserService + + @ServiceDependency() + var userGroupService: UserGroupService + + @ServiceDependency() + var bankTransactionService: BankTransactionService + + @ServiceDependency() + var ruleService: RuleService + + @ServiceDependency() + var fileImportService: FileImportService + + @ServiceDependency() + var graphService: GraphService + + @ServiceDependency() + var labelService: LabelService +} diff --git a/src/swift-server/core/validations.swift b/src/swift-server/core/validations.swift new file mode 100644 index 00000000..d24ba776 --- /dev/null +++ b/src/swift-server/core/validations.swift @@ -0,0 +1,106 @@ +import Fluent +import Foundation + +protocol InputWithUserGroup { + var groupOwnerId: String { get } +} + +class TransformerAndValidator {} + +extension TransformerAndValidator { + enum GroupOwnerIdState { + case notUuid + case notOwned(validGroups: [UUID]) + case ok(id: UUID) + } + static func groupOwner(_ obj: InputWithUserGroup, on db: Database, for user: User) throws + -> GroupOwnerIdState + { + guard let groupOwnerId = UUID(uuidString: obj.groupOwnerId) else { + return .notUuid + } + + let validGroupsId = try user.groups.map { return try $0.requireID() } + if validGroupsId.contains(groupOwnerId) { + return .ok(id: groupOwnerId) + } + return .notOwned(validGroups: validGroupsId) + } +} + +protocol InputWithParentRule { + var parentRuleId: String? { get } +} + +extension TransformerAndValidator { + enum ParentRuleState { + case notUuid + case notFound + case ok(parent: Rule?) + } + static func parentRule(_ obj: InputWithParentRule, on db: Database, for userGroup: UUID) + async throws -> ParentRuleState + { + guard let parentIdStr = obj.parentRuleId else { + return .ok(parent: nil) + } + guard let parentId = UUID(uuidString: parentIdStr) else { + return .notUuid + } + let realParent = try await Rule.query(on: db).filter(\.$id == parentId).filter( + \.$groupOwner.$id == userGroup + ).first() + guard let realParent else { + return .notFound + } + + return .ok(parent: realParent) + } + + enum ParentRuleOnEditState { + case parentRule(state: ParentRuleState) + case itsOwnParent + } + + static func parentRule( + _ obj: InputWithParentRule, ruleId: UUID, on db: Database, for userGroup: UUID + ) + async throws -> ParentRuleOnEditState + { + let state = try await parentRule(obj, on: db, for: userGroup) + let realParent: Rule + switch state { + case .notFound: + return .parentRule(state: .notFound) + case .notUuid: + return .parentRule(state: .notUuid) + case .ok(let rule): + if let rule { + realParent = rule + } else { + return .parentRule(state: .ok(parent: nil)) + } + } + var parentRule: Rule = realParent + while true { + if try parentRule.requireID() == ruleId { + return .itsOwnParent + } + guard let parentID = parentRule.$parent.id else { + return .parentRule(state: .ok(parent: realParent)) + } + let retrieveParentRule = try await Rule.query(on: db).filter( + \.$id == parentID + ).filter( + \.$groupOwner.$id == userGroup + ).first() + guard let retrieveParentRule else { + print( + "Rule \(try parentRule.requireID()) is corrupted as is pointing to a parent that is not found for the user group \(userGroup)." + ) + return .parentRule(state: .ok(parent: realParent)) + } + parentRule = retrieveParentRule + } + } +} diff --git a/src/swift-server/graph/graph-api-impl.swift b/src/swift-server/graph/graph-api-impl.swift index 4a19750b..c4363978 100644 --- a/src/swift-server/graph/graph-api-impl.swift +++ b/src/swift-server/graph/graph-api-impl.swift @@ -2,7 +2,6 @@ import Foundation import OpenAPIRuntime import OpenAPIVapor -let graphService = GraphService() extension MrScroogeAPIImpl { func ApiGraphs_create(_ input: Operations.ApiGraphs_create.Input) async throws -> Operations.ApiGraphs_create.Output @@ -30,7 +29,7 @@ extension MrScroogeAPIImpl { } - let data = try await graphService.createGraph(on: request.db, graphData) + let data = try await request.application.graphService.createGraph(graphData) switch data { case let .invalidLabels(data: info): return .notFound( @@ -58,8 +57,7 @@ extension MrScroogeAPIImpl { graphIds = inputGraphIds.map { UUID(uuidString: $0)! } } - let data = try await graphService.getGraphs( - on: request.db, + let data = try await request.application.graphService.getGraphs( pageQuery: .init( limit: input.query.limit ?? 100, cursor: input.query.cursor), groupsId: validGroupsId, graphsIds: graphIds) @@ -104,8 +102,8 @@ extension MrScroogeAPIImpl { ) } - let data = try await graphService.updateGraph( - on: request.db, withId: graphId, graph: graphData, forUser: user) + let data = try await request.application.graphService.updateGraph( + withId: graphId, graph: graphData, forUser: user) switch data { case let .invalidLabels(data: info): return .notFound( @@ -146,8 +144,8 @@ extension MrScroogeAPIImpl { code: ApiError.API10009.rawValue)))) } - let data = try await graphService.deleteGraph( - on: request.db, graphId: graphId, forUser: user) + let data = try await request.application.graphService.deleteGraph( + graphId: graphId, forUser: user) switch data { case let .notFound(graphId: graphId): return .notFound( diff --git a/src/swift-server/graph/graph.service.swift b/src/swift-server/graph/graph.service.swift index 34962e6d..2a03bdee 100644 --- a/src/swift-server/graph/graph.service.swift +++ b/src/swift-server/graph/graph.service.swift @@ -158,7 +158,7 @@ class GraphBuilder { } } -class GraphService { +class GraphService: ServiceWithDb { private let cursorHandler = CursorHandler(["id"]) struct InvalidLabels { @@ -183,11 +183,13 @@ class GraphService { } private func validateLabels( - on db: Database, groupOwnerId: UUID, group: Components.Schemas.Group, + on _db: Database? = nil, + groupOwnerId: UUID, group: Components.Schemas.Group, horizontalGroup: Components.Schemas.HorizontalGroup?, rootLabel: String? ) async throws -> InvalidLabels? { + let db = _db ?? self.db var searchLabelIds = Set() if let labels = group.labels { labels.forEach { searchLabelIds.insert($0) } @@ -215,7 +217,7 @@ class GraphService { return nil } - func createGraph(on db: Database, _ newGraph: Components.Schemas.GraphParam) async throws + func createGraph(_ newGraph: Components.Schemas.GraphParam) async throws -> CreateGraphResponse { let groupOwnerId = UUID(uuidString: newGraph.groupOwnerId)! @@ -302,11 +304,12 @@ class GraphService { } func getGraphs( - on db: Database, pageQuery: PageQuery = .init(), groupsId: [UUID], + pageQuery: PageQuery = .init(), groupsId: [UUID], graphsIds: [UUID]? ) async throws -> ListWithCursor { + let db = db let graphsQuery = Graph.query(on: db) .filter(\.$groupOwner.$id ~~ groupsId) if let graphsIds = graphsIds { @@ -380,7 +383,7 @@ class GraphService { } func updateGraph( - on db: Database, withId id: UUID, graph updatedGraph: Components.Schemas.GraphParam, + withId id: UUID, graph updatedGraph: Components.Schemas.GraphParam, forUser user: User ) async throws -> UpdateGraphResponse { return try await db.transaction { transaction in @@ -524,10 +527,10 @@ class GraphService { } - func deleteGraph(on database: Database, graphId: UUID, forUser user: User) async throws + func deleteGraph(graphId: UUID, forUser user: User) async throws -> DeleteGraphResponse { - return try await database.transaction { transaction in + return try await db.transaction { transaction in let validGroupsId = try user.groups.map { try $0.requireID() } // Delete the graph let graph = try await Graph.query(on: transaction) diff --git a/src/swift-server/importer/importer-api-impl.swift b/src/swift-server/importer/importer-api-impl.swift index a577168c..ec3c534e 100644 --- a/src/swift-server/importer/importer-api-impl.swift +++ b/src/swift-server/importer/importer-api-impl.swift @@ -3,8 +3,6 @@ import OpenAPIRuntime import OpenAPIVapor import Vapor -let fileImporterService = FileImportService() - struct UploadData: Content { var kind: String var file: File @@ -15,7 +13,8 @@ extension MrScroogeAPIImpl { func ApiImports_parserTypes(_ input: Operations.ApiImports_parserTypes.Input) async throws -> Operations.ApiImports_parserTypes.Output { - let parserList = fileImporterService.getParsers() + let fileImportService = request.application.fileImportService + let parserList = fileImportService.getParsers() return .ok( .init( body: .json( @@ -36,10 +35,11 @@ extension MrScroogeAPIImpl { func ApiImports_list(_ input: Operations.ApiImports_list.Input) async throws -> Operations.ApiImports_list.Output { + let fileImportService = request.application.fileImportService let user = try await getUser(fromRequest: request) let validGroupsId = try user.groups.map { return try $0.requireID() } - let data = try await fileImporterService.getAll( - on: request.db, groupIds: validGroupsId, cursor: input.query.cursor, + let data = try await fileImportService.getAll( + groupIds: validGroupsId, cursor: input.query.cursor, limit: input.query.limit ?? 100) return .ok( @@ -54,6 +54,7 @@ extension MrScroogeAPIImpl { func ApiImports_delete(_ input: Operations.ApiImports_delete.Input) async throws -> Operations.ApiImports_delete.Output { + let fileImportService = request.application.fileImportService let user = try await getUser(fromRequest: request) let validGroupsId = try user.groups.map { return try $0.requireID() } let idString = input.path.id @@ -61,8 +62,8 @@ extension MrScroogeAPIImpl { return .undocumented(statusCode: 400, UndocumentedPayload()) } - try await fileImporterService.delete( - on: request.db, groupIds: validGroupsId, importId: importId) + try await fileImportService.delete( + groupIds: validGroupsId, importId: importId) return .ok(.init(body: .json(.init(true)))) } @@ -77,6 +78,7 @@ struct ImportUpload: RouteCollection { func uploadFile(request: Request) async throws -> Response { let upload = try request.content.decode(UploadData.self) + let fileImportService = request.application.fileImportService let tmpDir = NSTemporaryDirectory() let filePath = @@ -95,8 +97,7 @@ struct ImportUpload: RouteCollection { let user = try await getUser(fromRequest: request) - let importData = try await fileImporterService.createFileImport( - on: request.db, withQueue: request.queue, + let importData = try await fileImportService.createFileImport( groupOwnerId: user.defaultGroup.requireID(), key: upload.kind, fileName: upload.file.filename, filePath: filePath) diff --git a/src/swift-server/importer/importer.service.swift b/src/swift-server/importer/importer.service.swift index e9a15c49..6ad78a54 100644 --- a/src/swift-server/importer/importer.service.swift +++ b/src/swift-server/importer/importer.service.swift @@ -2,19 +2,25 @@ import Fluent import Queues import Vapor -class FileImportService { +class FileImportService: ServiceWithQueueAndDb { private let cursorHandler = CursorHandler(["created", "id"]) - private let uploadImportService = NewImportService(parsers: [ - CommerzBankEnImporter(), CaixaEnginiersCreditImporter(), - CaixaEnginyersAccountImporter(), N26Importer(), - ]) + private let uploadImportService: NewImportService func getParsers() -> [ParserFactory] { return uploadImportService.getParsers() } + override init(app: Application) { + uploadImportService = .init( + parsers: [ + CommerzBankEnImporter(), CaixaEnginiersCreditImporter(), + CaixaEnginyersAccountImporter(), N26Importer(), + ], withApp: app) + super.init(app: app) + } + func createFileImport( - on db: Database, withQueue queue: Queue, groupOwnerId: UUID, key: String, + groupOwnerId: UUID, key: String, fileName: String, filePath: String ) async throws -> FileImportReport { let importId = try await uploadImportService.importFromFile( @@ -27,7 +33,7 @@ class FileImportService { return importReport! } - func getAll(on db: Database, groupIds: [UUID], cursor: String? = nil, limit: Int = 100) + func getAll(groupIds: [UUID], cursor: String? = nil, limit: Int = 100) async throws -> ListWithCursor { var query = FileImportReport.query(on: db) @@ -67,7 +73,7 @@ class FileImportService { ) } - func delete(on db: Database, groupIds: [UUID], importId: UUID) async throws { + func delete(groupIds: [UUID], importId: UUID) async throws { let exists = try await FileImportReport.query(on: db).filter(\.$id == importId) .filter( \.$groupOwnerId ~~ groupIds diff --git a/src/swift-server/importer/importers/new-import.service.swift b/src/swift-server/importer/importers/new-import.service.swift index 267bf8ab..bdf76b29 100644 --- a/src/swift-server/importer/importers/new-import.service.swift +++ b/src/swift-server/importer/importers/new-import.service.swift @@ -31,13 +31,14 @@ struct PartialBankTransaction { } } -class NewImportService { +class NewImportService: ServiceWithQueueAndDb { private var parsersMap: [String: ParserFactory] private let bankTransactionService: BankTransactionService - init(parsers: [ParserFactory]) { - self.parsersMap = Dictionary(uniqueKeysWithValues: parsers.map { ($0.key, $0) }) - self.bankTransactionService = BankTransactionService() + init(parsers: [ParserFactory], withApp app: Application) { + parsersMap = Dictionary(uniqueKeysWithValues: parsers.map { ($0.key, $0) }) + bankTransactionService = app.bankTransactionService + super.init(app: app) } func getParsers() -> [ParserFactory] { @@ -90,7 +91,7 @@ class NewImportService { status: status, transaction: partialTransaction) if try await bankTransactionService.existsSimilar( - on: db, transaction: transaction) + transaction: transaction) { if discarting { let msg = "Repeated row, not inserted" @@ -115,8 +116,6 @@ class NewImportService { let record = try await bankTransactionService .addTransaction( - on: db, - withQueue: queue, transaction: previousValidated) previousStateValidated.message = "Repeated row, but inserted" @@ -129,7 +128,6 @@ class NewImportService { discarting = false let record = try await bankTransactionService.addTransaction( - on: db, withQueue: queue, transaction: transaction ) diff --git a/src/swift-server/label/label-api-impl.swift b/src/swift-server/label/label-api-impl.swift index 06e1026d..d16a8016 100644 --- a/src/swift-server/label/label-api-impl.swift +++ b/src/swift-server/label/label-api-impl.swift @@ -1,35 +1,43 @@ import Foundation import OpenAPIRuntime -let labelService = LabelService() extension MrScroogeAPIImpl { func ApiLabels_create(_ input: Operations.ApiLabels_create.Input) async throws -> Operations.ApiLabels_create.Output { let user = try await getUser(fromRequest: request) - let validGroupsId = try user.groups.map { return try $0.requireID() } + let groupOwnerId: UUID var inputLabel: Components.Schemas.CreateLabel switch input.body { case .json(let _label): inputLabel = _label } - guard let groupOwnerId = UUID(uuidString: inputLabel.groupOwnerId), - validGroupsId.contains(groupOwnerId) - else { - print(validGroupsId) + + switch try + (TransformerAndValidator.groupOwner(inputLabel, on: request.db, for: user)) + { + case .notUuid: + return .badRequest( + .init( + body: .json( + .init( + message: "GroupOwner ID is not an UUID", + code: ApiError.API10025.rawValue)))) + case .notOwned(let validGroups): return .unauthorized( .init( body: .json( .init( message: "Invalid Group Owner ID", code: ApiError.API10004.rawValue, - validGroupOwners: validGroupsId.map { + validGroupOwners: validGroups.map { $0.uuidString })))) + case .ok(let _groupId): + groupOwnerId = _groupId } - let label = try await labelService.createLabel( - on: request.db, + let label = try await request.application.labelService.createLabel( label: .init(groupOwnerId: groupOwnerId, name: inputLabel.name)) return .ok(.init(body: .json(.init(label: label)))) @@ -40,8 +48,7 @@ extension MrScroogeAPIImpl { { let user = try await getUser(fromRequest: request) let validGroupsIds = try user.groups.map { return try $0.requireID() } - let data = try await labelService.getAll( - on: request.db, + let data = try await request.application.labelService.getAll( pageQuery: .init( limit: input.query.limit ?? 100, cursor: input.query.cursor), groupIds: validGroupsIds) @@ -53,8 +60,22 @@ extension MrScroogeAPIImpl { results: data.list.map { .init(label: $0) }, next: data.next)))) } + + func ApiLabels_update(_ input: Operations.ApiLabels_update.Input) async throws + -> Operations.ApiLabels_update.Output + { + return .undocumented(statusCode: 501, UndocumentedPayload()) + } + + func ApiLabels_delete(_ input: Operations.ApiLabels_delete.Input) async throws + -> Operations.ApiLabels_delete.Output + { + return .undocumented(statusCode: 501, UndocumentedPayload()) + } } +extension Components.Schemas.CreateLabel: InputWithUserGroup {} + extension Components.Schemas.Label { init(label: Label) { id = label.id!.uuidString diff --git a/src/swift-server/label/label.service.swift b/src/swift-server/label/label.service.swift index 5ae5d852..c198d590 100644 --- a/src/swift-server/label/label.service.swift +++ b/src/swift-server/label/label.service.swift @@ -1,9 +1,9 @@ import Fluent import Vapor -struct LabelService { +class LabelService: ServiceWithDb { private let cursorHandler = CursorHandler(["id"]) - func createLabel(on db: Database, label: Label) async throws -> Label { + func createLabel(label: Label) async throws -> Label { try await label.save(on: db) try await label.$groupOwner.load(on: db) return label @@ -16,7 +16,7 @@ struct LabelService { return labelTransaction } */ - func getAll(on db: Database, pageQuery: PageQuery = .init(), groupIds: [UUID]) async throws + func getAll(pageQuery: PageQuery = .init(), groupIds: [UUID]) async throws -> ListWithCursor