Copy code from Twigs iOS app
This commit is contained in:
parent
c93370df8c
commit
04cc2dee1c
10 changed files with 868 additions and 9 deletions
|
@ -5,6 +5,10 @@ import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "twigs",
|
name: "twigs",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v12),
|
||||||
|
.iOS(.v15)
|
||||||
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
.library(
|
.library(
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
# twigs
|
# Twigs
|
||||||
|
|
||||||
A description of this package.
|
Twigs is a personal finance/budgeting app aimed at individuals and families. This repository is the core code shared between the various platforms supported by Twigs.
|
||||||
|
|
25
Sources/twigs/Budget.swift
Normal file
25
Sources/twigs/Budget.swift
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Budget: Identifiable, Hashable, Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let currencyCode: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BudgetOverview {
|
||||||
|
let budget: Budget
|
||||||
|
let balance: Int
|
||||||
|
var expectedIncome: Int = 0
|
||||||
|
var actualIncome: Int = 0
|
||||||
|
var expectedExpenses: Int = 0
|
||||||
|
var actualExpenses: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol BudgetRepository {
|
||||||
|
func getBudgets(count: Int?, page: Int?) async throws -> [Budget]
|
||||||
|
func getBudget(_ id: String) async throws -> Budget
|
||||||
|
func newBudget(_ budget: Budget) async throws -> Budget
|
||||||
|
func updateBudget(_ budget: Budget) async throws -> Budget
|
||||||
|
func deleteBudget(_ id: String) async throws
|
||||||
|
}
|
19
Sources/twigs/Category.swift
Normal file
19
Sources/twigs/Category.swift
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Category: Identifiable, Hashable, Codable {
|
||||||
|
let budgetId: String
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let amount: Int
|
||||||
|
let expense: Bool
|
||||||
|
let archived: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol CategoryRepository {
|
||||||
|
func getCategories(budgetId: String?, expense: Bool?, archived: Bool?, count: Int?, page: Int?) async throws -> [Category]
|
||||||
|
func getCategory(_ categoryId: String) async throws -> Category
|
||||||
|
func createCategory(_ category: Category) async throws -> Category
|
||||||
|
func updateCategory(_ category: Category) async throws -> Category
|
||||||
|
func deleteCategory(_ id: String) async throws
|
||||||
|
}
|
282
Sources/twigs/RecurringTransaction.swift
Normal file
282
Sources/twigs/RecurringTransaction.swift
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by William Brawner on 12/22/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct RecurringTransaction: Identifiable, Hashable, Codable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let frequency: Frequency
|
||||||
|
let start: Date
|
||||||
|
let end: Date?
|
||||||
|
let amount: Int
|
||||||
|
let categoryId: String?
|
||||||
|
let expense: Bool
|
||||||
|
let createdBy: String
|
||||||
|
let budgetId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Frequency: Hashable, Codable, CustomStringConvertible {
|
||||||
|
let unit: FrequencyUnit
|
||||||
|
let count: Int
|
||||||
|
let time: Time
|
||||||
|
|
||||||
|
init?(unit: FrequencyUnit, count: Int, time: Time) {
|
||||||
|
if count < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.unit = unit
|
||||||
|
self.count = count
|
||||||
|
self.time = time
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(from string: String) {
|
||||||
|
let parts = string.split(separator: ";")
|
||||||
|
guard let count = Int(parts[1]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var timeIndex = 3
|
||||||
|
switch parts[0] {
|
||||||
|
case "D":
|
||||||
|
self.unit = .daily
|
||||||
|
timeIndex = 2
|
||||||
|
case "W":
|
||||||
|
let daysOfWeek = parts[2].split(separator: ",").compactMap { dayOfWeek in
|
||||||
|
DayOfWeek(rawValue: String(dayOfWeek))
|
||||||
|
}
|
||||||
|
if daysOfWeek.isEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.unit = .weekly(Set(daysOfWeek))
|
||||||
|
case "M":
|
||||||
|
guard let dayOfMonth = DayOfMonth(from: String(parts[2])) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.unit = .monthly(dayOfMonth)
|
||||||
|
case "Y":
|
||||||
|
guard let dayOfYear = DayOfYear(from: String(parts[2])) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.unit = .yearly(dayOfYear)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let time = Time(from: String(parts[timeIndex])) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.time = time
|
||||||
|
self.count = count
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let frequencyString = try container.decode(String.self)
|
||||||
|
self.init(from: frequencyString)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(description)
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
// TODO: Make the backend representation of this more sensible and then use this
|
||||||
|
// return [unit.description, count.description, time.description].joined(separator: ";")
|
||||||
|
let unitParts = "\(unit)".split(separator: ";")
|
||||||
|
if unitParts.count == 1 {
|
||||||
|
return [unitParts[0].description, count.description, time.description].joined(separator: ";")
|
||||||
|
} else{
|
||||||
|
return [unitParts[0].description, count.description, unitParts[1].description, time.description].joined(separator: ";")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var naturalDescription: String {
|
||||||
|
return unit.format(count: count, time: time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FrequencyUnit: Hashable, CustomStringConvertible {
|
||||||
|
case daily
|
||||||
|
case weekly(Set<DayOfWeek>)
|
||||||
|
case monthly(DayOfMonth)
|
||||||
|
case yearly(DayOfYear)
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .daily:
|
||||||
|
return "D"
|
||||||
|
case .weekly(let daysOfWeek):
|
||||||
|
return String(format: "W;%s", daysOfWeek.map { $0.rawValue }.joined(separator: ","))
|
||||||
|
case .monthly(let dayOfMonth):
|
||||||
|
return String(format: "M;%s", dayOfMonth.description)
|
||||||
|
case .yearly(let dayOfYear):
|
||||||
|
return String(format: "Y;%s", dayOfYear.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func format(count: Int, time: Time) -> String {
|
||||||
|
switch self {
|
||||||
|
case .daily:
|
||||||
|
return String(localized: "Every \(count) day(s) at \(time.description)")
|
||||||
|
case .weekly(let daysOfWeek):
|
||||||
|
return String(localized: "Every \(count) week(s) on \(daysOfWeek.description) at \(time.description)")
|
||||||
|
case .monthly(let dayOfMonth):
|
||||||
|
return String(localized: "Every \(count) month(s) on the \(dayOfMonth.description) at \(time.description)")
|
||||||
|
case .yearly(let dayOfYear):
|
||||||
|
return String(localized: "Every \(count) year(s) on \(dayOfYear.description) at \(time.description)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Time: Hashable, CustomStringConvertible {
|
||||||
|
let hours: Int
|
||||||
|
let minutes: Int
|
||||||
|
let seconds: Int
|
||||||
|
|
||||||
|
init?(hours: Int, minutes: Int, seconds: Int) {
|
||||||
|
if hours < 0 || hours > 23 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if minutes < 0 || minutes > 59 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if seconds < 0 || seconds > 59 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.hours = hours
|
||||||
|
self.minutes = minutes
|
||||||
|
self.seconds = seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(from string: String) {
|
||||||
|
let parts = string.split(separator: ":").compactMap {
|
||||||
|
Int($0)
|
||||||
|
}
|
||||||
|
if parts.count != 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(hours: parts[0], minutes: parts[1], seconds: parts[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DayOfMonth: Hashable, CustomStringConvertible {
|
||||||
|
case positional(Position, DayOfWeek)
|
||||||
|
case fixed(Int)
|
||||||
|
init?(position: Position, dayOfWeek: DayOfWeek) {
|
||||||
|
if position == .day {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self = .positional(position, dayOfWeek)
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(day: Int) {
|
||||||
|
if day < 1 || day > 31 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self = .fixed(day)
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(from string: String) {
|
||||||
|
let parts = string.split(separator: "-")
|
||||||
|
guard let position = Position.init(rawValue: String(parts[0])) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if position == .day {
|
||||||
|
guard let day = Int(parts[1]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self = .fixed(day)
|
||||||
|
} else {
|
||||||
|
guard let dayOfWeek = DayOfWeek(rawValue: String(parts[1])) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self = .positional(position, dayOfWeek)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .positional(let position, let dayOfWeek):
|
||||||
|
return "\(position)-\(dayOfWeek)"
|
||||||
|
case .fixed(let day):
|
||||||
|
return "\(Position.day)-\(day)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Position: String, Hashable {
|
||||||
|
case day = "DAY"
|
||||||
|
case first = "FIRST"
|
||||||
|
case second = "SECOND"
|
||||||
|
case third = "THIRD"
|
||||||
|
case fourth = "FOURTH"
|
||||||
|
case last = "LAST"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DayOfWeek: String, Hashable {
|
||||||
|
case monday = "MONDAY"
|
||||||
|
case tuesday = "TUESDAY"
|
||||||
|
case wednesday = "WEDNESDAY"
|
||||||
|
case thursday = "THURSDAY"
|
||||||
|
case friday = "FRIDAY"
|
||||||
|
case saturday = "SATURDAY"
|
||||||
|
case sunday = "SUNDAY"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DayOfYear: Hashable, CustomStringConvertible {
|
||||||
|
let month: Int
|
||||||
|
let day: Int
|
||||||
|
|
||||||
|
init?(month: Int, day: Int) {
|
||||||
|
var maxDay: Int
|
||||||
|
switch month {
|
||||||
|
case 2:
|
||||||
|
maxDay = 29;
|
||||||
|
break;
|
||||||
|
case 4, 6, 9, 11:
|
||||||
|
maxDay = 30;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
maxDay = 31;
|
||||||
|
}
|
||||||
|
if day < 1 || day > maxDay {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if month < 1 || month > 12 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.day = day
|
||||||
|
self.month = month
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(from string: String) {
|
||||||
|
let parts = string.split(separator: "-").compactMap {
|
||||||
|
Int($0)
|
||||||
|
}
|
||||||
|
if parts.count < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(month: parts[0], day: parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
return String(format: "%02d-%02d", self.month, self.day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol RecurringTransactionsRepository {
|
||||||
|
func getRecurringTransactions(budgetId: String) async throws -> [RecurringTransaction]
|
||||||
|
func getRecurringTransaction(_ id: String) async throws -> RecurringTransaction
|
||||||
|
func createRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction
|
||||||
|
func updateRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction
|
||||||
|
func deleteRecurringTransaction(_ id: String) async throws
|
||||||
|
}
|
54
Sources/twigs/Transaction.swift
Normal file
54
Sources/twigs/Transaction.swift
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by William Brawner on 12/22/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Transaction: Identifiable, Hashable, Codable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let date: Date
|
||||||
|
let amount: Int
|
||||||
|
let categoryId: String?
|
||||||
|
let expense: Bool
|
||||||
|
let createdBy: String
|
||||||
|
let budgetId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BalanceResponse: Codable {
|
||||||
|
let balance: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransactionType: Int, CaseIterable, Identifiable, Hashable {
|
||||||
|
case expense
|
||||||
|
case income
|
||||||
|
|
||||||
|
var id: TransactionType { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Transaction {
|
||||||
|
var type: TransactionType {
|
||||||
|
if (self.expense) {
|
||||||
|
return .expense
|
||||||
|
} else {
|
||||||
|
return .income
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var amountString: String {
|
||||||
|
return String(Double(self.amount) / 100.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol TransactionRepository {
|
||||||
|
func getTransactions(budgetIds: [String], categoryIds: [String]?, from: Date?, to: Date?, count: Int?, page: Int?) async throws -> [Transaction]
|
||||||
|
func getTransaction(_ transactionId: String) async throws -> Transaction
|
||||||
|
func createTransaction(_ transaction: Transaction) async throws -> Transaction
|
||||||
|
func updateTransaction(_ transaction: Transaction) async throws -> Transaction
|
||||||
|
func deleteTransaction(_ transactionId: String) async throws
|
||||||
|
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) async throws -> BalanceResponse
|
||||||
|
}
|
442
Sources/twigs/TwigsApiService.swift
Normal file
442
Sources/twigs/TwigsApiService.swift
Normal file
|
@ -0,0 +1,442 @@
|
||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by William Brawner on 12/22/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTransactionsRepository, TransactionRepository, UserRepository {
|
||||||
|
let requestHelper: RequestHelper
|
||||||
|
|
||||||
|
convenience init(_ serverUrl: String) {
|
||||||
|
self.init(RequestHelper(serverUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ requestHelper: RequestHelper) {
|
||||||
|
self.requestHelper = requestHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
var token: String? {
|
||||||
|
get {
|
||||||
|
return requestHelper.token
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
requestHelper.token = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Budgets
|
||||||
|
func getBudgets(count: Int? = nil, page: Int? = nil) async throws -> [Budget] {
|
||||||
|
var queries = [String: Array<String>]()
|
||||||
|
if count != nil {
|
||||||
|
queries["count"] = [String(count!)]
|
||||||
|
}
|
||||||
|
if (page != nil) {
|
||||||
|
queries["page"] = [String(page!)]
|
||||||
|
}
|
||||||
|
return try await requestHelper.get("/api/budgets", queries: queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBudget(_ id: String) async throws -> Budget {
|
||||||
|
return try await requestHelper.get("/api/budgets/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBudget(_ budget: Budget) async throws -> Budget {
|
||||||
|
return try await requestHelper.post("/api/budgets", data: budget, type: Budget.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBudget(_ budget: Budget) async throws -> Budget {
|
||||||
|
return try await requestHelper.put("/api/budgets/\(budget.id)", data: budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteBudget(_ id: String) async throws {
|
||||||
|
return try await requestHelper.delete("/api/budgets/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Transactions
|
||||||
|
|
||||||
|
func getTransactions(
|
||||||
|
budgetIds: [String],
|
||||||
|
categoryIds: [String]? = nil,
|
||||||
|
from: Date? = nil,
|
||||||
|
to: Date? = nil,
|
||||||
|
count: Int? = nil,
|
||||||
|
page: Int? = nil
|
||||||
|
) async throws -> [Transaction] {
|
||||||
|
var queries = [String: Array<String>]()
|
||||||
|
queries["budgetIds"] = budgetIds
|
||||||
|
if categoryIds != nil {
|
||||||
|
queries["categoryIds"] = categoryIds!
|
||||||
|
}
|
||||||
|
if from != nil {
|
||||||
|
queries["from"] = [from!.toISO8601String()]
|
||||||
|
}
|
||||||
|
if to != nil {
|
||||||
|
queries["to"] = [to!.toISO8601String()]
|
||||||
|
}
|
||||||
|
if count != nil {
|
||||||
|
queries["count"] = [String(count!)]
|
||||||
|
}
|
||||||
|
if (page != nil) {
|
||||||
|
queries["page"] = [String(page!)]
|
||||||
|
}
|
||||||
|
return try await requestHelper.get("/api/transactions", queries: queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTransaction(_ id: String) async throws -> Transaction {
|
||||||
|
return try await requestHelper.get("/api/transactions/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTransaction(_ transaction: Transaction) async throws -> Transaction {
|
||||||
|
return try await requestHelper.post("/api/transactions", data: transaction, type: Transaction.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTransaction(_ transaction: Transaction) async throws -> Transaction {
|
||||||
|
return try await requestHelper.put("/api/transactions/\(transaction.id)", data: transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTransaction(_ id: String) async throws {
|
||||||
|
return try await requestHelper.delete("/api/transactions/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sumTransactions(budgetId: String? = nil, categoryId: String? = nil, from: Date? = nil, to: Date? = nil) async throws -> BalanceResponse {
|
||||||
|
var queries = [String: Array<String>]()
|
||||||
|
if let budgetId = budgetId {
|
||||||
|
queries["budgetId"] = [budgetId]
|
||||||
|
}
|
||||||
|
if let categoryId = categoryId {
|
||||||
|
queries["categoryId"] = [categoryId]
|
||||||
|
}
|
||||||
|
if let from = from {
|
||||||
|
queries["from"] = [from.toISO8601String()]
|
||||||
|
}
|
||||||
|
if let to = to {
|
||||||
|
queries["to"] = [to.toISO8601String()]
|
||||||
|
}
|
||||||
|
return try await requestHelper.get("/api/transactions/sum", queries: queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Categories
|
||||||
|
|
||||||
|
func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = nil, count: Int? = nil, page: Int? = nil) async throws -> [Category] {
|
||||||
|
var queries = [String: Array<String>]()
|
||||||
|
if budgetId != nil {
|
||||||
|
queries["budgetIds"] = [String(budgetId!)]
|
||||||
|
}
|
||||||
|
if expense != nil {
|
||||||
|
queries["expense"] = [String(expense!)]
|
||||||
|
}
|
||||||
|
if archived != nil {
|
||||||
|
queries["archived"] = [String(archived!)]
|
||||||
|
}
|
||||||
|
if count != nil {
|
||||||
|
queries["count"] = [String(count!)]
|
||||||
|
}
|
||||||
|
if (page != nil) {
|
||||||
|
queries["page"] = [String(page!)]
|
||||||
|
}
|
||||||
|
return try await requestHelper.get("/api/categories", queries: queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCategory(_ id: String) async throws -> Category {
|
||||||
|
return try await requestHelper.get("/api/categories/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCategoryBalance(_ id: String) async throws -> Int {
|
||||||
|
return try await requestHelper.get("/api/categories/\(id)/balance")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCategory(_ category: Category) async throws -> Category {
|
||||||
|
return try await requestHelper.post("/api/categories", data: category, type: Category.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCategory(_ category: Category) async throws -> Category {
|
||||||
|
return try await requestHelper.put("/api/categories/\(category.id)", data: category)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCategory(_ id: String) async throws {
|
||||||
|
return try await requestHelper.delete("/api/categories/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Users
|
||||||
|
func login(username: String, password: String) async throws -> LoginResponse {
|
||||||
|
let response = try await requestHelper.post(
|
||||||
|
"/api/users/login",
|
||||||
|
data: LoginRequest(username: username, password: password),
|
||||||
|
type: LoginResponse.self
|
||||||
|
)
|
||||||
|
self.requestHelper.token = response.token
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(username: String, email: String, password: String) async throws -> User {
|
||||||
|
return try await requestHelper.post(
|
||||||
|
"/api/users/register",
|
||||||
|
data: RegistrationRequest(username: username, email: email, password: password),
|
||||||
|
type: User.self
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUser(_ id: String) async throws -> User {
|
||||||
|
return try await requestHelper.get("/api/users/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchUsers(_ query: String) async throws -> [User] {
|
||||||
|
return try await requestHelper.get(
|
||||||
|
"/api/users/search",
|
||||||
|
queries: ["query": [query]]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUsers(count: Int? = nil, page: Int? = nil) async throws -> [User] {
|
||||||
|
var queries = [String: Array<String>]()
|
||||||
|
if count != nil {
|
||||||
|
queries["count"] = [String(count!)]
|
||||||
|
}
|
||||||
|
if (page != nil) {
|
||||||
|
queries["page"] = [String(page!)]
|
||||||
|
}
|
||||||
|
return try await requestHelper.get("/api/Users", queries: queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUser(_ user: User) async throws -> User {
|
||||||
|
return try await requestHelper.post("/api/users", data: user, type: User.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUser(_ user: User) async throws -> User {
|
||||||
|
return try await requestHelper.put("/api/users/\(user.id)", data: user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUser(_ user: User) async throws {
|
||||||
|
return try await requestHelper.delete("/api/users/\(user.id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Recurring Transactions
|
||||||
|
func getRecurringTransactions(budgetId: String) async throws -> [RecurringTransaction] {
|
||||||
|
return try await requestHelper.get("/api/recurringtransactions", queries: ["budgetId": [budgetId]])
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecurringTransaction(_ id: String) async throws -> RecurringTransaction {
|
||||||
|
return try await requestHelper.get("/api/recurringtransactions/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction {
|
||||||
|
return try await requestHelper.post("/api/recurringtransactions", data: transaction, type: RecurringTransaction.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction {
|
||||||
|
return try await requestHelper.put("/api/recurringtransactions/\(transaction.id)", data: transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRecurringTransaction(_ id: String) async throws {
|
||||||
|
return try await requestHelper.delete("/api/recurringtransactions/\(id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RequestHelper {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
private var _baseUrl: String = ""
|
||||||
|
var baseUrl: String {
|
||||||
|
get {
|
||||||
|
self.baseUrl
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
var correctServer = newValue.lowercased()
|
||||||
|
if !correctServer.starts(with: "http://") && !correctServer.starts(with: "https://") {
|
||||||
|
correctServer = "http://\(correctServer)"
|
||||||
|
}
|
||||||
|
self._baseUrl = correctServer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var token: String?
|
||||||
|
|
||||||
|
init(_ serverUrl: String) {
|
||||||
|
self.baseUrl = serverUrl
|
||||||
|
self.decoder.dateDecodingStrategy = .formatted(Date.iso8601DateFormatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func get<ResultType: Codable>(
|
||||||
|
_ endPoint: String,
|
||||||
|
queries: [String: Array<String>]? = nil
|
||||||
|
) async throws -> ResultType {
|
||||||
|
var combinedEndPoint = endPoint
|
||||||
|
if (queries != nil) {
|
||||||
|
for (key, values) in queries! {
|
||||||
|
for value in values {
|
||||||
|
let separator = combinedEndPoint.contains("?") ? "&" : "?"
|
||||||
|
combinedEndPoint += separator + key + "=" + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await buildRequest(endPoint: combinedEndPoint, method: "GET")
|
||||||
|
}
|
||||||
|
|
||||||
|
func post<ResultType: Codable>(
|
||||||
|
_ endPoint: String,
|
||||||
|
data: Codable,
|
||||||
|
type: ResultType.Type
|
||||||
|
) async throws -> ResultType {
|
||||||
|
return try await buildRequest(
|
||||||
|
endPoint: endPoint,
|
||||||
|
method: "POST",
|
||||||
|
data: data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func put<ResultType: Codable>(
|
||||||
|
_ endPoint: String,
|
||||||
|
data: ResultType
|
||||||
|
) async throws -> ResultType {
|
||||||
|
return try await buildRequest(
|
||||||
|
endPoint: endPoint,
|
||||||
|
method: "PUT",
|
||||||
|
data: data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ endPoint: String) async throws {
|
||||||
|
// Delete requests return no body so they need a special request helper
|
||||||
|
guard let url = URL(string: self.baseUrl + endPoint) else {
|
||||||
|
throw NetworkError.invalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpMethod = "DELETE"
|
||||||
|
|
||||||
|
let (_, res) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else {
|
||||||
|
switch (res as? HTTPURLResponse)?.statusCode {
|
||||||
|
case 400: throw NetworkError.badRequest
|
||||||
|
case 401, 403: throw NetworkError.unauthorized
|
||||||
|
case 404: throw NetworkError.notFound
|
||||||
|
default: throw NetworkError.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildRequest<ResultType: Codable>(
|
||||||
|
endPoint: String,
|
||||||
|
method: String,
|
||||||
|
data: Encodable? = nil
|
||||||
|
) async throws -> ResultType {
|
||||||
|
guard let url = URL(string: self.baseUrl + endPoint) else {
|
||||||
|
print("Unable to build url from base: \(self.baseUrl)")
|
||||||
|
throw NetworkError.invalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\(method) - \(url)")
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpBody = data?.toJSONData()
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpMethod = method
|
||||||
|
if let token = self.token {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, res) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else {
|
||||||
|
switch (res as? HTTPURLResponse)?.statusCode {
|
||||||
|
case 400: throw NetworkError.badRequest
|
||||||
|
case 401, 403: throw NetworkError.unauthorized
|
||||||
|
case 404: throw NetworkError.notFound
|
||||||
|
default: throw NetworkError.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try self.decoder.decode(ResultType.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NetworkError: Error, Equatable {
|
||||||
|
static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.unknown, .unknown):
|
||||||
|
return true
|
||||||
|
case (.notFound, .notFound):
|
||||||
|
return true
|
||||||
|
case (.unauthorized, .unauthorized):
|
||||||
|
return true
|
||||||
|
case (.badRequest, .badRequest):
|
||||||
|
return true
|
||||||
|
case (.invalidUrl, .invalidUrl):
|
||||||
|
return true
|
||||||
|
case (let .jsonParsingFailed(error1), let .jsonParsingFailed(error2)):
|
||||||
|
return error1.localizedDescription == error2.localizedDescription
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
get {
|
||||||
|
switch self {
|
||||||
|
case .unknown:
|
||||||
|
return "unknown"
|
||||||
|
case .notFound:
|
||||||
|
return "notFound"
|
||||||
|
case .deleted:
|
||||||
|
return "deleted"
|
||||||
|
case .unauthorized:
|
||||||
|
return "unauthorized"
|
||||||
|
case .badRequest:
|
||||||
|
return "badRequest"
|
||||||
|
case .invalidUrl:
|
||||||
|
return "invalidUrl"
|
||||||
|
case .jsonParsingFailed(_):
|
||||||
|
return "jsonParsingFailed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case unknown
|
||||||
|
case notFound
|
||||||
|
case deleted
|
||||||
|
case unauthorized
|
||||||
|
case badRequest
|
||||||
|
case invalidUrl
|
||||||
|
case jsonParsingFailed(Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Encodable {
|
||||||
|
func toJSONData() -> Data? {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
return try? encoder.encode(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
static let iso8601DateFormatter: DateFormatter = {
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||||||
|
dateFormatter.timeZone = TimeZone(identifier: "UTC")
|
||||||
|
return dateFormatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let localeDateFormatter: DateFormatter = {
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.locale = Locale.current
|
||||||
|
dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yyyyMMdd", options: 0, locale: Locale.current)
|
||||||
|
return dateFormatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static var firstOfMonth: Date {
|
||||||
|
get {
|
||||||
|
return Calendar.current.dateComponents([.calendar, .year,.month], from: Date()).date!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toISO8601String() -> String {
|
||||||
|
return Date.iso8601DateFormatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLocaleString() -> String {
|
||||||
|
return Date.localeDateFormatter.string(from: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
39
Sources/twigs/User.swift
Normal file
39
Sources/twigs/User.swift
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by William Brawner on 12/22/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct User: Codable, Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let email: String?
|
||||||
|
let avatar: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginRequest: Codable {
|
||||||
|
let username: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginResponse: Codable {
|
||||||
|
let token: String
|
||||||
|
let expiration: String
|
||||||
|
let userId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RegistrationRequest: Codable {
|
||||||
|
let username: String
|
||||||
|
let email: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol UserRepository {
|
||||||
|
func getUser(_ id: String) async throws -> User
|
||||||
|
func searchUsers(_ withUsername: String) async throws -> [User]
|
||||||
|
func login(username: String, password: String) async throws -> LoginResponse
|
||||||
|
func register(username: String, email: String, password: String) async throws -> User
|
||||||
|
}
|
|
@ -1,6 +0,0 @@
|
||||||
public struct twigs {
|
|
||||||
public private(set) var text = "Hello, World!"
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,6 +6,6 @@ final class twigsTests: XCTestCase {
|
||||||
// This is an example of a functional test case.
|
// This is an example of a functional test case.
|
||||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
// Use XCTAssert and related functions to verify your tests produce the correct
|
||||||
// results.
|
// results.
|
||||||
XCTAssertEqual(twigs().text, "Hello, World!")
|
// XCTAssertEqual(twigs().text, "Hello, World!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue