374 lines
12 KiB
Swift
374 lines
12 KiB
Swift
//
|
|
// BudgetApiService.swift
|
|
// Budget
|
|
//
|
|
// Created by Billy Brawner on 9/25/19.
|
|
// Copyright © 2019 William Brawner. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import Combine
|
|
|
|
class BudgetAppApiService {
|
|
let requestHelper: RequestHelper
|
|
|
|
init(_ requestHelper: RequestHelper) {
|
|
self.requestHelper = requestHelper
|
|
}
|
|
|
|
// MARK: Budgets
|
|
|
|
func getBudgets(count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Budget], NetworkError> {
|
|
var queries = [String: Array<String>]()
|
|
if count != nil {
|
|
queries["count"] = [String(count!)]
|
|
}
|
|
if (page != nil) {
|
|
queries["page"] = [String(page!)]
|
|
}
|
|
return requestHelper.get("/api/budgets", queries: queries)
|
|
}
|
|
|
|
func getBudget(_ id: String) -> AnyPublisher<Budget, NetworkError> {
|
|
return requestHelper.get("/api/budgets/\(id)")
|
|
}
|
|
|
|
func getBudgetBalance(_ id: String) -> AnyPublisher<Int, NetworkError> {
|
|
return requestHelper.get("/api/budgets/\(id)/balance")
|
|
}
|
|
|
|
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
|
|
return requestHelper.post("/api/budgets/new", data: budget, type: Budget.self)
|
|
}
|
|
|
|
func updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
|
|
return requestHelper.put("/api/budgets/\(budget.id)", data: budget)
|
|
}
|
|
|
|
func deleteBudget(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
|
return requestHelper.delete("/api/budgets/\(id)")
|
|
}
|
|
|
|
// MARK: Transactions
|
|
|
|
func getTransactions(
|
|
budgetIds: [String]? = nil,
|
|
categoryIds: [String]? = nil,
|
|
from: Date? = nil,
|
|
to: Date? = nil,
|
|
count: Int? = nil,
|
|
page: Int? = nil
|
|
) -> AnyPublisher<[Transaction], NetworkError> {
|
|
var queries = [String: Array<String>]()
|
|
if budgetIds != nil {
|
|
queries["budgetId"] = budgetIds!
|
|
}
|
|
if categoryIds != nil {
|
|
queries["categoryId"] = 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 requestHelper.get("/api/transactions", queries: queries)
|
|
}
|
|
|
|
func getTransaction(_ id: String) -> AnyPublisher<Transaction, NetworkError> {
|
|
return requestHelper.get("/api/transactions/\(id)")
|
|
}
|
|
|
|
func newTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> {
|
|
return requestHelper.post("/api/transactions/new", data: transaction, type: Transaction.self)
|
|
}
|
|
|
|
func updateTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> {
|
|
return requestHelper.put("/api/transactions/\(transaction.id)", data: transaction)
|
|
}
|
|
|
|
func deleteTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
|
return requestHelper.delete("/api/transactions/\(id)")
|
|
}
|
|
|
|
// MARK: Categories
|
|
|
|
func getCategories(budgetId: String? = nil, count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Category], NetworkError> {
|
|
var queries = [String: Array<String>]()
|
|
if budgetId != nil {
|
|
queries["budgetId"] = [String(budgetId!)]
|
|
}
|
|
if count != nil {
|
|
queries["count"] = [String(count!)]
|
|
}
|
|
if (page != nil) {
|
|
queries["page"] = [String(page!)]
|
|
}
|
|
return requestHelper.get("/api/categories", queries: queries)
|
|
}
|
|
|
|
func getCategory(_ id: String) -> AnyPublisher<Category, NetworkError> {
|
|
return requestHelper.get("/api/categories/\(id)")
|
|
}
|
|
|
|
func getCategoryBalance(_ id: String) -> AnyPublisher<Int, NetworkError> {
|
|
return requestHelper.get("/api/categories/\(id)/balance")
|
|
}
|
|
|
|
func newCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
|
|
return requestHelper.post("/api/categories/new", data: category, type: Category.self)
|
|
}
|
|
|
|
func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
|
|
return requestHelper.put("/api/categories/\(category.id)", data: category)
|
|
}
|
|
|
|
func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
|
return requestHelper.delete("/api/categories/\(id)")
|
|
}
|
|
|
|
// MARK: Users
|
|
func login(username: String, password: String) -> AnyPublisher<LoginResponse, NetworkError> {
|
|
return requestHelper.post(
|
|
"/api/users/login",
|
|
data: LoginRequest(username: username, password: password),
|
|
type: LoginResponse.self
|
|
).map { (session) -> LoginResponse in
|
|
return session
|
|
}.eraseToAnyPublisher()
|
|
}
|
|
|
|
func register(username: String, email: String, password: String) -> AnyPublisher<User, NetworkError> {
|
|
return requestHelper.post(
|
|
"/api/users/new",
|
|
data: RegistrationRequest(username: username, email: email, password: password),
|
|
type: User.self
|
|
).map { (user) -> User in
|
|
// Persist the credentials on sucessful registration
|
|
return user
|
|
}.eraseToAnyPublisher()
|
|
}
|
|
|
|
func getUser(id: String) -> AnyPublisher<User, NetworkError> {
|
|
return requestHelper.get("/api/users/\(id)")
|
|
}
|
|
|
|
func searchUsers(query: String) -> AnyPublisher<[User], NetworkError> {
|
|
return requestHelper.get(
|
|
"/api/users/search",
|
|
queries: ["query": [query]]
|
|
)
|
|
}
|
|
|
|
func getUsers(count: Int? = nil, page: Int? = nil) -> AnyPublisher<[User], NetworkError> {
|
|
var queries = [String: Array<String>]()
|
|
if count != nil {
|
|
queries["count"] = [String(count!)]
|
|
}
|
|
if (page != nil) {
|
|
queries["page"] = [String(page!)]
|
|
}
|
|
return requestHelper.get("/api/Users", queries: queries)
|
|
}
|
|
|
|
func newUser(_ user: User) -> AnyPublisher<User, NetworkError> {
|
|
return requestHelper.post("/api/users/new", data: user, type: User.self)
|
|
}
|
|
|
|
func updateUser(_ user: User) -> AnyPublisher<User, NetworkError> {
|
|
return requestHelper.put("/api/users/\(user.id)", data: user)
|
|
}
|
|
|
|
func deleteUser(_ user: User) -> AnyPublisher<Empty, NetworkError> {
|
|
return requestHelper.delete("/api/users/\(user.id)")
|
|
}
|
|
}
|
|
|
|
class RequestHelper {
|
|
let decoder = JSONDecoder()
|
|
let baseUrl: String
|
|
var token: String?
|
|
|
|
init(_ baseUrl: String) {
|
|
self.baseUrl = baseUrl
|
|
self.decoder.dateDecodingStrategy = .iso8601
|
|
}
|
|
|
|
func get<ResultType: Codable>(
|
|
_ endPoint: String,
|
|
queries: [String: Array<String>]? = nil
|
|
) -> AnyPublisher<ResultType, NetworkError> {
|
|
var combinedEndPoint = endPoint
|
|
if (queries != nil) {
|
|
for (key, values) in queries! {
|
|
for value in values {
|
|
let separator = combinedEndPoint.contains("?") ? "&" : "?"
|
|
combinedEndPoint += separator + key + "=" + value
|
|
}
|
|
}
|
|
}
|
|
|
|
return buildRequest(endPoint: combinedEndPoint, method: "GET")
|
|
}
|
|
|
|
func post<ResultType: Codable>(
|
|
_ endPoint: String,
|
|
data: Codable,
|
|
type: ResultType.Type
|
|
) -> AnyPublisher<ResultType, NetworkError> {
|
|
return buildRequest(
|
|
endPoint: endPoint,
|
|
method: "POST",
|
|
data: data
|
|
)
|
|
}
|
|
|
|
func put<ResultType: Codable>(
|
|
_ endPoint: String,
|
|
data: ResultType
|
|
) -> AnyPublisher<ResultType, NetworkError> {
|
|
return buildRequest(
|
|
endPoint: endPoint,
|
|
method: "PUT",
|
|
data: data
|
|
)
|
|
}
|
|
|
|
func delete(_ endPoint: String) -> AnyPublisher<Empty, NetworkError> {
|
|
// Delete requests return no body so they need a special request helper
|
|
guard let url = URL(string: self.baseUrl + endPoint) else {
|
|
return Result.Publisher(.failure(.invalidUrl)).eraseToAnyPublisher()
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpMethod = "DELETE"
|
|
|
|
let task = URLSession.shared.dataTaskPublisher(for: request)
|
|
.tryMap { (_, res) -> Empty in
|
|
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 Empty()
|
|
}
|
|
.mapError {
|
|
return NetworkError.jsonParsingFailed($0)
|
|
}
|
|
return task.eraseToAnyPublisher()
|
|
}
|
|
|
|
private func buildRequest<ResultType: Codable>(
|
|
endPoint: String,
|
|
method: String,
|
|
data: Encodable? = nil
|
|
) -> AnyPublisher<ResultType, NetworkError> {
|
|
|
|
guard let url = URL(string: self.baseUrl + endPoint) else {
|
|
return Result.Publisher(.failure(.invalidUrl)).eraseToAnyPublisher()
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
return URLSession.shared.dataTaskPublisher(for: request)
|
|
.tryMap { (data, res) -> Data in
|
|
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 data
|
|
}
|
|
.decode(type: ResultType.self, decoder: self.decoder)
|
|
.mapError {
|
|
return NetworkError.jsonParsingFailed($0)
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
}
|
|
|
|
struct Empty: Codable {}
|
|
|
|
enum NetworkError: Error, Equatable {
|
|
static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.loading, .loading):
|
|
return true
|
|
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
|
|
}
|
|
}
|
|
|
|
case loading
|
|
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 {
|
|
var iso8601DateFormatter: DateFormatter {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
|
dateFormatter.timeZone = TimeZone(identifier: "UTC")
|
|
return dateFormatter
|
|
}
|
|
|
|
var localeDateFormatter: DateFormatter {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.locale = Locale.current
|
|
dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yyyyMMdd", options: 0, locale: Locale.current)
|
|
return dateFormatter
|
|
}
|
|
|
|
func toISO8601String() -> String {
|
|
return iso8601DateFormatter.string(from: self)
|
|
}
|
|
|
|
func toLocaleString() -> String {
|
|
return localeDateFormatter.string(from: self)
|
|
}
|
|
}
|