Nextcloud login flow v2, Network code rewrite

This commit is contained in:
Vicnet
2023-09-20 13:25:25 +02:00
parent 0f16b164d6
commit 26dd5c34ff
16 changed files with 504 additions and 364 deletions

View File

@@ -0,0 +1,78 @@
//
// APIInterface.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 20.09.23.
//
import Foundation
class APIInterface {
var userSettings: UserSettings
var apiPath: String
var authString: String
let apiVersion = "1"
init(userSettings: UserSettings) {
print("Initializing NetworkController.")
self.userSettings = userSettings
self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/"
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
self.authString = loginData.base64EncodedString()
}
func imageDataFromServer(recipeId: Int, thumb: Bool) async -> Data? {
do {
let request = RequestWrapper.imageRequest(path: .IMAGE(recipeId: recipeId, thumb: thumb))
let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: apiPath,
authString: authString
)
guard let data = data else {
print("Error receiving or decoding data.")
return nil
}
return data
} catch {
print("Could not load image from server.")
}
return nil
}
}
extension APIInterface {
func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
do {
let (data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: apiPath,
authString: authString
)
if let data = data {
return (JSONDecoder.safeDecode(data), error)
}
return (nil, error)
} catch {
print("An unknown network error occured.")
}
return (nil, NetworkError.unknownError)
}
func sendRequest(_ request: RequestWrapper) async -> Error? {
do {
return try await NetworkHandler.sendHTTPRequest(
request,
hostPath: apiPath,
authString: authString
).1
} catch {
print("An unknown network error occured.")
}
return NetworkError.unknownError
}
}

View File

@@ -13,3 +13,15 @@ public enum NotImplementedError: Error, CustomStringConvertible {
return "Function not implemented."
}
}
public enum NetworkError: String, Error {
case missingUrl = "Missing URL."
case parametersNil = "Parameters are nil."
case encodingFailed = "Parameter encoding failed."
case redirectionError = "Redirection error"
case clientError = "Client error"
case serverError = "Server error"
case invalidRequest = "Invalid request"
case unknownError = "Unknown error"
case dataError = "Invalid data error."
}

View File

@@ -1,34 +0,0 @@
//
// Extensions.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 14.09.23.
//
import Foundation
extension Formatter {
static let positional: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
return formatter
}()
}
func formatDate(duration: String) -> String {
var duration = duration
if duration.hasPrefix("PT") { duration.removeFirst(2) }
let hour, minute, second: Double
if let index = duration.firstIndex(of: "H") {
hour = Double(duration[..<index]) ?? 0
duration.removeSubrange(...index)
} else { hour = 0 }
if let index = duration.firstIndex(of: "M") {
minute = Double(duration[..<index]) ?? 0
duration.removeSubrange(...index)
} else { minute = 0 }
if let index = duration.firstIndex(of: "S") {
second = Double(duration[..<index]) ?? 0
} else { second = 0 }
return Formatter.positional.string(from: hour * 3600 + minute * 60 + second) ?? "0:00"
}

View File

