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 description: 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 balance: Int
public var expectedIncome: Int = 0
public var actualIncome: Int = 0
public var expectedExpenses: Int = 0
public var actualExpenses: Int = 0
public var expectedIncome: Int
public var actualIncome: Int
public var expectedExpenses: Int
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 {

View file

@ -8,6 +8,38 @@ public struct Category: Identifiable, Hashable, Codable {
public let amount: Int
public let expense: 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 {

View file

@ -13,12 +13,52 @@ public struct RecurringTransaction: Identifiable, Hashable, Codable {
public let description: String?
public let frequency: Frequency
public let start: Date
public let end: Date?
public let finish: Date?
public let amount: Int
public let categoryId: String?
public let expense: Bool
public let createdBy: 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 {
@ -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 weekly(Set<DayOfWeek>)
case monthly(DayOfMonth)
@ -111,11 +162,11 @@ public enum FrequencyUnit: Hashable, CustomStringConvertible {
case .daily:
return "D"
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):
return String(format: "M;%s", dayOfMonth.description)
return "M;\(dayOfMonth.description)"
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)")
}
}
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 {
@ -169,13 +233,13 @@ public struct Time: Hashable, CustomStringConvertible {
}
public enum DayOfMonth: Hashable, CustomStringConvertible {
case positional(Position, DayOfWeek)
case ordinal(Ordinal, DayOfWeek)
case fixed(Int)
public init?(position: Position, dayOfWeek: DayOfWeek) {
if position == .day {
public init?(ordinal: Ordinal, dayOfWeek: DayOfWeek) {
if ordinal == .day {
return nil
}
self = .positional(position, dayOfWeek)
self = .ordinal(ordinal, dayOfWeek)
}
public init?(day: Int) {
@ -187,7 +251,7 @@ public enum DayOfMonth: Hashable, CustomStringConvertible {
public init?(from string: String) {
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
}
if position == .day {
@ -199,21 +263,21 @@ public enum DayOfMonth: Hashable, CustomStringConvertible {
guard let dayOfWeek = DayOfWeek(rawValue: String(parts[1])) else {
return nil
}
self = .positional(position, dayOfWeek)
self = .ordinal(position, dayOfWeek)
}
}
public var description: String {
switch self {
case .positional(let position, let dayOfWeek):
return "\(position)-\(dayOfWeek)"
case .ordinal(let position, let dayOfWeek):
return "\(position.rawValue)-\(dayOfWeek)"
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 first = "FIRST"
case second = "SECOND"
@ -222,14 +286,18 @@ public enum Position: String, Hashable {
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 tuesday = "TUESDAY"
case wednesday = "WEDNESDAY"
case thursday = "THURSDAY"
case friday = "FRIDAY"
case saturday = "SATURDAY"
case sunday = "SUNDAY"
}
public struct DayOfYear: Hashable, CustomStringConvertible {
@ -237,17 +305,7 @@ public struct DayOfYear: Hashable, CustomStringConvertible {
public let day: Int
public 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;
}
let maxDay = DayOfYear.maxDays(inMonth: month)
if day < 1 || day > maxDay {
return nil
}
@ -271,10 +329,21 @@ public struct DayOfYear: Hashable, CustomStringConvertible {
public var description: String {
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 {
func getRecurringTransactions(budgetId: String) async throws -> [RecurringTransaction]
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

View file

@ -17,10 +17,26 @@ public struct Transaction: Identifiable, Hashable, Codable {
public let expense: Bool
public let createdBy: 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 let balance: Int
public init(balance: Int) {
self.balance = balance
}
}
public enum TransactionType: Int, CaseIterable, Identifiable, Hashable {
@ -40,7 +56,7 @@ extension Transaction {
}
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())
}
init(_ requestHelper: RequestHelper) {
public init(_ requestHelper: RequestHelper) {
self.requestHelper = requestHelper
}
@ -221,7 +221,7 @@ open class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTrans
}
// 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]])
}
@ -242,26 +242,26 @@ open class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTrans
}
}
class RequestHelper {
public class RequestHelper {
let decoder = JSONDecoder()
private var _baseUrl: String? = nil
var baseUrl: String? {
get {
self.baseUrl
self._baseUrl
}
set {
guard var correctServer = newValue?.lowercased() else {
return
}
if !correctServer.starts(with: "http://") && !correctServer.starts(with: "https://") {
correctServer = "http://\(correctServer)"
correctServer = "https://\(correctServer)"
}
self._baseUrl = correctServer
}
}
var token: String?
init() {
public init() {
self.decoder.dateDecodingStrategy = .formatted(Date.iso8601DateFormatter)
}
@ -317,11 +317,14 @@ class RequestHelper {
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "DELETE"
if let token = self.token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
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 400: throw NetworkError.badRequest(nil)
case 401, 403: throw NetworkError.unauthorized
case 404: throw NetworkError.notFound
default: throw NetworkError.unknown
@ -351,16 +354,46 @@ class RequestHelper {
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
var data: Data? = nil
var res: URLResponse?
do {
(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 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
case (.unauthorized, .unauthorized):
return true
case (.badRequest, .badRequest):
return true
case (let .badRequest(reason1), let .badRequest(reason2)):
return reason1 == reason2
case (.invalidUrl, .invalidUrl):
return true
case (.server, .server):
return true
case (let .jsonParsingFailed(error1), let .jsonParsingFailed(error2)):
return error1.localizedDescription == error2.localizedDescription
default:
@ -395,10 +430,12 @@ public enum NetworkError: Error, Equatable {
return "deleted"
case .unauthorized:
return "unauthorized"
case .badRequest:
case .badRequest(_):
return "badRequest"
case .invalidUrl:
return "invalidUrl"
case .server:
return "server"
case .jsonParsingFailed(_):
return "jsonParsingFailed"
}
@ -409,8 +446,9 @@ public enum NetworkError: Error, Equatable {
case notFound
case deleted
case unauthorized
case badRequest
case badRequest(String?)
case invalidUrl
case server
case jsonParsingFailed(Error)
}

View file

@ -10,25 +10,66 @@ import Foundation
public struct User: Codable, Equatable, Hashable {
public let id: String
public let username: String
public let password: String?
public let email: 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 let username: String
public let password: String
public init(username: String, password: String) {
self.username = username
self.password = password
}
}
public struct LoginResponse: Codable {
public let token: String
public let expiration: 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 let username: String
public let email: 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 {