diff --git a/Sources/SwiftDevRant/Models/Rant.Kind.swift b/Sources/SwiftDevRant/Models/Rant.Kind.swift new file mode 100644 index 0000000..36ef6fe --- /dev/null +++ b/Sources/SwiftDevRant/Models/Rant.Kind.swift @@ -0,0 +1,11 @@ +public extension Rant { + public enum Kind: Int { + case rant = 1 + case collaboration = 2 + case meme = 3 + case question = 4 + case devRant = 5 + case random = 6 + //case undefined = 7 // Not available anymore in the official app + } +} diff --git a/Sources/SwiftDevRant/Request/Request.swift b/Sources/SwiftDevRant/Request/Request.swift index f46c5f8..d02ed48 100644 --- a/Sources/SwiftDevRant/Request/Request.swift +++ b/Sources/SwiftDevRant/Request/Request.swift @@ -161,4 +161,10 @@ public struct Request { let outData = try await requestData(urlRequest: urlRequest, apiError: apiError).data return try decoder.decode(Out.self, from: outData) } + + public func requestJson(config: Config, data: Data, apiError: ApiError.Type = EmptyError.self) async throws -> Out { + let urlRequest = makeURLRequest(config: config, body: data) + let outData = try await requestData(urlRequest: urlRequest, apiError: apiError).data + return try decoder.decode(Out.self, from: outData) + } } diff --git a/Sources/SwiftDevRant/SwiftDevRant.swift b/Sources/SwiftDevRant/SwiftDevRant.swift index 7beac02..80d8fba 100644 --- a/Sources/SwiftDevRant/SwiftDevRant.swift +++ b/Sources/SwiftDevRant/SwiftDevRant.swift @@ -8,9 +8,10 @@ public struct SwiftDevRant { self.request = Request(encoder: .devRant, decoder: .devRant, logger: requestLogger) } - func makeConfig(_ method: Request.Method, path: String, urlParameters: [String: String] = [:], headers: [String: String] = [:], token: AuthToken? = nil) -> Request.Config { + private func makeConfig(_ method: Request.Method, path: String, urlParameters: [String: String] = [:], headers: [String: String] = [:], token: AuthToken? = nil) -> Request.Config { var urlParameters = urlParameters urlParameters["app"] = "3" + if let token { urlParameters["token_id"] = String(token.id) urlParameters["token_key"] = token.key @@ -23,6 +24,49 @@ public struct SwiftDevRant { return .init(method: method, backend: backend, path: path, urlParameters: urlParameters, headers: headers) } + private func makeMultipartConfig(_ method: Request.Method, path: String, parameters: [String: String] = [:], boundary: String, headers: [String: String] = [:], token: AuthToken? = nil) -> Request.Config { + var parameters = parameters + parameters["app"] = "3" + + if let token { + parameters["token_id"] = String(token.id) + parameters["token_key"] = token.key + parameters["user_id"] = String(token.userId) + } + + var headers = headers + headers["Content-Type"] = "multipart/form-data; boundary=\(boundary)" + + return .init(method: method, backend: backend, path: path, urlParameters: parameters, headers: headers) + } + + private func multipartBody(parameters: [String: String], boundary: String, imageData: Data?) -> Data { + var body = Data() + + let boundaryPrefix = "--\(boundary)\r\n" + + for (key, value) in parameters { + body.appendString(boundaryPrefix) + body.appendString("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n") + body.appendString("\(value)\r\n") + } + + if let imageData { + //TODO: the image is not always jpeg. not sure if it matters here. + body.appendString(boundaryPrefix) + body.appendString("Content-Disposition: form-data; name=\"image\"; filename=\"image.jpeg\"\r\n") + body.appendString("Content-Type: image/jpeg\r\n\r\n") + body.append(imageData) + body.appendString("\r\n") + } + + body.appendString("--".appending(boundary.appending("--"))) + + return body + } +} + +public extension SwiftDevRant { public func logIn(username: String, password: String) async throws -> AuthToken { var parameters: [String: String] = [:] parameters["app"] = "3" @@ -74,7 +118,7 @@ public struct SwiftDevRant { parameters["plat"] = "1" // I don't know wtf that is. parameters["nari"] = "1" // I don't know wtf that is. - let config = makeConfig(.get, path: "devrant/rants", urlParameters: parameters) + let config = makeConfig(.get, path: "devrant/rants", urlParameters: parameters, token: token) let response: RantFeed.CodingData = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) @@ -86,7 +130,7 @@ public struct SwiftDevRant { /// - Parameters: /// - token: The token from the `logIn` call response. public func getWeeklies(token: AuthToken) async throws -> [Weekly] { - let config = makeConfig(.get, path: "devrant/weekly-list") + let config = makeConfig(.get, path: "devrant/weekly-list", token: token) let response: Weekly.CodingData.List = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) @@ -109,7 +153,7 @@ public struct SwiftDevRant { //parameters["sort"] = "algo" //TODO: This seems wrong. Check if this is needed or not. - let config = makeConfig(.get, path: "devrant/weekly-rants", urlParameters: parameters) + let config = makeConfig(.get, path: "devrant/weekly-rants", urlParameters: parameters, token: token) let response: RantFeed.CodingData = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) @@ -127,7 +171,7 @@ public struct SwiftDevRant { parameters["last_time"] = lastChecked.flatMap { String(Int($0.timeIntervalSince1970)) } ?? "0" parameters["ext_prof"] = "1" // I don't know wtf that is. - let config = makeConfig(.get, path: "users/me/notif-feed\(category.rawValue)", urlParameters: parameters) + let config = makeConfig(.get, path: "users/me/notif-feed\(category.rawValue)", urlParameters: parameters, token: token) let response: NotificationFeed.CodingData.Container = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) @@ -146,7 +190,7 @@ public struct SwiftDevRant { parameters["last_comment_id"] = lastCommentId.flatMap { String($0) } //parameters["ver"] = "1.17.0.4" //TODO: check if this is needed - let config = makeConfig(.get, path: "devrant/rants/\(rantId)", urlParameters: parameters) + let config = makeConfig(.get, path: "devrant/rants/\(rantId)", urlParameters: parameters, token: token) struct Response: Codable { let rant: Rant.CodingData @@ -157,4 +201,167 @@ public struct SwiftDevRant { return (rant: response.rant.decoded, comments: response.comments?.map(\.decoded) ?? []) } + + /// Gets a single comment. + /// + /// - Parameters: + /// - token: The token from the `logIn` call response. + /// - commentId: The id of the comment. + public func getCommentFromID(token: AuthToken, commentId: Int) async throws -> Comment { + let config = makeConfig(.get, path: "comments/\(commentId)", token: token) + + let response: Comment.CodingData = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) + + return response.decoded + } + + /// Gets the id of a user. + /// + /// - Parameters: + /// - username: The username of the user. + public func getUserId(username: String) async throws -> Int { + var parameters: [String: String] = [:] + + parameters["username"] = username + + let config = makeConfig(.get, path: "get-user-id", urlParameters: parameters) + + struct Response: Decodable { + let user_id: Int + } + + let response: Response = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) + + return response.user_id + } + + /// Gets a user's profile data. + /// + /// - Parameters: + /// - token: The token from the `logIn` call response. + /// - userId: The id of the user. + /// - contentType: The type of content created by the user to be fetched. + /// - skip: The number of content items to skip for pagination. + public func getProfileFromID(token: AuthToken, userId: Int, contentType: Profile.ContentType, skip: Int) async throws -> Profile { + var parameters: [String: String] = [:] + + parameters["skip"] = String(skip) + parameters["content"] = contentType.rawValue + + let config = makeConfig(.get, path: "users/\(userId)", urlParameters: parameters, token: token) + + let response: Profile.CodingData = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) + + return response.decoded + } + + /// Votes on a rant. + /// + /// - Parameters: + /// - token: The token from the `logIn` call response. + /// - rantId: The id of the rant. + /// - vote: The vote for this rant. + public func voteOnRant(token: AuthToken, rantId: Int, vote: VoteState) async throws -> Rant { //TODO: add downvote reason + var parameters: [String: String] = [:] + + parameters["vote"] = String(vote.rawValue) + + let config = makeConfig(.post, path: "devrant/rants/\(rantId)/vote", urlParameters: parameters, token: token) + + struct Response: Codable { + let rant: Rant.CodingData + //let comments: [Comment.CodingData]? //probably not needed + } + + let response: Response = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) + + return response.rant.decoded + } + + /// Votes on a comment. + /// + /// - Parameters: + /// - token: The token from the `logIn` call response. + /// - commentId: The id of the comment. + /// - vote: The vote for this comment. + public func voteOnComment(token: AuthToken, commentId: Int, vote: VoteState) async throws -> Comment { + var parameters: [String: String] = [:] + + parameters["vote"] = String(vote.rawValue) + + let config = makeConfig(.post, path: "comments/\(commentId)/vote", urlParameters: parameters, token: token) + + struct Response: Decodable { + let comment: Comment.CodingData + } + + let response: Response = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) + + return response.comment.decoded + } + + /// Updates the user profile. + /// + /// - Parameters: + /// - token: The token from the `logIn` call response. + /// - about: The user's about text. + /// - skills: The user's list of skills. + /// - github: The user's GitHub link. + /// - location: The user's geographic location. + /// - website: The user's personal website. + public func editUserProfile(token: AuthToken, about: String, skills: String, github: String, location: String, website: String) async throws { + var parameters: [String: String] = [:] + + parameters["profile_about"] = about + parameters["profile_skills"] = skills + parameters["profile_github"] = github + parameters["profile_location"] = location + parameters["profile_website"] = website + + let config = makeConfig(.post, path: "users/me/edit-profile", urlParameters: parameters, token: token) + + try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self) + } + + /// Posts a rant. + /// + /// - Parameters: + /// - token: The token from the `logIn` call response. + /// - kind: The type of rant. + /// - text: The text content of the rant. + /// - tags: The rant's associated tags. + /// - image: An image to attach to the rant. + /// - imageConversion: The image conversion methods for unsupported image formats. + /// - Returns: + /// The id of the posted rant. + public func postRant(token: AuthToken, kind: Rant.Kind, text: String, tags: String, image: Data?, imageConversion: [ImageDataConverter] = [.unsupportedToJpeg]) async throws -> Int { + let boundary = UUID().uuidString + + let config = makeMultipartConfig(.post, path: "devrant/rants", boundary: boundary) + + var parameters = config.urlParameters + + parameters["content"] = text + parameters["tags"] = tags + parameters["type"] = String(kind.rawValue) + + let convertedImage = image.flatMap { imageConversion.convert($0) } + + let bodyData = multipartBody(parameters: parameters, boundary: boundary, imageData: convertedImage) + + struct Response: Decodable { + let rant_id: Int + } + + let response: Response = try await request.requestJson(config: config, data: bodyData, apiError: DevRantApiError.CodingData.self) + + return response.rant_id + } +} + +private extension Data { + mutating func appendString(_ string: String) { + let data = string.data(using: .utf8, allowLossyConversion: false) + append(data!) + } }