@@ -1,248 +0,0 @@
//
// NetworkController.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
public enum NetworkError: String, Error {
case missingUrl = "Missing URL."
case parametersNil = "Parameters are nil."
case encodingFailed = "Parameter encoding failed."
case redirectionError = "Redirection error"
case clientError = "Client error"
case serverError = "Server error"
case invalidRequest = "Invalid request"
case unknownError = "Unknown error"
case dataError = "Invalid data error."
}
class NetworkController {
var userSettings: UserSettings
var authString: String
var cookBookUrlString: String
let apiVersion = "1"
init() {
print("Initializing NetworkController.")
self.userSettings = UserSettings()
self.cookBookUrlString = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/"
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
self.authString = loginData.base64EncodedString()
}
func fetchData(path: String) async throws -> Data? {
let url = URL(string: "\(cookBookUrlString)/\(path)")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(
"true",
forHTTPHeaderField: "OCS-APIRequest"
)
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
do {
let (data, _) = try await URLSession.shared.data(for: request)
return data
} catch {
return nil
}
}
func sendHTTPRequest(_ requestWrapper: RequestWrapper) async throws -> (Data?, NetworkError?) {
print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.prepend(cookBookPath: cookBookUrlString))) ...")
let urlStringSanitized = requestWrapper.prepend(cookBookPath: cookBookUrlString).addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = URL(string: urlStringSanitized!)!
var request = URLRequest(url: url)
request.setValue(
"true",
forHTTPHeaderField: "OCS-APIRequest"
)
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
request.setValue(
requestWrapper.accept.rawValue,
forHTTPHeaderField: "Accept"
)
request.httpMethod = requestWrapper.method.rawValue
switch requestWrapper.method {
case .GET: break
case .POST, .PUT:
guard let httpBody = requestWrapper.body else { return (nil, nil) }
do {
print("Encoding request ...")
request.httpBody = try JSONEncoder().encode(httpBody)
print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")")
} catch {
throw error
}
case .DELETE: throw NotImplementedError.notImplemented
}
var data: Data? = nil
var response: URLResponse? = nil
do {
(data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response)
return (data, nil)
} catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
}
}
private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
guard let response = response else {
return NetworkError.unknownError
}
switch response.statusCode {
case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError)
case 500...599: return (NetworkError.serverError)
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError)
}
}
func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
do {
let (data, error) = try await sendHTTPRequest(request)
if let data = data {
return (decodeData(data), error)
}
return (nil, error)
} catch {
print("An unknown network error occured.")
}
return (nil, NetworkError.unknownError)
}
func sendRequest(_ request: RequestWrapper) async -> Error? {
do {
return try await sendHTTPRequest(request).1
} catch {
print("An unknown network error occured.")
}
return NetworkError.unknownError
}
private func decodeData<D: Decodable>(_ data: Data) -> D? {
let decoder = JSONDecoder()
do {
print("Decoding type ", D.self, " ...")
return try decoder.decode(D.self, from: data)
} catch (let error) {
print("DataController - decodeData(): Failed to decode data.")
print("Error: ", error)
return nil
}
}
}
struct NetworkHandler {
static func sendHTTPRequest(_ requestWrapper: RequestWrapper, authString: String? = nil) async throws -> (Data?, NetworkError?) {
print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.path)) ...")
let urlStringSanitized = requestWrapper.path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = URL(string: urlStringSanitized!)!
var request = URLRequest(url: url)
request.setValue(
"true",
forHTTPHeaderField: "OCS-APIRequest"
)
if let authString = authString {
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
}
request.setValue(
requestWrapper.accept.rawValue,
forHTTPHeaderField: "Accept"
)
request.httpMethod = requestWrapper.method.rawValue
switch requestWrapper.method {
case .GET: break
case .POST, .PUT:
guard let httpBody = requestWrapper.body else { break }
do {
print("Encoding request ...")
request.httpBody = try JSONEncoder().encode(httpBody)
print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")")
} catch {
throw error
}
case .DELETE: throw NotImplementedError.notImplemented
}
var data: Data? = nil
var response: URLResponse? = nil
do {
(data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response)
return (data, nil)
} catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
}
}
static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
guard let response = response else {
return NetworkError.unknownError
}
switch response.statusCode {
case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError)
case 500...599: return (NetworkError.serverError)
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError)
}
}
static func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
do {
let (data, error) = try await sendHTTPRequest(request)
if let data = data {
print(String(data: data, encoding: .utf8))
return (decodeData(data), error)
}
return (nil, error)
} catch {
print("An unknown network error occured.")
}
return (nil, NetworkError.unknownError)
}
private static func decodeData<D: Decodable>(_ data: Data) -> D? {
let decoder = JSONDecoder()
do {
print("Decoding type ", D.self, " ...")
return try decoder.decode(D.self, from: data)
} catch (let error) {
print("DataController - decodeData(): Failed to decode data.")
print("Error: ", error)
return nil
}
}
}

View File

@@ -0,0 +1,79 @@
//
// NetworkHandler.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
struct NetworkHandler {
static func sendHTTPRequest(
_ requestWrapper: RequestWrapper,
hostPath: String,
authString: String?
) async throws -> (Data?, NetworkError?) {
print("Sending \(requestWrapper.getMethod()) request (path: \(requestWrapper.getPath())) ...")
// Prepare URL
let urlString = hostPath + requestWrapper.getPath()
let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = URL(string: urlStringSanitized!)!
// Create URL request
var request = URLRequest(url: url)
// Set URL method
request.httpMethod = requestWrapper.getMethod()
// Set authentication string, if needed
if let authString = authString {
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
}
// Set other header fields
for headerField in requestWrapper.getHeaderFields() {
request.setValue(
headerField.getValue(),
forHTTPHeaderField: headerField.getField()
)
}
// Set http body
if let body = requestWrapper.getBody() {
request.httpBody = body
}
print("Request:\nMethod: \(request.httpMethod)\nHeaders: \(request.allHTTPHeaderFields)\nBody: \(request.httpBody)")
// Wait for and return data and (decoded) response
var data: Data? = nil
var response: URLResponse? = nil
do {
(data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response)
return (data, nil)
} catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
}
}
private static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
guard let response = response else {
return NetworkError.unknownError
}
switch response.statusCode {
case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError)
case 500...599: return (NetworkError.serverError)
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError)
}
}
}

