Various changes/fixes to API

I don't recall everything that's changed and I've been bad about making
smaller commits but at a high level, custom initializers were added,
network failures are handled better, amountStrings are empty when amount
is 0, and some other minor things were changed.
This commit is contained in:
William Brawner 2022-06-26 11:00:42 -05:00
parent 544f9de221
commit 58eb9699a4
6 changed files with 264 additions and 52 deletions

View file

@ -5,15 +5,31 @@ public struct Budget: Identifiable, Hashable, Codable {
public let name: String public let name: String
public let description: String? public let description: String?
public let currencyCode: String? public let currencyCode: String?
public init(id: String, name: String, description: String?, currencyCode: String?) {
self.id = id
self.name = name
self.description = description
self.currencyCode = currencyCode
}
} }
public struct BudgetOverview { public struct BudgetOverview: Equatable {
public let budget: Budget public let budget: Budget
public let balance: Int public let balance: Int
public var expectedIncome: Int = 0 public var expectedIncome: Int
public var actualIncome: Int = 0 public var actualIncome: Int
public var expectedExpenses: Int = 0 public var expectedExpenses: Int
public var actualExpenses: Int = 0 public var actualExpenses: Int
public init(budget: Budget, balance: Int, expectedIncome: Int = 0, actualIncome: Int = 0, expectedExpenses: Int = 0, actualExpenses: Int = 0) {
self.budget = budget
self.balance = balance
self.expectedIncome = expectedIncome
self.actualIncome = actualIncome
self.expectedExpenses = expectedExpenses
self.actualExpenses = actualExpenses
}
} }
public protocol BudgetRepository { public protocol BudgetRepository {

View file

@ -8,6 +8,38 @@ public struct Category: Identifiable, Hashable, Codable {
public let amount: Int public let amount: Int
public let expense: Bool public let expense: Bool
public let archived: Bool public let archived: Bool
public init(
budgetId: String,
id: String = "",
title: String = "",
description: String? = "",
amount: Int = 0,
expense: Bool = true,
archived: Bool = false
) {
self.budgetId = budgetId
self.id = id
self.title = title
self.description = description
self.amount = amount
self.expense = expense
self.archived = archived
}
}
extension Category {
public var type: TransactionType {
if (self.expense) {
return .expense
} else {
return .income
}
}
public var amountString: String {
return self.amount > 0 ? String(format: "%.02d", Double(self.amount) / 100.0) : ""
}
} }
public protocol CategoryRepository { public protocol CategoryRepository {

View file

@ -13,12 +13,52 @@ public struct RecurringTransaction: Identifiable, Hashable, Codable {
public let description: String? public let description: String?
public let frequency: Frequency public let frequency: Frequency
public let start: Date public let start: Date
public let end: Date? public let finish: Date?
public let amount: Int public let amount: Int
public let categoryId: String? public let categoryId: String?
public let expense: Bool public let expense: Bool
public let createdBy: String public let createdBy: String
public let budgetId: String public let budgetId: String
public init(
id: String = "",
title: String = "",
description: String? = nil,
frequency: Frequency = Frequency(unit: FrequencyUnit.daily, count: 1, time: Time(hours: 9, minutes: 0, seconds: 0)!)!,
start: Date = Date(),
finish: Date? = nil,
amount: Int = 0,
categoryId: String? = nil,
expense: Bool = true,
createdBy: String,
budgetId: String
) {
self.id = id
self.title = title
self.description = description
self.frequency = frequency
self.start = start
self.finish = finish
self.amount = amount
self.categoryId = categoryId
self.expense = expense
self.createdBy = createdBy
self.budgetId = budgetId
}
}
extension RecurringTransaction {
public var type: TransactionType {
if (self.expense) {
return .expense
} else {
return .income
}
}
public var amountString: String {
return self.amount > 0 ? String(format: "%.02f", Double(self.amount) / 100.0) : ""
}
} }
public struct Frequency: Hashable, Codable, CustomStringConvertible { public struct Frequency: Hashable, Codable, CustomStringConvertible {
@ -100,7 +140,18 @@ public struct Frequency: Hashable, Codable, CustomStringConvertible {
} }
} }
public enum FrequencyUnit: Hashable, CustomStringConvertible { public enum FrequencyUnit: Identifiable, Hashable, CustomStringConvertible, CaseIterable {
public var id: String {
return self.baseName
}
public static var allCases: [FrequencyUnit] = [
.daily,
.weekly(Set()),
.monthly(.fixed(1)),
.yearly(DayOfYear(month: 1, day: 1)!),
]
case daily case daily
case weekly(Set<DayOfWeek>) case weekly(Set<DayOfWeek>)
case monthly(DayOfMonth) case monthly(DayOfMonth)
@ -111,11 +162,11 @@ public enum FrequencyUnit: Hashable, CustomStringConvertible {
case .daily: case .daily:
return "D" return "D"
case .weekly(let daysOfWeek): case .weekly(let daysOfWeek):
return String(format: "W;%s", daysOfWeek.map { $0.rawValue }.joined(separator: ",")) return "W;\(daysOfWeek.map { $0.rawValue }.joined(separator: ","))"
case .monthly(let dayOfMonth): case .monthly(let dayOfMonth):
return String(format: "M;%s", dayOfMonth.description) return "M;\(dayOfMonth.description)"
case .yearly(let dayOfYear): case .yearly(let dayOfYear):
return String(format: "Y;%s", dayOfYear.description) return "Y;\(dayOfYear.description)"
} }
} }
@ -131,6 +182,19 @@ public enum FrequencyUnit: Hashable, CustomStringConvertible {
return String(localized: "Every \(count) year(s) on \(dayOfYear.description) at \(time.description)") return String(localized: "Every \(count) year(s) on \(dayOfYear.description) at \(time.description)")
} }
} }
public var baseName: String {
switch self {
case .daily:
return "day"
case .weekly(_):
return "week"
case .monthly(_):
return "month"
case .yearly(_):
return "year"
}
}
} }
public struct Time: Hashable, CustomStringConvertible { public struct Time: Hashable, CustomStringConvertible {
@ -169,13 +233,13 @@ public struct Time: Hashable, CustomStringConvertible {
} }
public enum DayOfMonth: Hashable, CustomStringConvertible { public enum DayOfMonth: Hashable, CustomStringConvertible {
case positional(Position, DayOfWeek) case ordinal(Ordinal, DayOfWeek)
case fixed(Int) case fixed(Int)
public init?(position: Position, dayOfWeek: DayOfWeek) { public init?(ordinal: Ordinal, dayOfWeek: DayOfWeek) {
if position == .day { if ordinal == .day {
return nil return nil
} }
self = .positional(position, dayOfWeek) self = .ordinal(ordinal, dayOfWeek)
} }
public init?(day: Int) { public init?(day: Int) {
@ -187,7 +251,7 @@ public enum DayOfMonth: Hashable, CustomStringConvertible {
public init?(from string: String) { public init?(from string: String) {
let parts = string.split(separator: "-") let parts = string.split(separator: "-")
guard let position = Position.init(rawValue: String(parts[0])) else { guard let position = Ordinal.init(rawValue: String(parts[0])) else {
return nil return nil
} }
if position == .day { if position == .day {
@ -199,21 +263,21 @@ public enum DayOfMonth: Hashable, CustomStringConvertible {
guard let dayOfWeek = DayOfWeek(rawValue: String(parts[1])) else { guard let dayOfWeek = DayOfWeek(rawValue: String(parts[1])) else {
return nil return nil
} }
self = .positional(position, dayOfWeek) self = .ordinal(position, dayOfWeek)
} }
} }
public var description: String { public var description: String {
switch self { switch self {
case .positional(let position, let dayOfWeek): case .ordinal(let position, let dayOfWeek):
return "\(position)-\(dayOfWeek)" return "\(position.rawValue)-\(dayOfWeek)"
case .fixed(let day): case .fixed(let day):
return "\(Position.day)-\(day)" return "\(Ordinal.day.rawValue)-\(day)"
} }
} }
} }
public enum Position: String, Hashable { public enum Ordinal: String, Hashable, CaseIterable {
case day = "DAY" case day = "DAY"
case first = "FIRST" case first = "FIRST"
case second = "SECOND" case second = "SECOND"
@ -222,14 +286,18 @@ public enum Position: String, Hashable {
case last = "LAST" case last = "LAST"
} }
public enum DayOfWeek: String, Hashable { public enum DayOfWeek: String, Hashable, CaseIterable, Identifiable {
public var id: String {
return self.rawValue
}
case sunday = "SUNDAY"
case monday = "MONDAY" case monday = "MONDAY"
case tuesday = "TUESDAY" case tuesday = "TUESDAY"
case wednesday = "WEDNESDAY" case wednesday = "WEDNESDAY"
case thursday = "THURSDAY" case thursday = "THURSDAY"
case friday = "FRIDAY" case friday = "FRIDAY"
case saturday = "SATURDAY" case saturday = "SATURDAY"
case sunday = "SUNDAY"
} }
public struct DayOfYear: Hashable, CustomStringConvertible { public struct DayOfYear: Hashable, CustomStringConvertible {
@ -237,17 +305,7 @@ public struct DayOfYear: Hashable, CustomStringConvertible {
public let day: Int public let day: Int
public init?(month: Int, day: Int) { public init?(month: Int, day: Int) {
var maxDay: Int let maxDay = DayOfYear.maxDays(inMonth: month)
switch month {
case 2:
maxDay = 29;
break;
case 4, 6, 9, 11:
maxDay = 30;
break;
default:
maxDay = 31;
}
if day < 1 || day > maxDay { if day < 1 || day > maxDay {
return nil return nil
} }
@ -271,10 +329,21 @@ public struct DayOfYear: Hashable, CustomStringConvertible {
public var description: String { public var description: String {
return String(format: "%02d-%02d", self.month, self.day) return String(format: "%02d-%02d", self.month, self.day)
} }
public static func maxDays(inMonth month: Int) -> Int {
switch month {
case 2:
return 29;
case 4, 6, 9, 11:
return 30;
default:
return 31;
}
}
} }
public protocol RecurringTransactionsRepository { public protocol RecurringTransactionsRepository {
func getRecurringTransactions(budgetId: String) async throws -> [RecurringTransaction] func getRecurringTransactions(_ budgetId: String) async throws -> [RecurringTransaction]
func getRecurringTransaction(_ id: String) async throws -> RecurringTransaction func getRecurringTransaction(_ id: String) async throws -> RecurringTransaction
func createRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction func createRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction
func updateRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction func updateRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction

View file

@ -17,10 +17,26 @@ public struct Transaction: Identifiable, Hashable, Codable {
public let expense: Bool public let expense: Bool
public let createdBy: String public let createdBy: String
public let budgetId: String public let budgetId: String
public init(id: String = "", title: String = "", description: String? = "", date: Date = Date(), amount: Int = 0, categoryId: String? = "", expense: Bool = true, createdBy: String, budgetId: String) {
self.id = id
self.title = title
self.description = description
self.date = date
self.amount = amount
self.categoryId = categoryId
self.expense = expense
self.createdBy = createdBy
self.budgetId = budgetId
}
} }
public struct BalanceResponse: Codable { public struct BalanceResponse: Codable {
public let balance: Int public let balance: Int
public init(balance: Int) {
self.balance = balance
}
} }
public enum TransactionType: Int, CaseIterable, Identifiable, Hashable { public enum TransactionType: Int, CaseIterable, Identifiable, Hashable {
@ -40,7 +56,7 @@ extension Transaction {
} }
public var amountString: String { public var amountString: String {
return String(Double(self.amount) / 100.0) return self.amount > 0 ? String(format: "%.02d", Double(self.amount) / 100.0) : ""
} }
} }

View file

@ -14,7 +14,7 @@ open class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTrans
self.init(RequestHelper()) self.init(RequestHelper())
} }
init(_ requestHelper: RequestHelper) { public init(_ requestHelper: RequestHelper) {
self.requestHelper = requestHelper self.requestHelper = requestHelper
} }
@ -221,7 +221,7 @@ open class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTrans
} }
// MARK: Recurring Transactions // MARK: Recurring Transactions
open func getRecurringTransactions(budgetId: String) async throws -> [RecurringTransaction] { open func getRecurringTransactions(_ budgetId: String) async throws -> [RecurringTransaction] {
return try await requestHelper.get("/api/recurringtransactions", queries: ["budgetId": [budgetId]]) return try await requestHelper.get("/api/recurringtransactions", queries: ["budgetId": [budgetId]])
} }
@ -242,26 +242,26 @@ open class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTrans
} }
} }
class RequestHelper { public class RequestHelper {
let decoder = JSONDecoder() let decoder = JSONDecoder()
private var _baseUrl: String? = nil private var _baseUrl: String? = nil
var baseUrl: String? { var baseUrl: String? {
get { get {
self.baseUrl self._baseUrl
} }
set { set {
guard var correctServer = newValue?.lowercased() else { guard var correctServer = newValue?.lowercased() else {
return return
} }
if !correctServer.starts(with: "http://") && !correctServer.starts(with: "https://") { if !correctServer.starts(with: "http://") && !correctServer.starts(with: "https://") {
correctServer = "http://\(correctServer)" correctServer = "https://\(correctServer)"
} }
self._baseUrl = correctServer self._baseUrl = correctServer
} }
} }
var token: String? var token: String?
init() { public init() {
self.decoder.dateDecodingStrategy = .formatted(Date.iso8601DateFormatter) self.decoder.dateDecodingStrategy = .formatted(Date.iso8601DateFormatter)
} }
@ -317,11 +317,14 @@ class RequestHelper {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "DELETE" request.httpMethod = "DELETE"
if let token = self.token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (_, res) = try await URLSession.shared.data(for: request) let (_, res) = try await URLSession.shared.data(for: request)
guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else {
switch (res as? HTTPURLResponse)?.statusCode { switch (res as? HTTPURLResponse)?.statusCode {
case 400: throw NetworkError.badRequest case 400: throw NetworkError.badRequest(nil)
case 401, 403: throw NetworkError.unauthorized case 401, 403: throw NetworkError.unauthorized
case 404: throw NetworkError.notFound case 404: throw NetworkError.notFound
default: throw NetworkError.unknown default: throw NetworkError.unknown
@ -351,16 +354,46 @@ class RequestHelper {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} }
let (data, res) = try await URLSession.shared.data(for: request) var data: Data? = nil
guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { var res: URLResponse?
switch (res as? HTTPURLResponse)?.statusCode { do {
case 400: throw NetworkError.badRequest (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 ?? -1 {
case 400:
var reason: String? = nil
if let data = data {
reason = String(decoding: data, as: UTF8.self)
}
throw NetworkError.badRequest(reason)
case 401, 403: throw NetworkError.unauthorized
case 404: throw NetworkError.notFound
case 500...599: throw NetworkError.server
default: throw NetworkError.unknown
}
}
} catch {
switch (res as? HTTPURLResponse)?.statusCode ?? -1 {
case 401, 403: throw NetworkError.unauthorized case 401, 403: throw NetworkError.unauthorized
case 404: throw NetworkError.notFound case 404: throw NetworkError.notFound
default: throw NetworkError.unknown case 500...599: throw NetworkError.server
default:
var reason: String = error.localizedDescription
if let data = data {
reason = String(decoding: data, as: UTF8.self)
}
throw NetworkError.badRequest(reason)
} }
} }
return try self.decoder.decode(ResultType.self, from: data) do {
guard let data = data else {
throw NetworkError.unknown
}
return try self.decoder.decode(ResultType.self, from: data)
} catch {
print("error decoding json: \(error)")
throw NetworkError.jsonParsingFailed(error)
}
} }
} }
@ -373,10 +406,12 @@ public enum NetworkError: Error, Equatable {
return true return true
case (.unauthorized, .unauthorized): case (.unauthorized, .unauthorized):
return true return true
case (.badRequest, .badRequest): case (let .badRequest(reason1), let .badRequest(reason2)):
return true return reason1 == reason2
case (.invalidUrl, .invalidUrl): case (.invalidUrl, .invalidUrl):
return true return true
case (.server, .server):
return true
case (let .jsonParsingFailed(error1), let .jsonParsingFailed(error2)): case (let .jsonParsingFailed(error1), let .jsonParsingFailed(error2)):
return error1.localizedDescription == error2.localizedDescription return error1.localizedDescription == error2.localizedDescription
default: default:
@ -395,10 +430,12 @@ public enum NetworkError: Error, Equatable {
return "deleted" return "deleted"
case .unauthorized: case .unauthorized:
return "unauthorized" return "unauthorized"
case .badRequest: case .badRequest(_):
return "badRequest" return "badRequest"
case .invalidUrl: case .invalidUrl:
return "invalidUrl" return "invalidUrl"
case .server:
return "server"
case .jsonParsingFailed(_): case .jsonParsingFailed(_):
return "jsonParsingFailed" return "jsonParsingFailed"
} }
@ -409,8 +446,9 @@ public enum NetworkError: Error, Equatable {
case notFound case notFound
case deleted case deleted
case unauthorized case unauthorized
case badRequest case badRequest(String?)
case invalidUrl case invalidUrl
case server
case jsonParsingFailed(Error) case jsonParsingFailed(Error)
} }

View file

@ -10,25 +10,66 @@ import Foundation
public struct User: Codable, Equatable, Hashable { public struct User: Codable, Equatable, Hashable {
public let id: String public let id: String
public let username: String public let username: String
public let password: String?
public let email: String? public let email: String?
public let avatar: String? public let avatar: String?
public init(id: String, username: String, email: String?, password: String?, avatar: String?) {
self.id = id
self.username = username
self.email = email
self.password = password
self.avatar = avatar
}
public func copy(
username: String? = nil,
email: String? = nil,
password: String? = nil,
avatar: String? = nil
) -> User {
return User(
id: self.id,
username: username ?? self.username,
email: email ?? self.email,
password: password ?? self.password,
avatar: avatar ?? self.avatar
)
}
} }
public struct LoginRequest: Codable { public struct LoginRequest: Codable {
public let username: String public let username: String
public let password: String public let password: String
public init(username: String, password: String) {
self.username = username
self.password = password
}
} }
public struct LoginResponse: Codable { public struct LoginResponse: Codable {
public let token: String public let token: String
public let expiration: String public let expiration: String
public let userId: String public let userId: String
public init(token: String, expiration: String, userId: String) {
self.token = token
self.expiration = expiration
self.userId = userId
}
} }
public struct RegistrationRequest: Codable { public struct RegistrationRequest: Codable {
public let username: String public let username: String
public let email: String public let email: String
public let password: String public let password: String
public init(username: String, email: String, password: String) {
self.username = username
self.email = email
self.password = password
}
} }
public protocol UserRepository { public protocol UserRepository {