diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index c3e4211..28c5319 100644 --- a/Sources/Grodt/Application/routes.swift +++ b/Sources/Grodt/Application/routes.swift @@ -2,141 +2,9 @@ import Vapor import AlphaSwiftage func routes(_ app: Application) async throws { - - let alphavantage = try await AlphaVantageService(serviceType: .rapidAPI(apiKey: app.config.alphavantageAPIKey()) ) - - let currencyDTOMapper = CurrencyDTOMapper() - let tickerDTOMapper = TickerDTOMapper() - let loginResponseDTOMapper = LoginResponseDTOMapper() - let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper, database: app.db) - let tickerRepository = PostgresTickerRepository(database: app.db) - let livePriceService = LivePriceService(alphavantage: alphavantage) - let quoteCache = PostgresQuoteRepository(database: app.db) - let priceService = CachedPriceService(priceService: livePriceService, cache: quoteCache) - let performanceCalculator = HoldingsPerformanceCalculator(priceService: priceService) - let investmentDTOMapper = InvestmentDTOMapper(currencyDTOMapper: currencyDTOMapper, - transactionDTOMapper: transactionDTOMapper, - tickerRepository: tickerRepository, - priceService: priceService) - - let userRepository = PostgresUserRepository(database: app.db) - let portfolioRepository = PostgresPortfolioRepository(database: app.db) - let transactionRepository = PostgresTransactionRepository(database: app.db) - let brokerageRepository = PostgresBrokerageRepository(database: app.db) - let brokerageAccountRepository = PostgresBrokerageAccountRepository(database: app.db) - let brokerageAccountDailyPerformanceRepository = PostgresBrokerageAccountDailyPerformanceRepository(database: app.db) - let brokerageDailyPerformanceRepository = PostgresBrokerageDailyPerformanceRepository(database: app.db) - - let portfolioDTOMapper = PortfolioDTOMapper(investmentDTOMapper: investmentDTOMapper, - transactionDTOMapper: transactionDTOMapper, - performanceDTOMapper: DatedPerformanceDTOMapper(), - currencyDTOMapper: currencyDTOMapper) - let currencyRepository = PostgresCurrencyRepository(database: app.db) - let portfolioPerformanceUpdater = PortfolioPerformanceUpdater( - userRepository: userRepository, - portfolioRepository: portfolioRepository, - tickerRepository: PostgresTickerRepository(database: app.db), - quoteCache: quoteCache, - priceService: priceService, - performanceCalculator: performanceCalculator, - portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db)) - let transactionChangedHandler = TransactionChangedHandler(portfolioRepository: PostgresPortfolioRepository(database: app.db), - historicalPerformanceUpdater: portfolioPerformanceUpdater) - - var tickersController = TickersController(tickerRepository: tickerRepository, - dataMapper: tickerDTOMapper, - tickerService: alphavantage) - let tickerChangeHandler = TickerChangeHandler(priceService: priceService) - tickersController.delegate = tickerChangeHandler - - let investmentsController = InvestmentController(portfolioRepository: portfolioRepository, - dataMapper: investmentDTOMapper) - - let accountController = AccountController(userRepository: userRepository, dataMapper: UserDTOMapper()) - - let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60) - let loginRateLimiter = RateLimiterMiddleware(maxRequests: 3, perSeconds: 60) - - app.middleware.use(app.sessions.middleware) - app.middleware.use(globalRateLimiter) - - let tokenAuthMiddleware = UserToken.authenticator() - let guardAuthMiddleware = User.guardMiddleware() - - try app.group("api") { api in - // Public routes - try api - .grouped(loginRateLimiter) - .register(collection: UserController(dtoMapper: loginResponseDTOMapper)) - - // Protected routes - let protected = api.grouped([ - UserTokenCookieAuthenticator(), - tokenAuthMiddleware, - OriginRefererCheckMiddleware(), - guardAuthMiddleware - ]) - try protected.register(collection: - PortfoliosController( - portfolioRepository: PostgresPortfolioRepository(database: app.db), - currencyRepository: currencyRepository, - historicalPortfolioPerformanceUpdater: portfolioPerformanceUpdater, - portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db), - dataMapper: portfolioDTOMapper) - ) - - let transactionController = TransactionsController(transactionsRepository: transactionRepository, - currencyRepository: currencyRepository, - dataMapper: transactionDTOMapper) - transactionController.delegate = transactionChangedHandler - try protected.register(collection: transactionController) - try protected.register(collection: tickersController) - try protected.register(collection: investmentsController) - try protected.register(collection: accountController) - try protected.register(collection: BrokerageController(brokerageRepository: brokerageRepository, - dtoMapper: BrokerageDTOMapper(brokerageRepository: brokerageRepository, - accountDTOMapper: BrokerageAccountDTOMapper(brokerageAccountRepository: brokerageAccountRepository, - currencyMapper: currencyDTOMapper, database: app.db), - database: app.db), - accounts: brokerageAccountRepository, - currencyMapper: currencyDTOMapper, - performanceRepository: brokerageDailyPerformanceRepository, - performanceDTOMapper: DatedPerformanceDTOMapper())) - try protected.register(collection: BrokerageAccountController(brokerageAccountRepository: brokerageAccountRepository, - performanceRepository: brokerageAccountDailyPerformanceRepository, - performanceDTOMapper: DatedPerformanceDTOMapper(), - currencyMapper: currencyDTOMapper, - transactionDTOMapper: transactionDTOMapper, - currencyRepository: currencyRepository)) - } - - if app.environment != .testing { - let nightlyUpdaterJob = NightlyUpdaterJob( - tickerPriceUpdater: TickerPriceUpdater(tickerRepository: tickerRepository, - quoteCache: quoteCache, - priceService: priceService), - portfolioPerformanceUpdater: portfolioPerformanceUpdater, - brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdater(transactionRepository: transactionRepository, - brokerageAccountRepository: brokerageAccountRepository, - accountDailyRepository: brokerageAccountDailyPerformanceRepository, - userRepository: userRepository, - calculator: performanceCalculator), - brokeragePerformanceUpdater: BrokeragePerformanceUpdater(userRepository: userRepository, - brokerageAccountRepository: brokerageAccountRepository, - accountDailyRepository: brokerageAccountDailyPerformanceRepository, - brokerageDailyRepository: brokerageDailyPerformanceRepository) - ) - app.queues.schedule(nightlyUpdaterJob) - .daily() - .at(3, 0) - - app.queues.add(LoggingJobEventDelegate(logger: app.logger)) - - let userTokenCleanerJob = UserTokenClearUpJob(userTokenClearing: UserTokenClearer(database: app.db)) - app.queues.schedule(userTokenCleanerJob) - .daily() - - try app.queues.startScheduledJobs() - try app.queues.startInProcessJobs() - } + let container = try await buildAppContainer(app) + installGlobalMiddleware(app) + try registerLoginRoutes(app, container) + try registerSkiRoutes(app, container) + try scheduleNightlyJobs(app, container) } diff --git a/Sources/Grodt/Application/routes/routes+Dependencies.swift b/Sources/Grodt/Application/routes/routes+Dependencies.swift new file mode 100644 index 0000000..e4b19fc --- /dev/null +++ b/Sources/Grodt/Application/routes/routes+Dependencies.swift @@ -0,0 +1,237 @@ +import Vapor +import AlphaSwiftage + +struct AppContainer { + // External services + let alphavantage: AlphaVantageService + + // Mappers + let currencyDTOMapper: CurrencyDTOMapper + let tickerDTOMapper: TickerDTOMapper + let loginResponseDTOMapper: LoginResponseDTOMapper + let transactionDTOMapper: TransactionDTOMapper + let portfolioDTOMapper: PortfolioDTOMapper + let performanceDTOMapper: DatedPerformanceDTOMapper + + // Core repos/services + let tickerRepository: PostgresTickerRepository + let livePriceService: LivePriceService + let quoteCache: PostgresQuoteRepository + let priceService: CachedPriceService + + let userRepository: PostgresUserRepository + let portfolioRepository: PostgresPortfolioRepository + let transactionRepository: PostgresTransactionRepository + let brokerageRepository: PostgresBrokerageRepository + let brokerageAccountRepository: PostgresBrokerageAccountRepository + let brokerageAccountDailyPerformanceRepository: PostgresBrokerageAccountDailyPerformanceRepository + let brokerageDailyPerformanceRepository: PostgresBrokerageDailyPerformanceRepository + + let currencyRepository: PostgresCurrencyRepository + + // Calculators / updaters + let performanceCalculator: HoldingsPerformanceCalculating + let portfolioPerformanceUpdater: PortfolioPerformanceUpdater + + let portfolioService: PortfolioService + let accountService: AccountService + let brokerageService: BrokerageService + let investmentService: InvestmentService + let transactionService: TransactionService + let tickersService: TickersService + let brokerageAccountsService: BrokerageAccountsService +} + +func buildAppContainer(_ app: Application) async throws -> AppContainer { + let alphavantage = try await AlphaVantageService( + serviceType: .rapidAPI(apiKey: app.config.alphavantageAPIKey()) + ) + + let currencyDTOMapper = CurrencyDTOMapper() + let tickerDTOMapper = TickerDTOMapper() + let loginResponseDTOMapper = LoginResponseDTOMapper() + + let tickerRepository = PostgresTickerRepository(database: app.db) + let livePriceService = LivePriceService(alphavantage: alphavantage) + let quoteCache = PostgresQuoteRepository(database: app.db) + let priceService = CachedPriceService(priceService: livePriceService, cache: quoteCache) + + let transactionDTOMapper = TransactionDTOMapper( + currencyDTOMapper: currencyDTOMapper, + database: app.db + ) + + let investmentDTOMapper = InvestmentDTOMapper( + currencyDTOMapper: currencyDTOMapper, + transactionDTOMapper: transactionDTOMapper, + tickerRepository: tickerRepository, + priceService: priceService + ) + + let userRepository = PostgresUserRepository(database: app.db) + let portfolioRepository = PostgresPortfolioRepository(database: app.db) + let transactionRepository = PostgresTransactionRepository(database: app.db) + let brokerageRepository = PostgresBrokerageRepository(database: app.db) + let brokerageAccountRepository = PostgresBrokerageAccountRepository(database: app.db) + + let brokerageAccountDailyPerformanceRepository = PostgresBrokerageAccountDailyPerformanceRepository(database: app.db) + let brokerageDailyPerformanceRepository = PostgresBrokerageDailyPerformanceRepository(database: app.db) + + let performanceDTOMapper = DatedPerformanceDTOMapper() + + let portfolioDTOMapper = PortfolioDTOMapper( + investmentDTOMapper: investmentDTOMapper, + transactionDTOMapper: transactionDTOMapper, + performanceDTOMapper: performanceDTOMapper, + currencyDTOMapper: currencyDTOMapper + ) + + let currencyRepository = PostgresCurrencyRepository(database: app.db) + + let performanceCalculator = HoldingsPerformanceCalculator(priceService: priceService) + + let portfolioPerformanceUpdater = PortfolioPerformanceUpdater( + userRepository: userRepository, + portfolioRepository: portfolioRepository, + tickerRepository: PostgresTickerRepository(database: app.db), + quoteCache: quoteCache, + priceService: priceService, + performanceCalculator: performanceCalculator, + portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db) + ) + + let portfolioService = PortfolioService(portfolioRepository: portfolioRepository, + currencyRepository: currencyRepository, + historicalPortfolioPerformanceUpdater: portfolioPerformanceUpdater, + portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db), + dataMapper: portfolioDTOMapper) + + let accountService = AccountService(userRepository: userRepository, userDataMapper: UserDTOMapper()) + + let brokerageService = BrokerageService( + brokerageRepository: brokerageRepository, + dtoMapper: BrokerageDTOMapper( + brokerageRepository: brokerageRepository, + accountDTOMapper: BrokerageAccountDTOMapper( + brokerageAccountRepository: brokerageAccountRepository, + currencyMapper: currencyDTOMapper, + database: app.db + ), + performanceRepository: brokerageDailyPerformanceRepository, + performanceDTOMapper: performanceDTOMapper, + database: app.db + ), + accounts: brokerageAccountRepository, + currencyMapper: currencyDTOMapper + ) + + let investmentService = InvestmentService( + portfolioRepository: portfolioRepository, + dataMapper: InvestmentDTOMapper( + currencyDTOMapper: currencyDTOMapper, + transactionDTOMapper: transactionDTOMapper, + tickerRepository: tickerRepository, + priceService: priceService + ) + ) + + let transactionService = TransactionService(transactionsRepository: transactionRepository, + currencyRepository: currencyRepository, + dataMapper: transactionDTOMapper) + + let tickersService = TickersService( + tickerRepository: tickerRepository, + dataMapper: tickerDTOMapper, + tickerService: alphavantage + ) + + let tickerChangeHandler = TickerChangeHandler(priceService: priceService) + tickersService.delegate = tickerChangeHandler + + let brokerageAccountsService = BrokerageAccountsService( + brokerageRepository: brokerageRepository, + brokerageAccountRepository: brokerageAccountRepository, + performanceRepository: brokerageAccountDailyPerformanceRepository, + performanceDTOMapper: performanceDTOMapper, + currencyMapper: currencyDTOMapper, + transactionDTOMapper: transactionDTOMapper, + currencyRepository: currencyRepository + ) + + + return AppContainer( + alphavantage: alphavantage, + currencyDTOMapper: currencyDTOMapper, + tickerDTOMapper: tickerDTOMapper, + loginResponseDTOMapper: loginResponseDTOMapper, + transactionDTOMapper: transactionDTOMapper, + portfolioDTOMapper: portfolioDTOMapper, + performanceDTOMapper: performanceDTOMapper, + tickerRepository: tickerRepository, + livePriceService: livePriceService, + quoteCache: quoteCache, + priceService: priceService, + userRepository: userRepository, + portfolioRepository: portfolioRepository, + transactionRepository: transactionRepository, + brokerageRepository: brokerageRepository, + brokerageAccountRepository: brokerageAccountRepository, + brokerageAccountDailyPerformanceRepository: brokerageAccountDailyPerformanceRepository, + brokerageDailyPerformanceRepository: brokerageDailyPerformanceRepository, + currencyRepository: currencyRepository, + performanceCalculator: performanceCalculator, + portfolioPerformanceUpdater: portfolioPerformanceUpdater, + portfolioService: portfolioService, + accountService: accountService, + brokerageService: brokerageService, + investmentService: investmentService, + transactionService: transactionService, + tickersService: tickersService, + brokerageAccountsService: brokerageAccountsService + ) +} + +func installGlobalMiddleware(_ app: Application) { + let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60) + app.middleware.use(app.sessions.middleware) + app.middleware.use(globalRateLimiter) +} + +func scheduleNightlyJobs(_ app: Application, _ container: AppContainer) throws { + if app.environment == .testing { return } + + let nightlyUpdaterJob = NightlyUpdaterJob( + tickerPriceUpdater: TickerPriceUpdater( + tickerRepository: container.tickerRepository, + quoteCache: container.quoteCache, + priceService: container.priceService + ), + portfolioPerformanceUpdater: container.portfolioPerformanceUpdater, + brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdater( + transactionRepository: container.transactionRepository, + brokerageAccountRepository: container.brokerageAccountRepository, + accountDailyRepository: container.brokerageAccountDailyPerformanceRepository, + userRepository: container.userRepository, + calculator: container.performanceCalculator + ), + brokeragePerformanceUpdater: BrokeragePerformanceUpdater( + userRepository: container.userRepository, + brokerageAccountRepository: container.brokerageAccountRepository, + accountDailyRepository: container.brokerageAccountDailyPerformanceRepository, + brokerageDailyRepository: container.brokerageDailyPerformanceRepository + ) + ) + + app.queues.schedule(nightlyUpdaterJob) + .daily() + .at(3, 0) + + app.queues.add(LoggingJobEventDelegate(logger: app.logger)) + + let userTokenCleanerJob = UserTokenClearUpJob(userTokenClearing: UserTokenClearer(database: app.db)) + app.queues.schedule(userTokenCleanerJob) + .daily() + + try app.queues.startScheduledJobs() + try app.queues.startInProcessJobs() +} diff --git a/Sources/Grodt/Application/routes/routes+login.swift b/Sources/Grodt/Application/routes/routes+login.swift new file mode 100644 index 0000000..5772d1a --- /dev/null +++ b/Sources/Grodt/Application/routes/routes+login.swift @@ -0,0 +1,8 @@ +import Vapor + +func registerLoginRoutes(_ app: Application, _ container: AppContainer) throws { + let loginRateLimiter = RateLimiterMiddleware(maxRequests: 3, perSeconds: 60) + try app + .grouped(loginRateLimiter) + .register(collection: LoginRoute(dtoMapper: container.loginResponseDTOMapper)) +} diff --git a/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift b/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift index 131647c..0bb71be 100644 --- a/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift +++ b/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift @@ -1,7 +1,6 @@ import Foundation struct CreateTransactionRequestDTO: Decodable { - let portfolio: String let brokerageAccountID: String? let purchaseDate: Date let ticker: String @@ -11,12 +10,11 @@ struct CreateTransactionRequestDTO: Decodable { let pricePerShare: Decimal enum CodingKeys: String, CodingKey { - case portfolio, brokerageAccountID, platform, account, purchaseDate, ticker, currency, fees, numberOfShares, pricePerShare + case brokerageAccountID, purchaseDate, ticker, currency, fees, numberOfShares, pricePerShare } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - portfolio = try container.decode(String.self, forKey: .portfolio) brokerageAccountID = try container.decodeIfPresent(String.self, forKey: .brokerageAccountID) ticker = try container.decode(String.self, forKey: .ticker) currency = try container.decode(String.self, forKey: .currency) diff --git a/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift index 9264854..107053c 100644 --- a/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift +++ b/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift @@ -20,7 +20,9 @@ class PortfolioDTOMapper { func portfolio(from portfolio: Portfolio) async throws -> PortfolioDTO { let investments = try await investmentDTOMapper.investments(from: portfolio.transactions) - let transactions = try await portfolio.transactions.asyncMap { try await transactionDTOMapper.transaction(from: $0) } + let transactions = try await portfolio.transactions + .sorted { $0.purchaseDate > $1.purchaseDate } + .asyncMap { try await transactionDTOMapper.transaction(from: $0) } return try await PortfolioDTO(id: portfolio.id?.uuidString ?? "", name: portfolio.name, currency: currencyDTOMapper.currency(from: portfolio.currency), diff --git a/Sources/Grodt/DTOs/RenamePortfolioRequestDTO.swift b/Sources/Grodt/DTOs/RenamePortfolioRequestDTO.swift new file mode 100644 index 0000000..7107730 --- /dev/null +++ b/Sources/Grodt/DTOs/RenamePortfolioRequestDTO.swift @@ -0,0 +1,5 @@ +import Foundation + +struct RenamePortfolioRequestDTO: Codable { + let name: String +} diff --git a/Sources/Grodt/DTOs/Response.swift b/Sources/Grodt/DTOs/Response.swift new file mode 100644 index 0000000..df4bec9 --- /dev/null +++ b/Sources/Grodt/DTOs/Response.swift @@ -0,0 +1,3 @@ +import Vapor + +protocol ResponseDTO: Content { } diff --git a/Sources/Grodt/DTOs/UpdatePortfolioRequestDTO.swift b/Sources/Grodt/DTOs/UpdatePortfolioRequestDTO.swift deleted file mode 100644 index 7fc595d..0000000 --- a/Sources/Grodt/DTOs/UpdatePortfolioRequestDTO.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -struct UpdatePortfolioRequestDTO: Codable { - let name: String - let currency: String -} diff --git a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTO.swift b/Sources/Grodt/DTOs/brokerages/BrokerageAccountDTO.swift similarity index 82% rename from Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTO.swift rename to Sources/Grodt/DTOs/brokerages/BrokerageAccountDTO.swift index 40e2655..4842cfc 100644 --- a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTO.swift +++ b/Sources/Grodt/DTOs/brokerages/BrokerageAccountDTO.swift @@ -8,4 +8,5 @@ struct BrokerageAccountDTO: Codable { let baseCurrency: CurrencyDTO let performance: PerformanceDTO let transactions: [TransactionDTO] + let historicalPerformance: PerformanceTimeSeriesDTO } diff --git a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTOMapper.swift b/Sources/Grodt/DTOs/brokerages/BrokerageAccountDTOMapper.swift similarity index 100% rename from Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTOMapper.swift rename to Sources/Grodt/DTOs/brokerages/BrokerageAccountDTOMapper.swift diff --git a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountInfoDTO.swift b/Sources/Grodt/DTOs/brokerages/BrokerageAccountInfoDTO.swift similarity index 100% rename from Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountInfoDTO.swift rename to Sources/Grodt/DTOs/brokerages/BrokerageAccountInfoDTO.swift diff --git a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTO.swift b/Sources/Grodt/DTOs/brokerages/BrokerageDTO.swift similarity index 75% rename from Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTO.swift rename to Sources/Grodt/DTOs/brokerages/BrokerageDTO.swift index 2ea40d7..e3a0684 100644 --- a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTO.swift +++ b/Sources/Grodt/DTOs/brokerages/BrokerageDTO.swift @@ -5,4 +5,5 @@ struct BrokerageDTO: Codable { let name: String let accounts: [BrokerageAccountInfoDTO] let performance: PerformanceDTO + let historicalPerformance: PerformanceTimeSeriesDTO } diff --git a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTOMapper.swift b/Sources/Grodt/DTOs/brokerages/BrokerageDTOMapper.swift similarity index 57% rename from Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTOMapper.swift rename to Sources/Grodt/DTOs/brokerages/BrokerageDTOMapper.swift index 7e362c8..e50787d 100644 --- a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTOMapper.swift +++ b/Sources/Grodt/DTOs/brokerages/BrokerageDTOMapper.swift @@ -3,14 +3,20 @@ import Fluent struct BrokerageDTOMapper { private let brokerageRepository: BrokerageRepository private let accountDTOMapper: BrokerageAccountDTOMapper + private let performanceRepository: PostgresBrokerageDailyPerformanceRepository + private let performanceDTOMapper: DatedPerformanceDTOMapper private let database: Database init(brokerageRepository: BrokerageRepository, accountDTOMapper: BrokerageAccountDTOMapper, + performanceRepository: PostgresBrokerageDailyPerformanceRepository, + performanceDTOMapper: DatedPerformanceDTOMapper, database: Database) { self.brokerageRepository = brokerageRepository self.accountDTOMapper = accountDTOMapper self.database = database + self.performanceRepository = performanceRepository + self.performanceDTOMapper = performanceDTOMapper } func brokerage(from brokerage: Brokerage) async throws -> BrokerageDTO { @@ -19,11 +25,17 @@ struct BrokerageDTOMapper { try await accountDTOMapper.brokerageAccountInfo(from: $0) } let performance = try await brokerageRepository.performance(for: brokerage.requireID()) + + let rows = try await performanceRepository.readSeries(for: brokerage.requireID(), from: nil, to: nil) + let values = rows.map { performanceDTOMapper.performancePoint(from: $0) } + .sorted { $0.date < $1.date } + return try BrokerageDTO( id: brokerage.requireID(), name: brokerage.name, accounts: accountDTOs, - performance: performance + performance: performance, + historicalPerformance: PerformanceTimeSeriesDTO(values: values) ) } } diff --git a/Sources/Grodt/Endpoints/brokerages/DTO/CreateBrokerageRequestDTO.swift b/Sources/Grodt/DTOs/brokerages/CreateBrokerageRequestDTO.swift similarity index 100% rename from Sources/Grodt/Endpoints/brokerages/DTO/CreateBrokerageRequestDTO.swift rename to Sources/Grodt/DTOs/brokerages/CreateBrokerageRequestDTO.swift diff --git a/Sources/Grodt/Endpoints/transactions/TransactionDTO.swift b/Sources/Grodt/DTOs/transactions/TransactionDTO.swift similarity index 100% rename from Sources/Grodt/Endpoints/transactions/TransactionDTO.swift rename to Sources/Grodt/DTOs/transactions/TransactionDTO.swift diff --git a/Sources/Grodt/Endpoints/transactions/TransactionDTOMapper.swift b/Sources/Grodt/DTOs/transactions/TransactionDTOMapper.swift similarity index 100% rename from Sources/Grodt/Endpoints/transactions/TransactionDTOMapper.swift rename to Sources/Grodt/DTOs/transactions/TransactionDTOMapper.swift diff --git a/Sources/Grodt/Endpoints/AccountController.swift b/Sources/Grodt/Endpoints/AccountController.swift deleted file mode 100644 index cfdb753..0000000 --- a/Sources/Grodt/Endpoints/AccountController.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Vapor -import Fluent - -struct AccountController: RouteCollection { - private let userRepository: UserRepository - private let dataMapper: UserDTOMapper - - init(userRepository: UserRepository, - dataMapper: UserDTOMapper) { - self.userRepository = userRepository - self.dataMapper = dataMapper - } - - func boot(routes: Vapor.RoutesBuilder) throws { - let account = routes.grouped("account") - account.group("me") { me in - me.get(use: userInfo) - } - } - - func userInfo(req: Request) async throws -> UserInfoDTO { - guard let userID = req.auth.get(User.self)?.id else { - throw Abort(.badRequest) - } - - guard let user = try await userRepository.user(for: userID) else { - throw Abort(.notFound) - } - - return dataMapper.userInfo(from: user) - } -} - -extension UserInfoDTO: Content { } diff --git a/Sources/Grodt/Endpoints/PortfoliosController.swift b/Sources/Grodt/Endpoints/PortfoliosController.swift deleted file mode 100644 index b9499c5..0000000 --- a/Sources/Grodt/Endpoints/PortfoliosController.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Vapor -import AlphaSwiftage -import Fluent -import CollectionConcurrencyKit - -struct PortfoliosController: RouteCollection { - private let portfolioRepository: PortfolioRepository - private let portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository - private let currencyRepository: CurrencyRepository - private let dataMapper: PortfolioDTOMapper - private let portfolioPerformanceUpdater: PortfolioPerformanceUpdating - - init(portfolioRepository: PortfolioRepository, - currencyRepository: CurrencyRepository, - historicalPortfolioPerformanceUpdater: PortfolioPerformanceUpdating, - portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository, - dataMapper: PortfolioDTOMapper) { - self.portfolioRepository = portfolioRepository - self.currencyRepository = currencyRepository - self.portfolioPerformanceUpdater = historicalPortfolioPerformanceUpdater - self.portfolioDailyRepo = portfolioDailyRepo - self.dataMapper = dataMapper - } - - func boot(routes: Vapor.RoutesBuilder) throws { - let portfolios = routes.grouped("portfolios") - portfolios.get(use: allPortfolios) - portfolios.post(use: create) - - portfolios.group(":id") { portfolio in - portfolio.get(use: portfolioDetail) - portfolio.put(use: update) - portfolio.delete(use: delete) - - portfolio.group("performance") { pref in - pref.get(use: historicalPerformance) - } - } - } - - func allPortfolios(req: Request) async throws -> [PortfolioInfoDTO] { - guard let userID = req.auth.get(User.self)?.id else { - throw Abort(.badRequest) - } - - return try await portfolioRepository.allPortfolios(for: userID) - .concurrentCompactMap { portfolio in - return try await dataMapper.portfolioInfo(from: portfolio) - } - } - - func create(req: Request) async throws -> PortfolioDTO { - guard let userID = req.auth.get(User.self)?.id else { - throw Abort(.badRequest) - } - - let postPortfolio = try req.content.decode(CreatePortfolioRequestDTO.self) - guard let currency = try await currencyRepository.currency(for: postPortfolio.currency) else { - throw Abort(.badRequest) - } - - let portfolio = Portfolio(userID: userID, - name: postPortfolio.name, - currency: currency) - - let newPortfolio = try await portfolioRepository.create(portfolio) - return try await dataMapper.portfolio(from: newPortfolio) - } - - func portfolioDetail(req: Request) async throws -> PortfolioDTO { - let id = try req.requiredID() - - guard let userID = req.auth.get(User.self)?.id else { - throw Abort(.badRequest) - } - - guard let portfolio = try await portfolioRepository.portfolio(for: userID, with: id) else { - throw Abort(.notFound) - } - - return try await dataMapper.portfolio(from: portfolio) - } - - func update(req: Request) async throws -> PortfolioDTO { - let id = try req.requiredID() - guard let userID = req.auth.get(User.self)?.id else { - throw Abort(.badRequest) - } - - let updateDTO = try req.content.decode(UpdatePortfolioRequestDTO.self) - - guard let portfolio = try await portfolioRepository.portfolio(for: userID, with: id) else { - throw Abort(.notFound) - } - - portfolio.name = updateDTO.name - - if portfolio.currency.code != updateDTO.currency { - guard let newCurrency = try await currencyRepository.currency(for: updateDTO.currency) else { - throw Abort(.badRequest) - } - portfolio.currency = newCurrency - } - - let updatedPortfolio = try await portfolioRepository.update(portfolio) - return try await dataMapper.portfolio(from: updatedPortfolio) - } - - func delete(req: Request) async throws -> HTTPStatus { - let id = try req.requiredID() - - guard let userID = req.auth.get(User.self)?.id else { - throw Abort(.badRequest) - } - - do { - try await portfolioRepository.delete(for: userID, with: id) - } catch FluentError.noResults { - return .ok - } - return .ok - } - - func historicalPerformance(req: Request) async throws -> PerformanceTimeSeriesDTO { - let id = try req.requiredID() - guard let userID = req.auth.get(User.self)?.id else { throw Abort(.badRequest) } - - guard let _ = try await portfolioRepository.portfolio(for: userID, with: id) else { - throw Abort(.notFound) - } - - let series = try await portfolioDailyRepo.readSeries(for: id, from: nil, to: nil) - return await dataMapper.timeSeriesPerformance(from: series) - } -} - -extension PortfolioDTO: Content { } -extension PortfolioInfoDTO: Content { } -extension PerformanceTimeSeriesDTO: Content { } diff --git a/Sources/Grodt/Endpoints/UserController.swift b/Sources/Grodt/Endpoints/Routes/login/LoginRoute.swift similarity index 96% rename from Sources/Grodt/Endpoints/UserController.swift rename to Sources/Grodt/Endpoints/Routes/login/LoginRoute.swift index d3e7bc6..a04c634 100644 --- a/Sources/Grodt/Endpoints/UserController.swift +++ b/Sources/Grodt/Endpoints/Routes/login/LoginRoute.swift @@ -1,7 +1,7 @@ import Vapor import Fluent -struct UserController: RouteCollection { +struct LoginRoute: RouteCollection { private let dtoMapper: LoginResponseDTOMapper init(dtoMapper: LoginResponseDTOMapper) { diff --git a/Sources/Grodt/Endpoints/Routes/ski/brokerage-accounts/BrokerageAccountsRoute.swift b/Sources/Grodt/Endpoints/Routes/ski/brokerage-accounts/BrokerageAccountsRoute.swift new file mode 100644 index 0000000..e8a3613 --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/brokerage-accounts/BrokerageAccountsRoute.swift @@ -0,0 +1,97 @@ +import Vapor + +class BrokerageAccountsRoute: RouteCollection { + private let service: BrokerageAccountsService + private let brokerageRepository: BrokerageRepository + private let brokerageAccountRepository: BrokerageAccountRepository + private let performanceRepository: PostgresBrokerageAccountDailyPerformanceRepository + private let currencyMapper: CurrencyDTOMapper + private let performanceDTOMapper: DatedPerformanceDTOMapper + private let currencyRepository: CurrencyRepository + private let transactionDTOMapper: TransactionDTOMapper + + init(service: BrokerageAccountsService, + brokerageRepository: BrokerageRepository, + brokerageAccountRepository: BrokerageAccountRepository, + performanceRepository: PostgresBrokerageAccountDailyPerformanceRepository, + performanceDTOMapper: DatedPerformanceDTOMapper, + currencyMapper: CurrencyDTOMapper, + transactionDTOMapper: TransactionDTOMapper, + currencyRepository: CurrencyRepository) { + self.service = service + self.brokerageRepository = brokerageRepository + self.brokerageAccountRepository = brokerageAccountRepository + self.performanceRepository = performanceRepository + self.performanceDTOMapper = performanceDTOMapper + self.currencyMapper = currencyMapper + self.transactionDTOMapper = transactionDTOMapper + self.currencyRepository = currencyRepository + } + + func boot(routes: RoutesBuilder) throws { + let group = routes.grouped("brokerage-accounts") + + group.group(":id") { item in + item.get(use: detail) + item.put(use: update) + item.delete(use: remove) + } + } + + private func detail(req: Request) async throws -> BrokerageAccountDTO { + let id = try req.parameters.require("id", as: UUID.self) + let userID = try req.requireUserID() + let model = try await requireAccount(id, userID: userID) + let brokerage = try await model.$brokerage.get(on: req.db) + let transactions = try await model.$transactions.get(on: req.db) + let performance = try await brokerageAccountRepository.performance(for: model.requireID()) + return try await BrokerageAccountDTO(id: try model.requireID(), + brokerageId: try brokerage.requireID(), + brokerageName: brokerage.name, + displayName: model.displayName, + baseCurrency: currencyMapper.currency(from: model.baseCurrency), + performance: performance, + transactions: transactions.asyncMap { try await transactionDTOMapper.transaction(from: $0) }, + historicalPerformance: performanceSeries(for: id, userID: userID)) + } + + private func update(req: Request) async throws -> HTTPStatus { + let id = try req.parameters.require("id", as: UUID.self) + let userID = try req.requireUserID() + let model = try await requireAccount(id, userID: userID) + struct In: Content { let displayName: String; let accountNumberMasked: String? } + let input = try req.content.decode(In.self) + model.displayName = input.displayName + try await brokerageAccountRepository.update(model) + return .ok + } + + private func remove(req: Request) async throws -> HTTPStatus { + let id = try req.parameters.require("id", as: UUID.self) + let userID = try req.requireUserID() + let model = try await requireAccount(id, userID: userID) + try await brokerageAccountRepository.delete(model) + return .noContent + } + + private func requireAccount(_ id: UUID, userID: UUID) async throws -> BrokerageAccount { + guard let model = try await brokerageAccountRepository.find(id, for: userID) else { + throw Abort(.notFound) + } + return model + } + + private func performanceSeries(for id: UUID, userID: User.IDValue) async throws -> PerformanceTimeSeriesDTO { + let account = try await requireAccount(id, userID: userID) + let rows = try await performanceRepository.readSeries(for: account.requireID(), + from: nil, + to: nil) + + let values = rows.map { performanceDTOMapper.performancePoint(from: $0) } + .sorted { $0.date < $1.date } + return PerformanceTimeSeriesDTO(values: values) + } +} + +extension BrokerageAccountDTO: ResponseDTO { } +extension BrokerageAccountInfoDTO: ResponseDTO { } diff --git a/Sources/Grodt/Endpoints/Routes/ski/brokerages/BrokeragesRoute.swift b/Sources/Grodt/Endpoints/Routes/ski/brokerages/BrokeragesRoute.swift new file mode 100644 index 0000000..60656e5 --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/brokerages/BrokeragesRoute.swift @@ -0,0 +1,71 @@ +import Vapor +import Fluent + +struct BrokeragesRoute: RouteCollection { + private let service: BrokerageService + private let accountsService: BrokerageAccountsService + + init(service: BrokerageService, + accountsService: BrokerageAccountsService) { + self.service = service + self.accountsService = accountsService + } + + func boot(routes: RoutesBuilder) throws { + let brokerages = routes.grouped("brokerages") + brokerages.get(use: list) + brokerages.post(use: create) + brokerages.group(":id") { brokerage in + brokerage.get(use: detail) + brokerage.put(use: update) + brokerage.delete(use: remove) + + brokerage.group("accounts") { accounts in + accounts.post(use: createAccount) + } + } + } + + private func list(req: Request) async throws -> [BrokerageDTO] { + let userID = try req.requireUserID() + return try await service.allBrokerages(for: userID) + } + + private func create(req: Request) async throws -> BrokerageDTO { + let userID = try req.requireUserID() + let input = try req.content.decode(CreateUpdateBrokerageRequestDTO.self) + return try await service.createBrokerage(named: input.name, for: userID) + } + + private func detail(req: Request) async throws -> BrokerageDTO { + let userID = try req.requireUserID() + let id = try req.requiredID() + return try await service.brokerageDetail(id: id, for: userID, on: req.db) + } + + private func update(req: Request) async throws -> HTTPStatus { + let userID = try req.requireUserID() + let id = try req.requiredID() + let input = try req.content.decode(CreateUpdateBrokerageRequestDTO.self) + _ = try await service.updateBrokerage(id: id, + update: input, + for: userID) + return .ok + } + + private func remove(req: Request) async throws -> HTTPStatus { + let userID = try req.requireUserID() + let id = try req.requiredID() + try await service.deleteBrokerage(id: id, for: userID) + return .ok + } + + private func createAccount(req: Request) async throws -> BrokerageAccountDTO { + let userID = try req.requireUserID() + let brokerageID = try req.requiredID() + let input = try req.content.decode(CreateBrokerageAccountDTO.self) + return try await accountsService.create(input, on: brokerageID, for: userID) + } +} + +extension BrokerageDTO: ResponseDTO { } diff --git a/Sources/Grodt/Endpoints/Routes/ski/home/BrokerageInfoDTO.swift b/Sources/Grodt/Endpoints/Routes/ski/home/BrokerageInfoDTO.swift new file mode 100644 index 0000000..63533ba --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/home/BrokerageInfoDTO.swift @@ -0,0 +1,9 @@ +import Foundation + +struct BrokerageInfoDTO: ResponseDTO { + let id: UUID + let name: String + let value: Decimal + let currency: CurrencyDTO + let accountCount: Int +} diff --git a/Sources/Grodt/Endpoints/Routes/ski/home/HomeResponseDTO.swift b/Sources/Grodt/Endpoints/Routes/ski/home/HomeResponseDTO.swift new file mode 100644 index 0000000..5419404 --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/home/HomeResponseDTO.swift @@ -0,0 +1,7 @@ +struct HomeResponseDTO: ResponseDTO { + let user: UserInfoDTO + let networth: PerformanceDTO + let portfolios: [PortfolioInfoDTO] + let brokerages: [BrokerageInfoDTO] + let investments: [InvestmentDTO] +} diff --git a/Sources/Grodt/Endpoints/Routes/ski/home/HomeRoute.swift b/Sources/Grodt/Endpoints/Routes/ski/home/HomeRoute.swift new file mode 100644 index 0000000..21c7707 --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/home/HomeRoute.swift @@ -0,0 +1,71 @@ +import Vapor +import Fluent + +class HomeRoute: RouteCollection { + private let portfolioService: PortfolioService + private let accountService: AccountService + private let brokerageService: BrokerageService + private let investmentService: InvestmentService + + init(portfolioService: PortfolioService, + accountService: AccountService, + brokeragesService: BrokerageService, + investmentService: InvestmentService) { + self.portfolioService = portfolioService + self.accountService = accountService + self.brokerageService = brokeragesService + self.investmentService = investmentService + } + + func boot(routes: any Vapor.RoutesBuilder) throws { + let home = routes.grouped("home") + home.get(use: `get`) + } + + private func get(req: Request) async throws -> HomeResponseDTO { + guard let userID = req.auth.get(User.self)?.id else { + throw Abort(.badRequest) + } + + let portfolios = try await portfolioService.allPortfolios(userID: userID) + let userInfo = try await accountService.userInfo(for: userID) + let networth = try await totalPerformance(of: portfolios) + let brokerages = try await brokerageService.allBrokerages(for: userID) + .compactMap { BrokerageInfoDTO(id: $0.id, + name: $0.name, + value: $0.performance.moneyOut, + currency: CurrencyDTO(code: "EUR", symbol: "€"), + accountCount: $0.accounts.count) + } + let investments = try await investmentService.allInvestments(for: userID) + + let response = HomeResponseDTO(user: userInfo, + networth: networth, + portfolios: portfolios, + brokerages: brokerages, + investments: investments) + return response + } + + private func totalPerformance(of portfolios: [PortfolioInfoDTO]) async throws -> PerformanceDTO { + + let (moneyIn, moneyOut) = await portfolios + .concurrentCompactMap { + $0.performance + } + .reduce(into: (Decimal.zero, Decimal.zero)) { acc, p in + acc.0 += p.moneyIn + acc.1 += p.moneyOut + } + + let profit = moneyOut - moneyIn + let totalReturn: Decimal = moneyIn > 0 ? (profit / moneyIn).rounded(to: 2) : .zero + + return PerformanceDTO( + moneyIn: moneyIn, + moneyOut: moneyOut, + profit: profit, + totalReturn: totalReturn + ) + } +} diff --git a/Sources/Grodt/Endpoints/InvestmentController.swift b/Sources/Grodt/Endpoints/Routes/ski/investments/InvestmentsRoute.swift similarity index 52% rename from Sources/Grodt/Endpoints/InvestmentController.swift rename to Sources/Grodt/Endpoints/Routes/ski/investments/InvestmentsRoute.swift index cf4a90a..a9c38ce 100644 --- a/Sources/Grodt/Endpoints/InvestmentController.swift +++ b/Sources/Grodt/Endpoints/Routes/ski/investments/InvestmentsRoute.swift @@ -1,14 +1,11 @@ import Vapor import Fluent -struct InvestmentController: RouteCollection { - private let portfolioRepository: PortfolioRepository - private let dataMapper: InvestmentDTOMapper +class InvestmentRoute: RouteCollection { + private let serivce: InvestmentService - init(portfolioRepository: PortfolioRepository, - dataMapper: InvestmentDTOMapper) { - self.portfolioRepository = portfolioRepository - self.dataMapper = dataMapper + init(serivce: InvestmentService) { + self.serivce = serivce } func boot(routes: Vapor.RoutesBuilder) throws { @@ -25,10 +22,7 @@ struct InvestmentController: RouteCollection { throw Abort(.badRequest) } - let transactions = try await portfolioRepository.allPortfolios(for: userID) - .flatMap { $0.transactions } - - return try await dataMapper.investments(from: transactions) + return try await serivce.allInvestments(for: userID) } func invesetmentDetail(req: Request) async throws -> InvestmentDetailDTO { @@ -38,11 +32,7 @@ struct InvestmentController: RouteCollection { throw Abort(.badRequest) } - let portfolios = try await portfolioRepository.allPortfolios(for: userID) - let transactions = portfolios - .flatMap { $0.transactions } - .filter { $0.ticker == ticker } - return try await dataMapper.investmentDetail(from: transactions) + return try await serivce.investmentDetail(for: ticker, userID: userID) } } diff --git a/Sources/Grodt/Endpoints/Routes/ski/portfolios/PortfolioResponseDTO.swift b/Sources/Grodt/Endpoints/Routes/ski/portfolios/PortfolioResponseDTO.swift new file mode 100644 index 0000000..6d4beec --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/portfolios/PortfolioResponseDTO.swift @@ -0,0 +1,4 @@ +struct PortfolioResponseDTO: ResponseDTO { + let portfolio: PortfolioDTO + let historicalPerformance: PerformanceTimeSeriesDTO +} diff --git a/Sources/Grodt/Endpoints/Routes/ski/portfolios/PortfolioRoute.swift b/Sources/Grodt/Endpoints/Routes/ski/portfolios/PortfolioRoute.swift new file mode 100644 index 0000000..b739560 --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/portfolios/PortfolioRoute.swift @@ -0,0 +1,106 @@ +import Vapor +import Fluent + +class PortfolioRoute: RouteCollection { + private let service: PortfolioService + private let transactionService: TransactionService + private let tickersService: TickersService + private let brokerageService: BrokerageService + + init(service: PortfolioService, + transactionService: TransactionService, + tickersService: TickersService, + brokerageService: BrokerageService) { + self.service = service + self.transactionService = transactionService + self.tickersService = tickersService + self.brokerageService = brokerageService + } + + func boot(routes: any Vapor.RoutesBuilder) throws { + let portfolios = routes.grouped("portfolios") + portfolios.post(use: create) + + portfolios.group(":id") { portfolio in + portfolio.get(use: `get`) + portfolio.patch(use: updateName) + portfolio.delete(use: delete) + + portfolio.group("transactions") { transactions in + transactions.post(use: createTransaction) + transactions.get("options", use: getTransactionOptions) + } + } + } + + private func get(req: Request) async throws -> PortfolioResponseDTO { + let id = try req.requiredID() + + guard let userID = req.auth.get(User.self)?.id else { + throw Abort(.badRequest) + } + + let portfolio = try await service.portfolioDetail(for: id, + userID: userID) + let performance = try await service.historicalPerformance(for: id, + userID: userID) + + return PortfolioResponseDTO(portfolio: portfolio, + historicalPerformance: performance) + } + + private func create(req: Request) async throws -> PortfolioDTO { + guard let userID = req.auth.get(User.self)?.id else { + throw Abort(.badRequest) + } + + let postPortfolio = try req.content.decode(CreatePortfolioRequestDTO.self) + + return try await service.create(request: postPortfolio, userID: userID) + } + + private func updateName(req: Request) async throws -> PortfolioDTO { + let id = try req.requiredID() + guard let userID = req.auth.get(User.self)?.id else { + throw Abort(.badRequest) + } + + let request = try req.content.decode(RenamePortfolioRequestDTO.self) + + return try await service.updateName(with: id, + forUser: userID, + newName: request.name) + } + + private func delete(req: Request) async throws -> HTTPStatus { + let id = try req.requiredID() + + guard let userID = req.auth.get(User.self)?.id else { + throw Abort(.badRequest) + } + + return try await service.delete(for: id, userID: userID) + } + + private func createTransaction(req: Request) async throws -> TransactionDTO { + let portfolioID = try req.requiredID() + let transaction = try req.content.decode(CreateTransactionRequestDTO.self) + return try await transactionService.create(transaction, on: portfolioID) + } + + private func getTransactionOptions(req: Request) async throws -> CreateTransactionOptionsDTO { + guard let userID = req.auth.get(User.self)?.id else { + throw Abort(.badRequest) + } + + let tickers = try await tickersService.allTickers() + let brokerages = try await brokerageService.allBrokerages(for: userID) + return CreateTransactionOptionsDTO(tickers: tickers, + brokerages: brokerages) + } +} + +struct CreateTransactionOptionsDTO: ResponseDTO { + let tickers: [TickerDTO] + let brokerages: [BrokerageDTO] +} diff --git a/Sources/Grodt/Endpoints/Routes/ski/routes+ski.swift b/Sources/Grodt/Endpoints/Routes/ski/routes+ski.swift new file mode 100644 index 0000000..91e6acb --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/routes+ski.swift @@ -0,0 +1,64 @@ +import Vapor + +func registerSkiRoutes(_ app: Application, _ container: AppContainer) throws { + try app.group("ski", "v1") { ski in + let tokenAuthMiddleware = UserToken.authenticator() + let guardAuthMiddleware = User.guardMiddleware() + let protected = ski.grouped([ + UserTokenCookieAuthenticator(), + tokenAuthMiddleware, + OriginRefererCheckMiddleware(), + guardAuthMiddleware + ]) + + try protected.register(collection: HomeRoute( + portfolioService: container.portfolioService, + accountService: container.accountService, + brokeragesService: container.brokerageService, + investmentService: container.investmentService) + ) + + try protected.register(collection: PortfolioRoute( + service: container.portfolioService, + transactionService: container.transactionService, + tickersService: container.tickersService, + brokerageService: container.brokerageService) + ) + + let transactionsRoute = TransactionsRoute( + transactionsRepository: container.transactionRepository, + currencyRepository: container.currencyRepository, + dataMapper: container.transactionDTOMapper) + + try protected.register(collection: transactionsRoute) + + transactionsRoute.delegate = TransactionChangedHandler( + portfolioRepository: container.portfolioRepository, + historicalPerformanceUpdater: container.portfolioPerformanceUpdater + ) + + try protected.register(collection: TickersRoute( + service: container.tickersService) + ) + + try protected.register(collection: BrokeragesRoute( + service: container.brokerageService, + accountsService: container.brokerageAccountsService) + ) + + try protected.register(collection: BrokerageAccountsRoute( + service: container.brokerageAccountsService, + brokerageRepository: container.brokerageRepository, + brokerageAccountRepository: container.brokerageAccountRepository, + performanceRepository: container.brokerageAccountDailyPerformanceRepository, + performanceDTOMapper: container.performanceDTOMapper, + currencyMapper: container.currencyDTOMapper, + transactionDTOMapper: container.transactionDTOMapper, + currencyRepository: container.currencyRepository + )) + + try protected.register(collection: InvestmentRoute( + serivce: container.investmentService) + ) + } +} diff --git a/Sources/Grodt/Endpoints/Routes/ski/tickers/TickersRoute.swift b/Sources/Grodt/Endpoints/Routes/ski/tickers/TickersRoute.swift new file mode 100644 index 0000000..8e16a36 --- /dev/null +++ b/Sources/Grodt/Endpoints/Routes/ski/tickers/TickersRoute.swift @@ -0,0 +1,32 @@ +import Vapor +import CollectionConcurrencyKit + +class TickersRoute: RouteCollection { + private let service: TickersService + + init(service: TickersService) { + self.service = service + } + + func boot(routes: Vapor.RoutesBuilder) throws { + let tickers = routes.grouped("tickers") + tickers.post(use: create) + + tickers.get("search", ":keyword", use: search) + } + + func create(req: Request) async throws -> TickerDTO { + let postTicker = try req.content.decode(TickerDTO.self) + let ticker = Ticker(symbol: postTicker.symbol, + region: postTicker.region, + name: postTicker.name, + currency: postTicker.currency) + + return try await service.create(ticker) + } + + func search(req: Request) async throws -> [TickerDTO] { + let keyword: String = try req.requiredParameter(named: "keyword") + return try await service.search(keyword: keyword) + } +} diff --git a/Sources/Grodt/Endpoints/transactions/TransactionsController.swift b/Sources/Grodt/Endpoints/Routes/ski/transactions/TransactionRoute.swift similarity index 66% rename from Sources/Grodt/Endpoints/transactions/TransactionsController.swift rename to Sources/Grodt/Endpoints/Routes/ski/transactions/TransactionRoute.swift index f7225d9..1b1aed8 100644 --- a/Sources/Grodt/Endpoints/transactions/TransactionsController.swift +++ b/Sources/Grodt/Endpoints/Routes/ski/transactions/TransactionRoute.swift @@ -9,7 +9,7 @@ protocol TransactionsControllerDelegate: AnyObject { func transactionDeleted(_ transaction: Transaction) async throws } -class TransactionsController: RouteCollection { +class TransactionsRoute: RouteCollection { private let transactionsRepository: TransactionsRepository private let currencyRepository: CurrencyRepository private let dataMapper: TransactionDTOMapper @@ -25,7 +25,6 @@ class TransactionsController: RouteCollection { func boot(routes: Vapor.RoutesBuilder) throws { let transactions = routes.grouped("transactions") - transactions.post(use: create) transactions.group(":id") { transaction in transaction.get(use: transactionDetail) @@ -34,32 +33,6 @@ class TransactionsController: RouteCollection { } } - private func create(req: Request) async throws -> TransactionDTO { - let transaction = try req.content.decode(CreateTransactionRequestDTO.self) - guard let currency = try await currencyRepository.currency(for: transaction.currency) else { - throw Abort(.badRequest) - } - - let brokerageAccountId: UUID? = { - guard let id = transaction.brokerageAccountID else { return nil } - return UUID(uuidString: id) - }() - - - let newTransaction = Transaction(portfolioID: UUID(uuidString: transaction.portfolio)!, - brokerageAccountID: brokerageAccountId, - purchaseDate: transaction.purchaseDate, - ticker: transaction.ticker, - currency: currency, - fees: transaction.fees, - numberOfShares: transaction.numberOfShares, - pricePerShareAtPurchase: transaction.pricePerShare) - - try await newTransaction.save(on: req.db) - try await delegate?.transactionCreated(newTransaction) - return try await dataMapper.transaction(from: newTransaction) - } - private func transactionDetail(req: Request) async throws -> TransactionDTO { let id = try req.requiredID() diff --git a/Sources/Grodt/Endpoints/Services/AccountService.swift b/Sources/Grodt/Endpoints/Services/AccountService.swift new file mode 100644 index 0000000..afef698 --- /dev/null +++ b/Sources/Grodt/Endpoints/Services/AccountService.swift @@ -0,0 +1,20 @@ +import Foundation +import Vapor + +class AccountService { + private let userRepository: UserRepository + private let userDataMapper: UserDTOMapper + + init(userRepository: UserRepository, userDataMapper: UserDTOMapper) { + self.userRepository = userRepository + self.userDataMapper = userDataMapper + } + + func userInfo(for userID: UUID) async throws -> UserInfoDTO { + guard let user = try await userRepository.user(for: userID) else { + throw Abort(.notFound) + } + + return userDataMapper.userInfo(from: user) + } +} diff --git a/Sources/Grodt/Endpoints/Services/BrokerageAccountsService.swift b/Sources/Grodt/Endpoints/Services/BrokerageAccountsService.swift new file mode 100644 index 0000000..b08d9fa --- /dev/null +++ b/Sources/Grodt/Endpoints/Services/BrokerageAccountsService.swift @@ -0,0 +1,71 @@ +import Vapor +import Fluent + +struct BrokerageAccountsService { + private let brokerageRepository: BrokerageRepository + private let brokerageAccountRepository: BrokerageAccountRepository + private let performanceRepository: PostgresBrokerageAccountDailyPerformanceRepository + private let currencyMapper: CurrencyDTOMapper + private let performanceDTOMapper: DatedPerformanceDTOMapper + private let currencyRepository: CurrencyRepository + private let transactionDTOMapper: TransactionDTOMapper + + init(brokerageRepository: BrokerageRepository, + brokerageAccountRepository: BrokerageAccountRepository, + performanceRepository: PostgresBrokerageAccountDailyPerformanceRepository, + performanceDTOMapper: DatedPerformanceDTOMapper, + currencyMapper: CurrencyDTOMapper, + transactionDTOMapper: TransactionDTOMapper, + currencyRepository: CurrencyRepository) { + self.brokerageRepository = brokerageRepository + self.brokerageAccountRepository = brokerageAccountRepository + self.performanceRepository = performanceRepository + self.performanceDTOMapper = performanceDTOMapper + self.currencyMapper = currencyMapper + self.transactionDTOMapper = transactionDTOMapper + self.currencyRepository = currencyRepository + } + + func allAccounts(for userID: User.IDValue) async throws -> [BrokerageAccountInfoDTO] { + let brokerageAccounts = try await brokerageAccountRepository.all(for: userID) + return try await brokerageAccounts.asyncMap { brokerageAccount in + let performance = try await brokerageAccountRepository.performance(for: brokerageAccount.requireID()) + let brokerage = brokerageAccount.brokerage + return BrokerageAccountInfoDTO( + id: try brokerageAccount.requireID(), + brokerageId: try brokerage.requireID(), + brokerageName: brokerage.name, + displayName: brokerageAccount.displayName, + baseCurrency: currencyMapper.currency(from: brokerageAccount.baseCurrency), + performance: performance) + } + } + + func create(_ request: CreateBrokerageAccountDTO, on brokerageID: Brokerage.IDValue, for userID: User.IDValue) async throws -> BrokerageAccountDTO { + guard let currency = try await currencyRepository.currency(for: request.currency) else { + throw Abort(.badRequest) + } + + guard let brokerage = try await brokerageRepository.find(brokerageID, for: userID) + else { throw Abort(.notFound, reason: "Brokerage not found") } + + let model = BrokerageAccount(brokerageID: brokerageID, + displayName: request.displayName, + baseCurrency: currency) + + try await brokerageAccountRepository.create(model) + return BrokerageAccountDTO(id: try model.requireID(), + brokerageId: try brokerage.requireID(), + brokerageName: brokerage.name, + displayName: model.displayName, + baseCurrency: currencyMapper.currency(from: model.baseCurrency), + performance: PerformanceDTO.zero, + transactions: [], + historicalPerformance: PerformanceTimeSeriesDTO(values: [])) + } +} + +struct CreateBrokerageAccountDTO: ResponseDTO { + let displayName: String + let currency: String +} diff --git a/Sources/Grodt/Endpoints/Services/BrokerageService.swift b/Sources/Grodt/Endpoints/Services/BrokerageService.swift new file mode 100644 index 0000000..df7dfda --- /dev/null +++ b/Sources/Grodt/Endpoints/Services/BrokerageService.swift @@ -0,0 +1,58 @@ +import Vapor +import Fluent + +struct BrokerageService { + private let brokerageRepository: BrokerageRepository + private let dtoMapper: BrokerageDTOMapper + private let accountsRepository: BrokerageAccountRepository + private let currencyMapper: CurrencyDTOMapper + + init(brokerageRepository: BrokerageRepository, + dtoMapper: BrokerageDTOMapper, + accounts: BrokerageAccountRepository, + currencyMapper: CurrencyDTOMapper) { + self.brokerageRepository = brokerageRepository + self.dtoMapper = dtoMapper + self.accountsRepository = accounts + self.currencyMapper = currencyMapper + } + + func allBrokerages(for userID: UUID) async throws -> [BrokerageDTO] { + let brokerages = try await brokerageRepository.list(for: userID) + return try await brokerages.concurrentMap {try await dtoMapper.brokerage(from: $0) } + } + + func createBrokerage(named: String, for userID: UUID) async throws -> BrokerageDTO { + let brokerage = Brokerage(userID: userID, name: named) + try await brokerageRepository.create(brokerage) + return try await dtoMapper.brokerage(from: brokerage) + } + + func brokerageDetail(id: UUID, + for userID: UUID, + on db: Database) async throws -> BrokerageDTO { + guard let brokerage = try await brokerageRepository.find(id, for: userID) else { + throw Abort(.notFound) + } + return try await dtoMapper.brokerage(from: brokerage) + } + + func updateBrokerage(id: UUID, + update: CreateUpdateBrokerageRequestDTO, + for userID: UUID) async throws -> BrokerageDTO { + guard let brokerage = try await brokerageRepository.find(id, for: userID) else { + throw Abort(.notFound) + } + brokerage.name = update.name + try await brokerageRepository.update(brokerage) + return try await dtoMapper.brokerage(from: brokerage) + } + + func deleteBrokerage(id: UUID, + for userID: UUID) async throws { + guard let brokerage = try await brokerageRepository.find(id, for: userID) else { + throw Abort(.notFound) + } + try await brokerageRepository.delete(brokerage) + } +} diff --git a/Sources/Grodt/Endpoints/Services/InvestmentService.swift b/Sources/Grodt/Endpoints/Services/InvestmentService.swift new file mode 100644 index 0000000..8f12d45 --- /dev/null +++ b/Sources/Grodt/Endpoints/Services/InvestmentService.swift @@ -0,0 +1,25 @@ +class InvestmentService { + private let portfolioRepository: PortfolioRepository + private let dataMapper: InvestmentDTOMapper + + init(portfolioRepository: PortfolioRepository, + dataMapper: InvestmentDTOMapper) { + self.portfolioRepository = portfolioRepository + self.dataMapper = dataMapper + } + + func allInvestments(for userID: User.IDValue) async throws -> [InvestmentDTO] { + let transactions = try await portfolioRepository.allPortfolios(for: userID) + .flatMap { $0.transactions } + + return try await dataMapper.investments(from: transactions) + } + + func investmentDetail(for ticker: String, userID: User.IDValue) async throws -> InvestmentDetailDTO { + let portfolios = try await portfolioRepository.allPortfolios(for: userID) + let transactions = portfolios + .flatMap { $0.transactions } + .filter { $0.ticker == ticker } + return try await dataMapper.investmentDetail(from: transactions) + } +} diff --git a/Sources/Grodt/Endpoints/Services/PortfolioService.swift b/Sources/Grodt/Endpoints/Services/PortfolioService.swift new file mode 100644 index 0000000..e0b22e7 --- /dev/null +++ b/Sources/Grodt/Endpoints/Services/PortfolioService.swift @@ -0,0 +1,90 @@ +import Vapor +import Fluent + +class PortfolioService { + private let portfolioRepository: PortfolioRepository + private let portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository + private let currencyRepository: CurrencyRepository + private let dataMapper: PortfolioDTOMapper + private let portfolioPerformanceUpdater: PortfolioPerformanceUpdating + + init(portfolioRepository: PortfolioRepository, + currencyRepository: CurrencyRepository, + historicalPortfolioPerformanceUpdater: PortfolioPerformanceUpdating, + portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository, + dataMapper: PortfolioDTOMapper) { + self.portfolioRepository = portfolioRepository + self.currencyRepository = currencyRepository + self.portfolioPerformanceUpdater = historicalPortfolioPerformanceUpdater + self.portfolioDailyRepo = portfolioDailyRepo + self.dataMapper = dataMapper + } + + func allPortfolios(userID: User.IDValue) async throws -> [PortfolioInfoDTO] { + return try await portfolioRepository.allPortfolios(for: userID) + .concurrentCompactMap { portfolio in + return try await self.dataMapper.portfolioInfo(from: portfolio) + } + } + + func create(request: CreatePortfolioRequestDTO, + userID: User.IDValue) async throws -> PortfolioDTO { + + guard let currency = try await currencyRepository.currency(for: request.currency) else { + throw Abort(.badRequest) + } + + let portfolio = Portfolio(userID: userID, + name: request.name, + currency: currency) + + let newPortfolio = try await portfolioRepository.create(portfolio) + return try await dataMapper.portfolio(from: newPortfolio) + } + + func portfolioDetail(for id: Portfolio.IDValue, + userID: User.IDValue) async throws -> PortfolioDTO { + guard let portfolio = try await portfolioRepository.portfolio(for: userID, with: id) else { + throw Abort(.notFound) + } + + return try await dataMapper.portfolio(from: portfolio) + } + + func updateName(with id: Portfolio.IDValue, + forUser userID: User.IDValue, + newName: String) async throws -> PortfolioDTO { + guard let portfolio = try await portfolioRepository.portfolio(for: userID, with: id) else { + throw Abort(.notFound) + } + + portfolio.name = newName + + let updatedPortfolio = try await portfolioRepository.update(portfolio) + return try await dataMapper.portfolio(from: updatedPortfolio) + } + + func delete(for id: Portfolio.IDValue, + userID: User.IDValue) async throws -> HTTPStatus { + do { + try await portfolioRepository.delete(for: userID, with: id) + } catch FluentError.noResults { + return .ok + } + return .ok + } + + func historicalPerformance(for id: Portfolio.IDValue, + userID: User.IDValue) async throws -> PerformanceTimeSeriesDTO { + guard let _ = try await portfolioRepository.portfolio(for: userID, with: id) else { + throw Abort(.notFound) + } + + let series = try await portfolioDailyRepo.readSeries(for: id, from: nil, to: nil) + return await dataMapper.timeSeriesPerformance(from: series) + } +} + +extension PortfolioDTO: ResponseDTO { } +extension PortfolioInfoDTO: ResponseDTO { } +extension PerformanceTimeSeriesDTO: ResponseDTO{ } diff --git a/Sources/Grodt/Endpoints/TickersController.swift b/Sources/Grodt/Endpoints/Services/TickersService.swift similarity index 57% rename from Sources/Grodt/Endpoints/TickersController.swift rename to Sources/Grodt/Endpoints/Services/TickersService.swift index f432524..815c938 100644 --- a/Sources/Grodt/Endpoints/TickersController.swift +++ b/Sources/Grodt/Endpoints/Services/TickersService.swift @@ -6,7 +6,7 @@ protocol TickersControllerDelegate: AnyObject { func tickerCreated(_ ticker: Ticker) } -struct TickersController: RouteCollection { +class TickersService { private let tickerRepository: TickerRepository private let dataMapper: TickerDTOMapper private let tickerService: AlphaVantageService @@ -21,36 +21,20 @@ struct TickersController: RouteCollection { self.tickerService = tickerService } - func boot(routes: Vapor.RoutesBuilder) throws { - let tickers = routes.grouped("tickers") - tickers.get(use: allTickers) - tickers.post(use: create) - - tickers.get("search", ":keyword", use: search) - } - - func allTickers(req: Request) async throws -> [TickerDTO] { + func allTickers() async throws -> [TickerDTO] { return try await tickerRepository.allTickers() .concurrentCompactMap { ticker in - return dataMapper.ticker(from: ticker) + return self.dataMapper.ticker(from: ticker) } } - func create(req: Request) async throws -> TickerDTO { - let postTicker = try req.content.decode(TickerDTO.self) - let ticker = Ticker(symbol: postTicker.symbol, - region: postTicker.region, - name: postTicker.name, - currency: postTicker.currency) - - try await ticker.save(on: req.db) + func create(_ ticker: Ticker) async throws -> TickerDTO { + try await tickerRepository.save(ticker) delegate?.tickerCreated(ticker) - return postTicker + return dataMapper.ticker(from: ticker) } - func search(req: Request) async throws -> [TickerDTO] { - let keyword: String = try req.requiredParameter(named: "keyword") - + func search(keyword: String) async throws -> [TickerDTO] { let result = await tickerService.symbolSearch(keywords: keyword) switch result { case .success(let symbols): @@ -66,4 +50,4 @@ struct TickersController: RouteCollection { } } -extension TickerDTO: Content { } +extension TickerDTO: ResponseDTO { } diff --git a/Sources/Grodt/Endpoints/Services/TransactionService.swift b/Sources/Grodt/Endpoints/Services/TransactionService.swift new file mode 100644 index 0000000..237d787 --- /dev/null +++ b/Sources/Grodt/Endpoints/Services/TransactionService.swift @@ -0,0 +1,42 @@ +import Vapor + +class TransactionService { + private let transactionsRepository: TransactionsRepository + private let currencyRepository: CurrencyRepository + private let dataMapper: TransactionDTOMapper + var delegate: TransactionsControllerDelegate? // TODO: Weak + + init(transactionsRepository: TransactionsRepository, + currencyRepository: CurrencyRepository, + dataMapper: TransactionDTOMapper) { + self.transactionsRepository = transactionsRepository + self.currencyRepository = currencyRepository + self.dataMapper = dataMapper + } + + func create(_ transaction: CreateTransactionRequestDTO, on portfolioID: Portfolio.IDValue) async throws -> TransactionDTO { + guard let currency = try await currencyRepository.currency(for: transaction.currency) else { + throw Abort(.badRequest) + } + + let brokerageAccountId: UUID? = { + guard let id = transaction.brokerageAccountID else { return nil } + return UUID(uuidString: id) + }() + + + let newTransaction = Transaction(portfolioID: portfolioID, + brokerageAccountID: brokerageAccountId, + purchaseDate: transaction.purchaseDate, + ticker: transaction.ticker, + currency: currency, + fees: transaction.fees, + numberOfShares: transaction.numberOfShares, + pricePerShareAtPurchase: transaction.pricePerShare) + + try await transactionsRepository.save(newTransaction) + try await delegate?.transactionCreated(newTransaction) + return try await dataMapper.transaction(from: newTransaction) + } +} + diff --git a/Sources/Grodt/Endpoints/brokerages/BrokerageAccountController.swift b/Sources/Grodt/Endpoints/brokerages/BrokerageAccountController.swift deleted file mode 100644 index d114f07..0000000 --- a/Sources/Grodt/Endpoints/brokerages/BrokerageAccountController.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Vapor -import Fluent - -struct BrokerageAccountController: RouteCollection { - private let brokerageAccountRepository: BrokerageAccountRepository - private let performanceRepository: PostgresBrokerageAccountDailyPerformanceRepository - private let currencyMapper: CurrencyDTOMapper - private let performanceDTOMapper: DatedPerformanceDTOMapper - private let currencyRepository: CurrencyRepository - private let transactionDTOMapper: TransactionDTOMapper - - init(brokerageAccountRepository: BrokerageAccountRepository, - performanceRepository: PostgresBrokerageAccountDailyPerformanceRepository, - performanceDTOMapper: DatedPerformanceDTOMapper, - currencyMapper: CurrencyDTOMapper, - transactionDTOMapper: TransactionDTOMapper, - currencyRepository: CurrencyRepository) { - self.brokerageAccountRepository = brokerageAccountRepository - self.performanceRepository = performanceRepository - self.performanceDTOMapper = performanceDTOMapper - self.currencyMapper = currencyMapper - self.transactionDTOMapper = transactionDTOMapper - self.currencyRepository = currencyRepository - } - - func boot(routes: RoutesBuilder) throws { - let group = routes.grouped("brokerage-accounts") - group.get(use: list) - group.post(use: create) - group.group(":id") { item in - item.get(use: detail) - item.put(use: update) - item.delete(use: remove) - item.get("performance", use: performanceSeries) - } - } - - private func list(req: Request) async throws -> [BrokerageAccountInfoDTO] { - let userID = try req.requireUserID() - let items = try await brokerageAccountRepository.all(for: userID) - return try await items.asyncMap { model in - let performance = try await brokerageAccountRepository.performance(for: model.requireID()) - let brokerage = try await model.$brokerage.get(on: req.db) - return BrokerageAccountInfoDTO( - id: try model.requireID(), - brokerageId: try brokerage.requireID(), - brokerageName: brokerage.name, - displayName: model.displayName, - baseCurrency: currencyMapper.currency(from: model.baseCurrency), - performance: performance) - } - } - - private func create(req: Request) async throws -> BrokerageAccountDTO { - let userID = try req.requireUserID() - struct In: Content { - let brokerageID: UUID - let displayName: String - let currency: String - } - let input = try req.content.decode(In.self) - guard let currency = try await currencyRepository.currency(for: input.currency) else { - throw Abort(.badRequest) - } - - guard let brokerage = try await Brokerage.query(on: req.db) - .filter(\.$id == input.brokerageID) - .filter(\.$user.$id == userID) - .first() - else { throw Abort(.notFound, reason: "Brokerage not found") } - - let model = BrokerageAccount(brokerageID: try brokerage.requireID(), - displayName: input.displayName, - baseCurrency: currency) - - try await brokerageAccountRepository.create(model) - return BrokerageAccountDTO(id: try model.requireID(), - brokerageId: try brokerage.requireID(), - brokerageName: brokerage.name, - displayName: model.displayName, - baseCurrency: currencyMapper.currency(from: model.baseCurrency), - performance: PerformanceDTO.zero, - transactions: []) - } - - private func detail(req: Request) async throws -> BrokerageAccountDTO { - let userID = try req.requireUserID() - let model = try await requireAccount(req, userID: userID) - let brokerage = try await model.$brokerage.get(on: req.db) - let transactions = try await model.$transactions.get(on: req.db) - let performance = try await brokerageAccountRepository.performance(for: model.requireID()) - return try await BrokerageAccountDTO(id: try model.requireID(), - brokerageId: try brokerage.requireID(), - brokerageName: brokerage.name, - displayName: model.displayName, - baseCurrency: currencyMapper.currency(from: model.baseCurrency), - performance: performance, - transactions: transactions.asyncMap { try await transactionDTOMapper.transaction(from: $0) }) - } - - private func update(req: Request) async throws -> HTTPStatus { - let userID = try req.requireUserID() - let model = try await requireAccount(req, userID: userID) - struct In: Content { let displayName: String; let accountNumberMasked: String? } - let input = try req.content.decode(In.self) - model.displayName = input.displayName - try await brokerageAccountRepository.update(model) - return .ok - } - - private func remove(req: Request) async throws -> HTTPStatus { - let userID = try req.requireUserID() - let model = try await requireAccount(req, userID: userID) - try await brokerageAccountRepository.delete(model) - return .noContent - } - - private func performanceSeries(req: Request) async throws -> PerformanceTimeSeriesDTO { - let userID = try req.requireUserID() - let account = try await requireAccount(req, userID: userID) - let rows = try await performanceRepository.readSeries(for: account.requireID(), - from: nil, - to: nil) - - let values = rows.map { performanceDTOMapper.performancePoint(from: $0) } - .sorted { $0.date < $1.date } - return PerformanceTimeSeriesDTO(values: values) - } - - private func requireAccount(_ req: Request, userID: UUID) async throws -> BrokerageAccount { - let id = try req.parameters.require("id", as: UUID.self) - guard let model = try await brokerageAccountRepository.find(id, for: userID) else { - throw Abort(.notFound) - } - return model - } -} - -extension BrokerageAccountDTO: Content { } -extension BrokerageAccountInfoDTO: Content { } diff --git a/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift b/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift deleted file mode 100644 index b88aa7e..0000000 --- a/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Vapor -import Fluent - -struct BrokerageController: RouteCollection { - private let brokerageRepository: BrokerageRepository - private let dtoMapper: BrokerageDTOMapper - private let accounts: BrokerageAccountRepository - private let currencyMapper: CurrencyDTOMapper - private let performanceRepository: PostgresBrokerageDailyPerformanceRepository - private let performanceDTOMapper: DatedPerformanceDTOMapper - - init(brokerageRepository: BrokerageRepository, - dtoMapper: BrokerageDTOMapper, - accounts: BrokerageAccountRepository, - currencyMapper: CurrencyDTOMapper, - performanceRepository: PostgresBrokerageDailyPerformanceRepository, - performanceDTOMapper: DatedPerformanceDTOMapper - ) { - self.brokerageRepository = brokerageRepository - self.dtoMapper = dtoMapper - self.accounts = accounts - self.currencyMapper = currencyMapper - self.performanceRepository = performanceRepository - self.performanceDTOMapper = performanceDTOMapper - } - - func boot(routes: RoutesBuilder) throws { - let group = routes.grouped("brokerages") - group.get(use: list) - group.post(use: create) - group.group(":id") { item in - item.get(use: detail) - item.put(use: update) - item.delete(use: remove) - item.get("performance", use: performanceSeries) - } - } - - private func list(req: Request) async throws -> [BrokerageDTO] { - let userID = try req.requireUserID() - let items = try await brokerageRepository.list(for: userID) - return try await items.asyncMap { try await dtoMapper.brokerage(from: $0) } - } - - private func create(req: Request) async throws -> BrokerageDTO { - let userID = try req.requireUserID() - let input = try req.content.decode(CreateUpdateBrokerageRequestDTO.self) - let item = Brokerage(userID: userID, name: input.name) - try await brokerageRepository.create(item) - return BrokerageDTO(id: try item.requireID(), - name: item.name, - accounts: [], - performance: PerformanceDTO.zero) - } - - private func detail(req: Request) async throws -> BrokerageDTO { - let userID = try req.requireUserID() - let brokerage = try await requireBrokerage(req, userID: userID) - return try await dtoMapper.brokerage(from: brokerage) - } - - private func update(req: Request) async throws -> HTTPStatus { - let userID = try req.requireUserID() - let brokerage = try await requireBrokerage(req, userID: userID) - let input = try req.content.decode(CreateUpdateBrokerageRequestDTO.self) - brokerage.name = input.name - try await brokerageRepository.update(brokerage) - return .ok - } - - private func remove(req: Request) async throws -> HTTPStatus { - let userID = try req.requireUserID() - let brokerage = try await requireBrokerage(req, userID: userID) - try await brokerageRepository.delete(brokerage) - return .noContent - } - - private func performanceSeries(req: Request) async throws -> PerformanceTimeSeriesDTO { - let userID = try req.requireUserID() - _ = try await requireBrokerage(req, userID: userID) - let id = try req.parameters.require("id", as: UUID.self) - let rows = try await performanceRepository.readSeries(for: id, from: nil, to: nil) - - let values = rows.map { performanceDTOMapper.performancePoint(from: $0) } - .sorted { $0.date < $1.date } - return PerformanceTimeSeriesDTO(values: values) - } - - private func requireBrokerage(_ req: Request, userID: UUID) async throws -> Brokerage { - let id = try req.parameters.require("id", as: UUID.self) - guard let model = try await brokerageRepository.find(id, for: userID) else { - throw Abort(.notFound) - } - return model - } -} - -extension BrokerageDTO: Content { } diff --git a/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift b/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift index f780c7c..2162ccf 100644 --- a/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift @@ -21,6 +21,7 @@ class PostgresBrokerageAccountRepository: BrokerageAccountRepository { let query = BrokerageAccount.query(on: database) .join(Brokerage.self, on: \BrokerageAccount.$brokerage.$id == \Brokerage.$id) .filter(Brokerage.self, \.$user.$id == userID) + .with(\.$brokerage) return try await query.all() } diff --git a/Sources/Grodt/Persistency/Repositories/TickerRepository.swift b/Sources/Grodt/Persistency/Repositories/TickerRepository.swift index c178905..584e96b 100644 --- a/Sources/Grodt/Persistency/Repositories/TickerRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/TickerRepository.swift @@ -3,6 +3,7 @@ import Fluent protocol TickerRepository { func allTickers() async throws -> [Ticker] func tickers(for symbol: String) async throws -> Ticker? + func save(_ ticker: Ticker) async throws } class PostgresTickerRepository: TickerRepository { @@ -22,4 +23,8 @@ class PostgresTickerRepository: TickerRepository { .filter(\Ticker.$symbol == symbol) .first() } + + func save(_ ticker: Ticker) async throws { + try await ticker.save(on: database) + } } diff --git a/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift b/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift index 44d986d..201abe1 100644 --- a/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift @@ -4,6 +4,7 @@ import Foundation protocol TransactionsRepository { func transaction(for id: UUID) async throws -> Transaction? func all(for userID: User.IDValue) async throws -> [Transaction] + func save(_ transaction: Transaction) async throws } class PostgresTransactionRepository: TransactionsRepository { @@ -29,4 +30,8 @@ class PostgresTransactionRepository: TransactionsRepository { .sort(\.$purchaseDate, .descending) .all() } + + func save(_ transaction: Transaction) async throws { + _ = try await transaction.save(on: database) + } } diff --git a/Sources/Grodt/Security/OriginRefererCheckMiddleware.swift b/Sources/Grodt/Security/OriginRefererCheckMiddleware.swift index c1ff6bf..e0d086f 100644 --- a/Sources/Grodt/Security/OriginRefererCheckMiddleware.swift +++ b/Sources/Grodt/Security/OriginRefererCheckMiddleware.swift @@ -3,31 +3,39 @@ import Foundation struct OriginRefererCheckMiddleware: AsyncMiddleware { func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response { - switch request.method { case .POST, .PUT, .PATCH, .DELETE: - let origin = request.headers.first(name: .origin) - let referer = request.headers.first(name: .referer) - let host = request.headers.first(name: .host) - + let origin = request.headers.first(name: .origin) + let referer = request.headers.first(name: .referer) + + let forwardedHost = request.headers.first(name: "X-Forwarded-Host") + let forwarded = request.headers.first(name: "Forwarded") + let forwardedHostFromForwarded = forwarded? + .split(separator: ";") + .compactMap { $0.trimmingCharacters(in: .whitespaces) } + .first(where: { $0.lowercased().hasPrefix("host=") })? + .dropFirst("host=".count) + + let effectiveHost = forwardedHost ?? (forwardedHostFromForwarded.map(String.init)) ?? request.headers.first(name: .host) + func hostMatches(_ urlString: String?, _ expectedHost: String?) -> Bool { guard let urlString, let expected = expectedHost, !urlString.isEmpty, !expected.isEmpty else { return true } let uri = URI(string: urlString) guard let got = uri.host else { return false } - return got.lowercased() == expected.split(separator: ":").first!.lowercased() + let expectedBase = expected.split(separator: ":").first?.lowercased() ?? expected.lowercased() + return got.lowercased() == expectedBase } - - if let origin, !hostMatches(origin, host) { + + if let origin, !hostMatches(origin, effectiveHost) { throw Abort(.forbidden, reason: "Cross-origin write blocked by Origin policy") } - if origin == nil, let referer, !hostMatches(referer, host) { + if origin == nil, let referer, !hostMatches(referer, effectiveHost) { throw Abort(.forbidden, reason: "Cross-origin write blocked by Referer policy") } default: break } - return try await next.respond(to: request) } } diff --git a/Tests/GrodtTests/Controllers/PortfoliosControllerTests.swift b/Tests/GrodtTests/Controllers/PortfoliosControllerTests.swift deleted file mode 100644 index ed81790..0000000 --- a/Tests/GrodtTests/Controllers/PortfoliosControllerTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -@testable import Grodt -import XCTVapor - -final class PortfoliosControllerTests: GrodtControllerTestCase { - override var basePath: String { return "api/portfolios" } - - // MARK: - Portfolios - func test_GivenNoPortfolios_WhenPortfoliosCalled_ThenExpectEmptyResponse() async throws { - // When - let response = try await sendRequest(.GET) - - // Then - XCTAssertEqual(response.status, .ok) - let portfolios = try response.content.decode([PortfolioInfoDTO].self) - XCTAssertEqual(portfolios.count, 0) - } - - func test_GivenSinglePortfolios_WhenPortfoliosCalled_ThenExpectResponse() async throws { - // Given - let expectedPortfolioInfo = TestConstant.PortfolioInfoDTOs.new - try await givenPortfolio(expectedPortfolioInfo) - - // When - let response = try await sendRequest(.GET) - - // Then - XCTAssertEqual(response.status, .ok) - let portfolioInfos = try response.content.decode([PortfolioInfoDTO].self) - XCTAssertEqual(portfolioInfos, [expectedPortfolioInfo]) - } - - // MARK: - Create - func test_GivenCurrencyExists_WhenCreatePortfolioCalled_ThenExpectNewPortfolioToReturn() async throws { - // Given - let expectedPortfolioInfo = TestConstant.PortfolioInfoDTOs.new - let newPortfolioDTO = CreatePortfolioRequestDTO(name: expectedPortfolioInfo.name, - currency: expectedPortfolioInfo.currency.code) - - // When - let response = try await sendPostRequest(body: newPortfolioDTO) - - // Then - XCTAssertEqual(response.status, .ok) - let portfolioInfo = try response.content.decode(PortfolioInfoDTO.self) - XCTAssertEqualExceptID(portfolioInfo, expectedPortfolioInfo) - } - - // MARK: - Details - func test_GivenNoPortfolios_WhenPortfolioDetailCalled_ThenExpectEmptyResponse() async throws { - // When - let response = try await sendRequest(.GET, "\(TestConstant.PortfolioInfoDTOs.new.id)") - - // Then - XCTAssertEqual(response.status, .notFound) - } - - func test_GivenPortfolios_WhenPortfolioDetailCalled_ThenExpectResponse() async throws { - // Given - let expectedPortfolio = TestConstant.PortfolioDTOs.new - try await givenPortfolio(expectedPortfolio) - - // When - let response = try await sendRequest(.GET, "\(expectedPortfolio.id)") - - // Then - XCTAssertEqual(response.status, .ok) - let actualPortfolio = try response.content.decode(PortfolioDTO.self) - XCTAssertEqual(actualPortfolio, expectedPortfolio) - } - - // MARK: - Delete - func test_GivenNoPortfolio_WhenDeleteCalled_ThenExpectOK() async throws { - // When - let response = try await sendRequest(.DELETE, "\(TestConstant.PortfolioInfoDTOs.new.id)") - - // Then - XCTAssertEqual(response.status, .ok) - } - - func test_GivenPortfolio_WhenDeleteCalled_ThenExpectOK() async throws { - // Given - let expectedPortfolio = TestConstant.PortfolioDTOs.new - try await givenPortfolio(expectedPortfolio) - - // When - let response = try await sendRequest(.DELETE, "\(expectedPortfolio.id)") - - // Then - XCTAssertEqual(response.status, .ok) - } - - private func givenPortfolio(_ portfolioDTO: PortfolioDTO) async throws { - let portfolio = Portfolio(id: UUID(uuidString: portfolioDTO.id), - userID: user.id!, - name: portfolioDTO.name, - currency: portfolioDTO.currency.model) - try await portfolio.save(on: database) - } - - private func givenPortfolio(_ portfolioInfoDTO: PortfolioInfoDTO) async throws { - let portfolio = Portfolio(id: UUID(uuidString: portfolioInfoDTO.id), - userID: user.id!, - name: portfolioInfoDTO.name, - currency: portfolioInfoDTO.currency.model) - try await portfolio.save(on: database) - } -} - -extension CreatePortfolioRequestDTO: Content { } diff --git a/Tests/GrodtTests/GrodtTestCase.swift b/Tests/GrodtTests/GrodtTestCase.swift deleted file mode 100644 index 85973e4..0000000 --- a/Tests/GrodtTests/GrodtTestCase.swift +++ /dev/null @@ -1,84 +0,0 @@ -@testable import Grodt -import Foundation -import XCTVapor -import FluentKit - -class GrodtTestCase: XCTestCase { - fileprivate var app: Application! - - var database: any Database { - return app.db - } - - override func setUpWithError() throws { - try super.setUpWithError() - - let expectation = XCTestExpectation(description: "Setup complete") - - Task { - app = Application(.testing) - try await configure(app) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - } - - override func tearDown() { - app.shutdown() - } -} - -class GrodtControllerTestCase: GrodtTestCase { - var basePath: String { return "" } - var user: User! - - override func setUpWithError() throws { - try super.setUpWithError() - - let email = "test@test.com" - let password = "password" - let user = User(name: "test", email: email, passwordHash: try! Bcrypt.hash(password)) - - let expectation = XCTestExpectation(description: "Setup complete") - - Task { - try await user.save(on: app.db) - self.user = user - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - } - - func sendRequest(_ method: HTTPMethod, _ path: String = "") async throws -> XCTHTTPResponse { - return try await app.sendRequest(method, "\(basePath)/\(path)", headers: HTTPHeaders([authHeader()])) - } - - func sendPostRequest(_ path: String = "", body: Encodable) async throws -> XCTHTTPResponse { - let requestData = try JSONEncoder().encode(body) - let requestBody = ByteBuffer(data: requestData) - let headers = try await HTTPHeaders([("Content-Type", "application/json"), authHeader()]) - return try await app.sendRequest(.POST, "\(basePath)/\(path)", headers: headers, body: requestBody) - } - - private func authHeader() async throws -> (String, String) { - let login = try await app.sendRequest(.POST, "api/login", headers: HTTPHeaders([AuthorizationHeader.basic(email: user.email, password: "password").value])) - return AuthorizationHeader.bearer(token: login.headers.bearerAuthorization!.token).value - } -} - -fileprivate enum AuthorizationHeader { - case basic(email: String, password: String) - case bearer(token: String) - - var value: (String, String) { - switch self { - case .basic(let email, let password): - let basicAuthToken = "\(email):\(password)" - return ("Authorization", "Basic \(basicAuthToken.data(using: .utf8)!.base64EncodedString())") - case .bearer(let token): - return ("Authorization", "Bearer \(token)") - } - } -}