View File

@@ -8,47 +8,153 @@
import Foundation
enum RequestMethod: String {
case GET = "GET", POST = "POST", PUT = "PUT", DELETE = "DELETE"
case GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE"
}
enum RequestPath: String {
case GET_CATEGORIES = "categories"
enum RequestPath {
case CATEGORIES,
RECIPE_LIST(categoryName: String),
RECIPE_DETAIL(recipeId: Int),
IMAGE(recipeId: Int, thumb: Bool)
case LOGINV2REQ,
CUSTOM(path: String),
NONE
var stringValue: String {
switch self {
case .CATEGORIES: return "categories"
case .RECIPE_LIST(categoryName: let name): return "category/\(name)"
case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)"
case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")"
case .LOGINV2REQ: return "/index.php/login/v2"
case .CUSTOM(path: let path): return path
case .NONE: return ""
}
}
}
enum AcceptHeader: String {
case JSON = "application/json", IMAGE = "image/jpeg"
enum ContentType: String {
case JSON = "application/json",
IMAGE = "image/jpeg",
FORM = "application/x-www-form-urlencoded"
}
struct HeaderField {
private let _field: String
private let _value: String
func getField() -> String {
return _field
}
func getValue() -> String {
return _value
}
static func accept(value: ContentType) -> HeaderField {
return HeaderField(_field: "accept", _value: value.rawValue)
}
static func ocsRequest(value: Bool) -> HeaderField {
return HeaderField(_field: "OCS-APIRequest", _value: value ? "true" : "false")
}
static func contentType(value: ContentType) -> HeaderField {
return HeaderField(_field: "Content-Type", _value: value.rawValue)
}
}
struct RequestWrapper {
let method: RequestMethod
var path: String
let accept: AcceptHeader
let body: Codable?
private let _method: RequestMethod
private let _path: RequestPath
private let _headerFields: [HeaderField]
private let _body: Data?
private let _authenticate: Bool = true
init(method: RequestMethod, path: String, body: Codable? = nil, accept: AcceptHeader = .JSON) {
self.method = method
self.path = path
self.body = body
self.accept = accept
private init(
method: RequestMethod,
path: RequestPath,
headerFields: [HeaderField] = [],
body: Data? = nil,
authenticate: Bool = true
) {
self._method = method
self._path = path
self._headerFields = headerFields
self._body = body
}
func prepend(cookBookPath: String) -> String {
return cookBookPath + self.path
func getMethod() -> String {
return self._method.rawValue
}
func getPath() -> String {
return self._path.stringValue
}
func getHeaderFields() -> [HeaderField] {
return self._headerFields
}
func getBody() -> Data? {
return _body
}
func needsAuth() -> Bool {
return _authenticate
}
}
struct LoginV2Request: Codable {
let poll: LoginV2Poll
let login: String
extension RequestWrapper {
static func customRequest(
method: RequestMethod,
path: RequestPath,
headerFields: [HeaderField] = [],
body: Data? = nil,
authenticate: Bool = true
) -> RequestWrapper {
let request = RequestWrapper(
method: method,
path: path,
headerFields: headerFields,
body: body,
authenticate: authenticate
)
return request
}
static func jsonGetRequest(path: RequestPath) -> RequestWrapper {
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON)
]
let request = RequestWrapper(
method: .GET,
path: path,
headerFields: headerFields,
authenticate: true
)
return request
}
static func imageRequest(path: RequestPath) -> RequestWrapper {
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .IMAGE)
]
let request = RequestWrapper(
method: .GET,
path: path,
headerFields: headerFields,
authenticate: true
)
return request
}
}
struct LoginV2Poll: Codable {
let token: String
let endpoint: String
}
struct LoginV2Response: Codable {
let server: String
let loginName: String
let appPassword: String
}