Skip to content

Commit

Permalink
Merge pull request #61 from baupen/dev
Browse files Browse the repository at this point in the history
v2.5.1
  • Loading branch information
juliand665 authored Jun 24, 2024
2 parents b127e8c + ac3c825 commit 0b8f568
Show file tree
Hide file tree
Showing 92 changed files with 3,888 additions and 2,727 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ on: [push, pull_request]

jobs:
build:
runs-on: macos-11
runs-on: macos-14
timeout-minutes: 30
steps:
- name: Check out
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: true
- name: Run build script
Expand Down
165 changes: 73 additions & 92 deletions Issue Manager/Back End/Client.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
// Created by Julian Dunskus

import Foundation
import Promise
import UserDefault
import Protoquest
import HandyOperators

typealias TaskResult = (data: Data, response: HTTPURLResponse)

@MainActor
final class Client {
static let shared = Client()
static let dateFormatter = ISO8601DateFormatter()

private static let baseServerURL = URL(string: "https://app.baupen.ch")!
nonisolated static let dateFormatter = ISO8601DateFormatter()

@UserDefault("client.loginInfo") var loginInfo: LoginInfo?

Expand All @@ -20,111 +18,103 @@ final class Client {

var isLoggedIn: Bool { loginInfo != nil && localUser != nil }

private let urlSession = URLSession.shared

private let requestEncoder = JSONEncoder() <- {
$0.dateEncodingStrategy = .iso8601
}
private let responseDecoder = JSONDecoder() <- {
$0.dateDecodingStrategy = .iso8601
}

/// any dependent requests are executed on this queue, so as to avoid bad interleavings and races and such
private let linearQueue = DispatchQueue(label: "dependent request execution")
private let _makeContext: @Sendable (Client, LoginInfo?) -> any RequestContext

func assertOnLinearQueue() {
dispatchPrecondition(condition: .onQueue(linearQueue))
nonisolated init(
makeContext: @escaping @Sendable (Client, LoginInfo?) -> any RequestContext = {
DefaultRequestContext(client: $0, loginInfo: $1)
} // can't use pointfree reference to DefaultRequestContext.init because it's not sendable
) {
self._makeContext = makeContext
}

private init() {}

func wipeAllData() {
loginInfo = nil
localUser = nil
}

func send<R: Request>(_ request: R) -> Future<R.Response> {
Future { try urlRequest(for: request) }
.flatMap { rawRequest in
let bodyDesc = rawRequest.httpBody
.map { "\"\(debugRepresentation(of: $0))\"" }
?? "request without body"
print("\(request.path): \(rawRequest.httpMethod!)ing \(bodyDesc) to \(rawRequest.url!)")

return self.send(rawRequest)
.catch { _ in print("\(request.path): failed!") }
}
.map { try self.extractData(from: $0, for: request) }
@discardableResult
func updateLocalUser(from repository: Repository) -> ConstructionManager? {
localUser = localUser.flatMap { repository.object($0.id) }
return localUser
}

func pushChangesThen<T>(perform task: @escaping () throws -> T) -> Future<T> {
Future(asyncOn: linearQueue) {
let errors = self.synchronouslyPushLocalChanges()
guard errors.isEmpty else {
throw RequestError.pushFailed(errors)
/// Provides a context for performing authenticated requests.
///
/// - Note: This is a method (not a property) to encourage reusing it when important, since the `await` that would usually notify of the main actor hop is already expected for the `send` that usually follows.
/// If you're not on the main actor, getting this context cannot be done synchronously and would thus silently introduce a main actor hop with every `await client.send(...)`.
func makeContext() -> any RequestContext {
_makeContext(self, loginInfo)
}
}

protocol RequestContext: Sendable {
var client: Client { get }
var loginInfo: LoginInfo? { get }

func send<R: BaupenRequest>(_ request: R) async throws -> R.Response
}

struct DefaultRequestContext: RequestContext {
let client: Client
let loginInfo: LoginInfo?

func send<R: BaupenRequest>(_ request: R) async throws -> R.Response {
let layer = Protolayer.urlSession(baseURL: loginInfo?.origin ?? baseServerURL)
.wrapErrors(RequestError.communicationError(_:))
.printExchanges()
.transformRequest { request in
if let token = loginInfo?.token {
request.setValue(token, forHTTPHeaderField: "X-Authentication")
}
}
return try task()
.readResponse(handleErrors(in:))

do {
return try await layer.send(request)
} catch {
print("\(request.path): failed!")
throw error
}
}

private func extractData<R: Request>(from taskResult: TaskResult, for request: R) throws -> R.Response {
let (data, response) = taskResult
print("\(request.path): status code: \(response.statusCode), body: \(debugRepresentation(of: data))")

switch response.statusCode {
private func handleErrors(in response: Protoresponse) throws {
switch response.httpMetadata!.statusCode {
case 200..<300:
return try request.decode(from: data, using: responseDecoder)
break // success
case 401:
throw RequestError.notAuthenticated
case let statusCode:
var hydraError: HydraError?
if
let hydraError: HydraError? = if
response.contentType?.hasPrefix("application/ld+json") == true,
let metadata = try? responseDecoder.decode(HydraMetadata.self, from: data),
let metadata = try? response.decodeJSON(as: HydraMetadata.self, using: responseDecoder),
metadata.type == HydraError.type
{
hydraError = try? responseDecoder.decode(from: data)
}
try? response.decodeJSON(using: responseDecoder)
} else { nil }
throw RequestError.apiError(hydraError, statusCode: statusCode)
}
}

private func urlRequest<R: Request>(for request: R) throws -> URLRequest {
try URLRequest(url: apiURL(for: request)) <- { rawRequest in
rawRequest.httpMethod = R.httpMethod
try request.encode(using: requestEncoder, into: &rawRequest)
if let token = loginInfo?.token {
rawRequest.setValue(token, forHTTPHeaderField: "X-Authentication")
}
if let contentType = R.contentType {
rawRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
}
}

private func apiURL<R: Request>(for request: R) -> URL {
(URLComponents(
url: request.baseURLOverride ?? loginInfo?.origin ?? Self.baseServerURL,
resolvingAgainstBaseURL: false
)! <- {
$0.percentEncodedPath += request.path
$0.queryItems = request.collectURLQueryItems()
.map { URLQueryItem(name: $0, value: "\($1)") }
.nonEmptyOptional
}).url!
}

private func send(_ request: URLRequest) -> Future<TaskResult> {
urlSession.dataTask(with: request)
.transformError { _, error in throw RequestError.communicationError(error) }
}
}

extension HTTPURLResponse {
var contentType: String? {
allHeaderFields["Content-Type"] as? String
}
protocol BaupenRequest: Request, Sendable where Response: Sendable {}
extension BaupenRequest where Self: JSONEncodingRequest {
var encoderOverride: JSONEncoder? { requestEncoder }
}
extension BaupenRequest where Self: MultipartEncodingRequest {
var encoderOverride: JSONEncoder? { requestEncoder }
}
extension BaupenRequest where Self: JSONDecodingRequest {
var decoderOverride: JSONDecoder? { responseDecoder }
}

private let requestEncoder = JSONEncoder() <- {
$0.dateEncodingStrategy = .iso8601
}
private let responseDecoder = JSONDecoder() <- {
$0.dateDecodingStrategy = .iso8601
}
private let baseServerURL = URL(string: "https://app.baupen.ch")!

/// An error that occurs while interfacing with the server.
enum RequestError: Error {
Expand All @@ -140,14 +130,5 @@ enum RequestError: Error {
// TODO: reimplement outdated client logic
}

fileprivate func debugRepresentation(of data: Data, maxLength: Int = 5000) -> String {
guard data.count <= maxLength else { return "<\(data.count) bytes>" }

return String(bytes: data, encoding: .utf8)?
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
?? "<\(data.count) bytes not UTF-8 decodable data>"
}

extension LoginInfo: DefaultsValueConvertible {}
extension ConstructionManager: DefaultsValueConvertible {}
12 changes: 6 additions & 6 deletions Issue Manager/Back End/Concrete Requests/DownloadFile.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Created by Julian Dunskus

import Foundation
import Promise
import Protoquest

private struct FileDownloadRequest: GetDataRequest {
private struct FileDownloadRequest: GetDataRequest, BaupenRequest {
var path: String

var size: String? = "full"
Expand All @@ -12,15 +12,15 @@ private struct FileDownloadRequest: GetDataRequest {
path = file.urlPath
}

func collectURLQueryItems() -> [(String, Any)] {
var urlParams: [URLParameter] {
if let size = size {
("size", size)
}
}
}

extension Client {
func download(_ file: AnyFile) -> Future<Data> {
send(FileDownloadRequest(file: file))
extension RequestContext {
func download(_ file: AnyFile) async throws -> Data {
try await send(FileDownloadRequest(file: file))
}
}
11 changes: 6 additions & 5 deletions Issue Manager/Back End/Concrete Requests/GetObjects.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Created by Julian Dunskus

import Foundation
import Protoquest

struct GetObjectRequest<Object: StoredObject>: GetJSONRequest {
struct GetObjectRequest<Object: StoredObject>: GetJSONRequest, BaupenRequest {
typealias Response = APIObject<Object.Model>

var path: String
Expand All @@ -14,15 +15,15 @@ extension GetObjectRequest {
}
}

struct GetObjectsRequest<Object: StoredObject>: GetJSONRequest {
struct GetObjectsRequest<Object: StoredObject>: GetJSONRequest, BaupenRequest {
typealias Response = HydraCollection<APIObject<Object.Model>>

var path: String { Object.apiPath }

var constructionSite: ConstructionSite.ID?
var minLastChangeTime: Date?

func collectURLQueryItems() -> [(String, Any)] {
var urlParams: [URLParameter] {
if let constructionSite = constructionSite {
("constructionSite", constructionSite.apiString)
}
Expand All @@ -33,7 +34,7 @@ struct GetObjectsRequest<Object: StoredObject>: GetJSONRequest {
}
}

struct GetPagedObjectsRequest<Model: APIModel>: GetJSONRequest {
struct GetPagedObjectsRequest<Model: APIModel>: GetJSONRequest, BaupenRequest {
typealias Response = PagedHydraCollection<APIObject<Model>>

var path: String { Model.Object.apiPath }
Expand All @@ -43,7 +44,7 @@ struct GetPagedObjectsRequest<Model: APIModel>: GetJSONRequest {
var page = 1
var itemsPerPage = 1000

func collectURLQueryItems() -> [(String, Any)] {
var urlParams: [URLParameter] {
if let constructionSite = constructionSite {
("constructionSite", constructionSite.apiString)
}
Expand Down
37 changes: 22 additions & 15 deletions Issue Manager/Back End/Concrete Requests/Login.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Created by Julian Dunskus

import Foundation
import Promise
import Protoquest

private struct SelfRequest: GetJSONRequest {
private struct SelfRequest: GetJSONRequest, BaupenRequest {
let path = "/api/me"

struct Response: Decodable {
Expand All @@ -12,19 +12,26 @@ private struct SelfRequest: GetJSONRequest {
}

extension Client {
func logIn(with loginInfo: LoginInfo) -> Future<Void> {
func logIn(with loginInfo: LoginInfo, repository: Repository) async throws {
self.loginInfo = loginInfo
return send(SelfRequest())
.map { GetObjectRequest(for: $0.constructionManagerIri.makeID()) }
.flatMap(send)
.map {
self.loginInfo = loginInfo // set again in case something else changed it since
let user = $0.makeObject()
self.localUser = user
Repository.shared.signedIn(as: user)
}
.catch { _ in
self.loginInfo = nil
}
do {
let context = makeContext()
let userID = try await context.send(SelfRequest()).constructionManagerIri.makeID()
let user = try await context.send(GetObjectRequest(for: userID)).makeObject()
self.loginInfo = loginInfo // set again in case something else changed it since
self.localUser = user
repository.signedIn(as: user)
} catch {
self.loginInfo = nil
throw error
}
}

#if DEBUG
func mockLogIn(loginInfo: LoginInfo, user: ConstructionManager, repository: Repository) {
self.loginInfo = loginInfo
self.localUser = user
repository.signedIn(as: user)
}
#endif
}
8 changes: 4 additions & 4 deletions Issue Manager/Back End/Concrete Requests/Register.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Created by Julian Dunskus

import Foundation
import Promise
import Protoquest

private struct RegisterRequest: JSONEncodingRequest, StatusCodeRequest {
private struct RegisterRequest: JSONEncodingRequest, StatusCodeRequest, BaupenRequest {
var baseURLOverride: URL?
var path: String { ConstructionManager.apiPath }

Expand All @@ -15,8 +15,8 @@ private struct RegisterRequest: JSONEncodingRequest, StatusCodeRequest {
}

extension Client {
func register(asEmail email: String, at domain: URL) -> Future<Void> {
func register(asEmail email: String, at domain: URL) async throws {
wipeAllData()
return send(RegisterRequest(baseURLOverride: domain, body: .init(email: email)))
return try await makeContext().send(RegisterRequest(baseURLOverride: domain, body: .init(email: email)))
}
}
Loading

0 comments on commit 0b8f568

Please sign in to comment.