Sources/XCMetricsClient/Network/MultipartRequestBuilder.swift (122 lines of code) (raw):

// Copyright (c) 2020 Spotify AB. // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. import Foundation import XCMetricsCommon /// Creates a Nested Multipart Request to send the /// `xcactivitylog` and the Metadata associated to it as `JSON` documents /// in a single request class MultipartRequestBuilder { public let request: MetricsUploadRequest public let url: URL public let additionalHeaders: [String: String] public let machineName: String public let projectName: String public let isCI: Bool public let skipNotes: Bool public let truncLargeIssues: Bool public init(request: MetricsUploadRequest, url: URL, additionalHeaders: [String: String], machineName: String, projectName: String, isCI: Bool, skipNotes: Bool, truncLargeIssues: Bool) { self.request = request self.url = url self.additionalHeaders = additionalHeaders self.machineName = machineName self.projectName = projectName self.isCI = isCI self.skipNotes = skipNotes self.truncLargeIssues = truncLargeIssues } public func build() throws -> URLRequest { // Use the file name UUID as the boundary. let uuid = self.request.fileURL.deletingPathExtension().lastPathComponent let boundary = "Boundary-\(uuid)" var request = URLRequest(url: url) request.httpMethod = "PUT" additionalHeaders.forEach { request.addValue($1, forHTTPHeaderField: $0) } request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") // If this is a retry for a previously failed request, simply set the body. Otherwise compute it. let body: Data if self.request.fileURL.isRequestFile { body = try Data(contentsOf: self.request.fileURL) } else { body = try getBody(boundary: boundary) } request.setValue(String(body.count), forHTTPHeaderField: "Content-Length") request.httpBody = body return request } private func getBody(boundary: String) throws -> Data { let httpBody = NSMutableData() if let logData = try toBinaryFormField( named: "log", fileURL: request.fileURL, using: boundary ) { httpBody.append(logData) } let jsonEncoder = JSONEncoder() /// Backend will decide if the username will be stored hashed or not based on its configuration let user = MacOSUsernameReader().userID ?? "unknown" let sleepTime = HardwareFactsFetcherImplementation().sleepTime let extraInfo = UploadRequestExtraInfo(projectName: projectName, machineName: machineName, user: user, isCI: isCI, sleepTime: sleepTime, skipNotes: skipNotes, tag: request.request.build.tag, truncLargeIssues: truncLargeIssues) let extraJson = try jsonEncoder.encode(extraInfo) if let extraData = toJSONFormField(named: "extraInfo", jsonData: extraJson, using: boundary) { httpBody.append(extraData) } let buildHostJson = try jsonEncoder.encode(request.request.buildHost) if let buildHostData = toJSONFormField(named: "buildHost", jsonData: buildHostJson, using: boundary) { httpBody.append(buildHostData) } if !request.request.xcodeVersion.buildNumber.isEmpty { let jsonData = try jsonEncoder.encode(request.request.xcodeVersion) if let xcodeVersionData = toJSONFormField(named: "xcodeVersion", jsonData: jsonData, using: boundary) { httpBody.append(xcodeVersionData) } } if !request.request.buildMetadata.metadata.isEmpty { let jsonData = try jsonEncoder.encode(request.request.buildMetadata) if let buildMetadataData = toJSONFormField(named: "buildMetadata", jsonData: jsonData, using: boundary) { httpBody.append(buildMetadataData) } } if let end = "--\(boundary)--\r\n".data(using: .utf8) { httpBody.append(end) } return httpBody as Data } private func toJSONFormField(named name: String, jsonData: Data, using boundary: String) -> Data? { var fieldString = "--\(boundary)\r\n" fieldString += "Content-Disposition: form-data; name=\"\(name)\"\r\n" fieldString += "\r\n" fieldString += "Content-Type: application/json\r\n\r\n" guard let fieldsData = fieldString.data(using: .utf8), let separator = "\r\n".data(using: .utf8) else { return nil } let data = NSMutableData() data.append(fieldsData) data.append(jsonData) data.append(separator) return data as Data } private func toBinaryFormField(named name: String, fileURL: URL, using boundary: String) throws -> Data? { let fileData = try Data(contentsOf: fileURL) var fieldString = "--\(boundary)\r\n" fieldString += "Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(fileURL.lastPathComponent)\"\r\n" fieldString += "\r\n" fieldString += "Content-Type: application/octet-stream\r\n\r\n" guard let fieldsData = fieldString.data(using: .utf8), let separator = "\r\n".data(using: .utf8) else { return nil } let data = NSMutableData() data.append(fieldsData) data.append(fileData) data.append(separator) return data as Data } }