{
"extension": ".swift",
"source": "import Foundation\nimport KreeRequest\n\npublic struct DevRantRequest {\n let request: KreeRequest\n let backend = DevRantBackend()\n \n public init(requestLogger: Logger) {\n self.request = KreeRequest(encoder: .devRant, decoder: .devRant, logger: requestLogger)\n }\n \n private func makeConfig(_ method: KreeRequest.Method, path: String, urlParameters: [String: String] = [:], headers: [String: String] = [:], token: AuthToken? = nil) -> KreeRequest.Config {\n var urlParameters = urlParameters\n urlParameters[\"app\"] = \"3\"\n \n if let token {\n urlParameters[\"token_id\"] = String(token.id)\n urlParameters[\"token_key\"] = token.key\n urlParameters[\"user_id\"] = String(token.userId)\n }\n \n var headers = headers\n headers[\"Content-Type\"] = \"application/x-www-form-urlencoded\"\n \n return .init(method: method, backend: backend, path: path, urlParameters: urlParameters, headers: headers)\n }\n \n private func makeMultipartConfig(_ method: KreeRequest.Method, path: String, parameters: [String: String] = [:], boundary: String, headers: [String: String] = [:], token: AuthToken? = nil) -> KreeRequest.Config {\n var parameters = parameters\n parameters[\"app\"] = \"3\"\n \n if let token {\n parameters[\"token_id\"] = String(token.id)\n parameters[\"token_key\"] = token.key\n parameters[\"user_id\"] = String(token.userId)\n }\n \n var headers = headers\n headers[\"Content-Type\"] = \"multipart/form-data; boundary=\\(boundary)\"\n \n return .init(method: method, backend: backend, path: path, urlParameters: parameters, headers: headers)\n }\n \n private func multipartBody(parameters: [String: String], boundary: String, imageData: Data?) -> Data {\n var body = Data()\n \n let boundaryPrefix = \"--\\(boundary)\\r\\n\"\n \n for (key, value) in parameters {\n body.appendString(boundaryPrefix)\n body.appendString(\"Content-Disposition: form-data; name=\\\"\\(key)\\\"\\r\\n\\r\\n\")\n body.appendString(\"\\(value)\\r\\n\")\n }\n \n if let imageData {\n //TODO: the image is not always jpeg. not sure if it matters here.\n body.appendString(boundaryPrefix)\n body.appendString(\"Content-Disposition: form-data; name=\\\"image\\\"; filename=\\\"image.jpeg\\\"\\r\\n\")\n body.appendString(\"Content-Type: image/jpeg\\r\\n\\r\\n\")\n body.append(imageData)\n body.appendString(\"\\r\\n\")\n }\n \n body.appendString(\"--\".appending(boundary.appending(\"--\")))\n \n return body\n }\n \n /// For endpoints with the POST method in the devRant API the url parameters need to be passed as a string in the http body rather than in the URL.\n /// The url encoding works but it might also work with other encodings like json or multipart form data.\n private func stringBody(fromUrlParameters urlParameters: [String: String]) -> String {\n String(KreeRequest.urlEncodedQueryString(from: urlParameters).dropFirst()) // dropping the first character \"?\"\n }\n}\n\npublic extension DevRantRequest {\n func logIn(username: String, password: String) async throws -> AuthToken {\n var parameters: [String: String] = [:]\n parameters[\"app\"] = \"3\"\n parameters[\"username\"] = username\n parameters[\"password\"] = password\n \n let config = makeConfig(.post, path: \"users/auth-token\")\n \n let body = stringBody(fromUrlParameters: parameters)\n \n let response: AuthToken.CodingData.Container = try await request.requestJson(config: config, string: body, apiError: DevRantApiError.CodingData.self)\n \n return response.auth_token.decoded\n }\n \n /// Gets a personalized feed of rants.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - limit: The number of rants to get for pagination.\n /// - skip: How many rants to skip for pagination.\n /// - sessionHash: Pass the session hash value from the last rant feed response or `nil` if calling for the first time.\n func getRantFeed(token: AuthToken, sort: RantFeed.Sort = .algorithm, limit: Int = 20, skip: Int, sessionHash: String?) async throws -> RantFeed {\n var parameters: [String: String] = [:]\n \n parameters[\"sort\"] = switch sort {\n case .algorithm: \"algo\"\n case .recent: \"recent\"\n case .top: \"top\"\n }\n \n switch sort {\n case .top(range: let range):\n parameters[\"range\"] = switch range {\n case .day: \"day\"\n case .week: \"week\"\n case .month: \"month\"\n case .all: \"all\"\n }\n default:\n break\n }\n \n parameters[\"limit\"] = String(limit)\n parameters[\"skip\"] = String(skip)\n parameters[\"prev_set\"] = sessionHash\n \n parameters[\"plat\"] = \"1\" // I don't know wtf that is.\n parameters[\"nari\"] = \"1\" // I don't know wtf that is.\n \n let config = makeConfig(.get, path: \"devrant/rants\", urlParameters: parameters, token: token)\n \n let response: RantFeed.CodingData = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n \n return response.decoded\n }\n \n /// Gets all weeklies as a list.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n func getWeeklies(token: AuthToken) async throws -> [Weekly] {\n let config = makeConfig(.get, path: \"devrant/weekly-list\", token: token)\n \n let response: Weekly.CodingData.List = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n \n return response.weeks.map(\\.decoded)\n }\n \n /// Gets a specific week's weekly rants.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - week: The number of the week. Pass `nil` to get the latest week's rants.\n /// - limit: The number of rants for pagination.\n /// - skip: How many rants to skip for pagination.\n func getWeeklyRants(token: AuthToken, week: Int?, limit: Int = 20, skip: Int) async throws -> RantFeed {\n var parameters: [String: String] = [:]\n \n parameters[\"week\"] = week.flatMap { String($0) }\n parameters[\"limit\"] = String(limit)\n parameters[\"skip\"] = String(skip)\n \n let config = makeConfig(.get, path: \"devrant/weekly-rants\", urlParameters: parameters, token: token)\n \n let response: RantFeed.CodingData = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n \n return response.decoded\n }\n \n /// Gets the list of notifications and numbers for each notification type.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - lastChecked: Pass the value from the last response or `nil`.\n func getNotificationFeed(token: AuthToken, lastChecked: Date?, category: NotificationFeed.Category) async throws -> NotificationFeed {\n var parameters: [String: String] = [:]\n \n parameters[\"last_time\"] = lastChecked.flatMap { String(Int($0.timeIntervalSince1970)) } ?? \"0\"\n parameters[\"ext_prof\"] = \"1\" // I don't know wtf that is.\n \n let config = makeConfig(.get, path: \"users/me/notif-feed\\(category.rawValue)\", urlParameters: parameters, token: token)\n \n let response: NotificationFeed.CodingData.Container = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n \n return response.data.decoded\n }\n \n /// Gets a single rant and its comments.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - rantId: The id of the rant.\n /// - lastCommentId: Only fetch the comments which were posted after the one corresponding to this id. Pass `nil` to get all comments.\n func getRant(token: AuthToken, rantId: Int, lastCommentId: Int? = nil) async throws -> (rant: Rant, comments: [Comment]) {\n var parameters: [String: String] = [:]\n\n parameters[\"last_comment_id\"] = lastCommentId.flatMap { String($0) }\n \n let config = makeConfig(.get, path: \"devrant/rants/\\(rantId)\", urlParameters: parameters, token: token)\n \n struct Response: Codable {\n let rant: Rant.CodingData\n let comments: [Comment.CodingData]?\n }\n \n let response: Response = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n \n return (rant: response.rant.decoded, comments: response.comments?.map(\\.decoded) ?? [])\n }\n \n /// Gets a single comment.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - commentId: The id of the comment.\n func getComment(token: AuthToken, commentId: Int) async throws -> Comment {\n let config = makeConfig(.get, path: \"comments/\\(commentId)\", token: token)\n \n let response: Comment.CodingData = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n \n return response.decoded\n }\n \n /// Gets the id of a user.\n ///\n /// - Parameters:\n /// - username: The username of the user.\n func getUserId(username: String) async throws -> Int {\n var parameters: [String: String] = [:]\n\n parameters[\"username\"] = username\n \n let config = makeConfig(.get, path: \"get-user-id\", urlParameters: parameters)\n \n struct Response: Decodable {\n let user_id: Int\n }\n \n let response: Response = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n \n return response.user_id\n }\n \n /// Gets a user's profile data.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - userId: The id of the user.\n /// - contentType: The type of content created by the user to be fetched.\n /// - skip: The number of content items to skip for pagination.\n func getProfile(token: AuthToken, userId: Int, contentType: Profile.ContentType, skip: Int) async throws -> Profile {\n var parameters: [String: String] = [:]\n\n parameters[\"skip\"] = String(skip)\n parameters[\"content\"] = contentType.rawValue\n \n let config = makeConfig(.get, path: \"users/\\(userId)\", urlParameters: parameters, token: token)\n \n let response: Profile.CodingData.Container = try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n \n return response.decoded\n }\n \n /// Votes on a rant.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - rantId: The id of the rant.\n /// - vote: The vote for this rant.\n func voteOnRant(token: AuthToken, rantId: Int, vote: VoteState, downvoteReason: DownvoteReason = .notForMe) async throws -> Rant {\n let config = makeConfig(.post, path: \"devrant/rants/\\(rantId)/vote\", token: token)\n \n var parameters = config.urlParameters\n\n parameters[\"vote\"] = String(vote.rawValue)\n \n if vote == .downvoted {\n parameters[\"reason\"] = String(downvoteReason.rawValue)\n }\n \n let body = stringBody(fromUrlParameters: parameters)\n \n struct Response: Codable {\n let rant: Rant.CodingData\n }\n \n let response: Response = try await request.requestJson(config: config, string: body, apiError: DevRantApiError.CodingData.self)\n \n return response.rant.decoded\n }\n \n /// Votes on a comment.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - commentId: The id of the comment.\n /// - vote: The vote for this comment.\n func voteOnComment(token: AuthToken, commentId: Int, vote: VoteState, downvoteReason: DownvoteReason = .notForMe) async throws -> Comment {\n let config = makeConfig(.post, path: \"comments/\\(commentId)/vote\", token: token)\n \n var parameters = config.urlParameters\n\n parameters[\"vote\"] = String(vote.rawValue)\n \n if vote == .downvoted {\n parameters[\"reason\"] = String(downvoteReason.rawValue)\n }\n \n let body = stringBody(fromUrlParameters: parameters)\n \n struct Response: Decodable {\n let comment: Comment.CodingData\n }\n \n let response: Response = try await request.requestJson(config: config, string: body, apiError: DevRantApiError.CodingData.self)\n \n return response.comment.decoded\n }\n \n /// Updates the user profile.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - about: The user's about text.\n /// - skills: The user's list of skills.\n /// - github: The user's GitHub link.\n /// - location: The user's geographic location.\n /// - website: The user's personal website.\n func editUserProfile(token: AuthToken, about: String, skills: String, github: String, location: String, website: String) async throws {\n let config = makeConfig(.post, path: \"users/me/edit-profile\", token: token)\n \n var parameters = config.urlParameters\n\n parameters[\"profile_about\"] = about\n parameters[\"profile_skills\"] = skills\n parameters[\"profile_github\"] = github\n parameters[\"profile_location\"] = location\n parameters[\"profile_website\"] = website\n \n let body = stringBody(fromUrlParameters: parameters)\n \n try await request.requestJson(config: config, string: body, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Creates and posts a rant.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - kind: The type of rant.\n /// - text: The text content of the rant.\n /// - tags: The rant's associated tags.\n /// - image: An image to attach to the rant.\n /// - imageConversion: The image conversion methods for unsupported image formats.\n /// - Returns:\n /// The id of the posted rant.\n func postRant(token: AuthToken, kind: Rant.Kind, text: String, tags: String, image: Data?, imageConversion: [ImageDataConverter] = [.unsupportedToJpeg]) async throws -> Int {\n let boundary = UUID().uuidString\n \n let config = makeMultipartConfig(.post, path: \"devrant/rants\", boundary: boundary, token: token)\n \n var parameters = config.urlParameters\n\n parameters[\"rant\"] = text\n parameters[\"tags\"] = tags\n parameters[\"type\"] = String(kind.rawValue)\n \n let convertedImage = image.flatMap { imageConversion.convert($0) }\n \n let bodyData = multipartBody(parameters: parameters, boundary: boundary, imageData: convertedImage)\n \n struct Response: Decodable {\n let rant_id: Int\n }\n \n let response: Response = try await request.requestJson(config: config, data: bodyData, apiError: DevRantApiError.CodingData.self)\n \n return response.rant_id\n }\n \n /// Deletes a rant.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - rantId: The id of the rant.\n func deleteRant(token: AuthToken, rantId: Int) async throws {\n let config = makeConfig(.delete, path: \"devrant/rants/\\(rantId)\", token: token)\n \n try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Sets or unsets a rant as a favorite.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - rantId: The id of the rant.\n /// - favorite: `true` sets the rant as favorite and `false` sets it as not favorite.\n func favoriteRant(token: AuthToken, rantId: Int, favorite: Bool) async throws {\n let favoritePath = favorite ? \"favorite\" : \"unfavorite\"\n \n let config = makeConfig(.post, path: \"devrant/rants/\\(rantId)/\\(favoritePath)\", token: token)\n \n let parameters = config.urlParameters\n \n let body = stringBody(fromUrlParameters: parameters)\n \n try await request.requestJson(config: config, string: body, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Edits a posted rant.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - rantId: The id of the rant.\n /// - text: The text content of the rant.\n /// - tags: The rants's associated tags.\n /// - image: An image to attach to the rant.\n func editRant(token: AuthToken, rantId: Int, text: String, tags: String, image: Data?, imageConversion: [ImageDataConverter] = [.unsupportedToJpeg]) async throws {\n let boundary = UUID().uuidString\n \n let config = makeMultipartConfig(.post, path: \"devrant/rants/\\(rantId)\", boundary: boundary, token: token)\n \n var parameters = config.urlParameters\n\n parameters[\"rant\"] = text\n parameters[\"tags\"] = tags\n \n let convertedImage = image.flatMap { imageConversion.convert($0) }\n \n let bodyData = multipartBody(parameters: parameters, boundary: boundary, imageData: convertedImage)\n \n try await request.requestJson(config: config, data: bodyData, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Creates and posts a comment for a specific rant.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - rantId: The id of the rant that this comment should be posted for.\n /// - text: The text content of the comment.\n /// - image: An image to attach to the comment.\n func postComment(token: AuthToken, rantId: Int, text: String, image: Data?, imageConversion: [ImageDataConverter] = [.unsupportedToJpeg]) async throws {\n let boundary = UUID().uuidString\n \n let config = makeMultipartConfig(.post, path: \"devrant/rants/\\(rantId)/comments\", boundary: boundary, token: token)\n \n var parameters = config.urlParameters\n\n parameters[\"comment\"] = text\n \n let convertedImage = image.flatMap { imageConversion.convert($0) }\n \n let bodyData = multipartBody(parameters: parameters, boundary: boundary, imageData: convertedImage)\n \n try await request.requestJson(config: config, data: bodyData, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Edits a posted comment.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - commentId: The id of the comment.\n /// - text: The text content of the comment.\n /// - image: An image to attach to the comment.\n func editComment(token: AuthToken, commentId: Int, text: String, image: Data?, imageConversion: [ImageDataConverter] = [.unsupportedToJpeg]) async throws {\n let boundary = UUID().uuidString\n \n let config = makeMultipartConfig(.post, path: \"comments/\\(commentId)\", boundary: boundary, token: token)\n \n var parameters = config.urlParameters\n\n parameters[\"comment\"] = text\n \n let convertedImage = image.flatMap { imageConversion.convert($0) }\n \n let bodyData = multipartBody(parameters: parameters, boundary: boundary, imageData: convertedImage)\n \n try await request.requestJson(config: config, data: bodyData, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Deletes a comment.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - commentId: The id of the comment.\n func deleteComment(token: AuthToken, commentId: Int) async throws {\n let config = makeConfig(.delete, path: \"comments/\\(commentId)\", token: token)\n \n try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Marks all notifications as read.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n func markAllNotificationsAsRead(token: AuthToken) async throws {\n let config = makeConfig(.delete, path: \"users/me/notif-feed\", token: token)\n \n try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Subscribes to a user.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - userId: The id of the user to subscribe to.\n func subscribeToUser(token: AuthToken, userId: Int) async throws {\n let config = makeConfig(.post, path: \"users/\\(userId)/subscribe\", token: token)\n \n let parameters = config.urlParameters\n \n let body = stringBody(fromUrlParameters: parameters)\n \n try await request.requestJson(config: config, string: body, apiError: DevRantApiError.CodingData.self)\n }\n \n /// Unsubscribes from a user.\n ///\n /// - Parameters:\n /// - token: The token from the `logIn` call response.\n /// - userId: The id of the user to unsubscribe from.\n func unsubscribeFromUser(token: AuthToken, userId: Int) async throws {\n let config = makeConfig(.delete, path: \"users/\\(userId)/subscribe\", token: token)\n \n try await request.requestJson(config: config, apiError: DevRantApiError.CodingData.self)\n }\n}\n\nprivate extension Data {\n mutating func appendString(_ string: String) {\n let data = string.data(using: .utf8, allowLossyConversion: false)\n append(data!)\n }\n}\n",
"review": "# 8\n\n## Bugs\n- Possible issue with the hardcoded JPEG image type label in `multipartBody` method, which assumes all images are JPEG.\n- Commented TODO for image type indicates unresolved consideration which can be critical for image handling.\n\n## Optimizations\n- Use of `.flatMap` with conditional bindings for converting images can be made more readable by leveraging optional chaining.\n- Repeated code for appending form-data parameters could be abstracted to avoid redundancy.\n- Replace string-based API path composition with constants or enums for better maintainability and to avoid string errors.\n- Implement error logging/handling for scenarios where data could unexpectedly fail to encode or decode.\n- Consider making the boundary creation more secure by ensuring no overlap with actual data content.\n\n## Good points\n- Consistent use of Swift's error-handling with `try/await` demonstrates modern Swift practices.\n- Use of extensions and private methods for code organization and encapsulation.\n- Thoughtful use of comments and TODOs to note potential improvements.\n- Separation of concerns is well managed with specific methods for each API action.\n\n## Summary\nThe codebase is overall well-structured, demonstrating a clear understanding of Swift's asynchronous programming model and best practices like encapsulation, and separation of concerns. You have highlighted potential improvement areas through comments, especially concerning image handling, which still needs resolution. Implementing improvements related to repeated patterns could enhance code maintainability and readability. \n\n## Open source alternatives\n- **Alamofire:** An HTTP networking library written in Swift that simplifies making network requests. It may be worth comparing if it provides additional benefits.\n- **Moya:** A network abstraction layer built on top of Alamofire which provides additional features to handle network requests in a more organized manner.\n- **Combine framework's URLSession:** With the Combine framework, you can handle HTTP networking tasks concurrently efficiently, simplifying error handling and response data parsing.",
"filename": "DevRantRequest.swift",
"path": "Sources/SwiftDevRant/DevRantRequest.swift",
"directory": "SwiftDevRant",
"grade": 8,
"size": 22105,
"line_count": 531
}