/// <summary> /// Performs an HTTP <b>PATCH</b> using a specific <see cref="IRetryPolicy"/> and ensuring that /// a success code was returned. /// </summary> /// <param name="retryPolicy">The retry policy or <c>null</c> to disable retries.</param> /// <param name="uri">The URI</param> /// <param name="document">The optional object to be uploaded as the request payload.</param> /// <param name="args">The optional query arguments.</param> /// <param name="headers">The Optional HTTP headers.</param> /// <param name="cancellationToken">The optional <see cref="CancellationToken"/>.</param> /// <param name="logActivity">The optional <see cref="LogActivity"/> whose ID is to be included in the request.</param> /// <returns>The <see cref="JsonResponse"/>.</returns> /// <exception cref="SocketException">Thrown for network connectivity issues.</exception> /// <exception cref="HttpException">Thrown when the server responds with an HTTP error status code.</exception> public async Task <JsonResponse> PatchAsync( IRetryPolicy retryPolicy, string uri, object document = null, ArgDictionary args = null, ArgDictionary headers = null, CancellationToken cancellationToken = default, LogActivity logActivity = default) { await SyncContext.ClearAsync; Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(uri), nameof(uri)); retryPolicy = retryPolicy ?? NoRetryPolicy.Instance; return(await retryPolicy.InvokeAsync( async() => { var requestUri = FormatUri(uri, args); try { var client = this.HttpClient; if (client == null) { throw new ObjectDisposedException(nameof(JsonClient)); } var httpResponse = await client.PatchAsync(requestUri, CreateContent(document), cancellationToken: cancellationToken, headers: headers, activity: logActivity); var jsonResponse = new JsonResponse(requestUri, httpResponse, await httpResponse.Content.ReadAsStringAsync()); jsonResponse.EnsureSuccess(); return jsonResponse; } catch (HttpRequestException e) { throw new HttpException(e, requestUri); } })); }
/// <summary> /// Performs an HTTP <b>DELETE</b> using a specific <see cref="IRetryPolicy"/> and without ensuring /// that a success code was returned. /// </summary> /// <param name="retryPolicy">The retry policy or <c>null</c> to disable retries.</param> /// <param name="uri">The URI</param> /// <param name="args">The optional query arguments.</param> /// <param name="headers">The Optional HTTP headers.</param> /// <param name="cancellationToken">The optional <see cref="CancellationToken"/>.</param> /// <param name="logActivity">The optional <see cref="LogActivity"/> whose ID is to be included in the request.</param> /// <returns>The <see cref="JsonResponse"/>.</returns> /// <exception cref="SocketException">Thrown for network connectivity issues.</exception> public async Task <JsonResponse> DeleteUnsafeAsync( IRetryPolicy retryPolicy, string uri, ArgDictionary args = null, ArgDictionary headers = null, CancellationToken cancellationToken = default, LogActivity logActivity = default) { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(uri)); retryPolicy = retryPolicy ?? NoRetryPolicy.Instance; return(await retryPolicy.InvokeAsync( async() => { var requestUri = FormatUri(uri, args); try { var client = this.HttpClient; if (client == null) { throw new ObjectDisposedException(nameof(JsonClient)); } var httpResponse = await client.DeleteAsync(requestUri, cancellationToken: cancellationToken, headers: headers, activity: logActivity); return new JsonResponse(requestUri, httpResponse, await httpResponse.Content.ReadAsStringAsync()); } catch (HttpRequestException e) { throw new HttpException(e, requestUri); } })); }
/// <summary> /// Asynchronously downloads and assembles a multi-part file as specified by a <see cref="Neon.Deployment.DownloadManifest"/>. /// </summary> /// <param name="manifest">The download details.</param> /// <param name="targetPath">The target file path.</param> /// <param name="progressAction">Optionally specifies an action to be called with the the percentage downloaded.</param> /// <param name="partTimeout">Optionally specifies the HTTP download timeout for each part (defaults to 10 minutes).</param> /// <param name="retry">Optionally specifies the retry policy. This defaults to a reasonable policy.</param> /// <param name="cancellationToken">Optionally specifies the operation cancellation token.</param> /// <returns>The path to the downloaded file.</returns> /// <exception cref="IOException">Thrown when the download is corrupt.</exception> /// <exception cref="SocketException">Thrown for network errors.</exception> /// <exception cref="HttpException">Thrown for HTTP network errors.</exception> /// <exception cref="OperationCanceledException">Thrown when the operation was cancelled.</exception> /// <remarks> /// <para> /// This method downloads the file specified by <paramref name="manifest"/> to the folder specified, creating /// the folder first when required. The file will be downloaded in parts, where each part will be validated /// by comparing the part's MD5 hash (when present) with the computed value. The output file will be named /// <see cref="DownloadManifest.Name"/> and the overall MD5 hash will also be saved using the same file name but /// <b>adding</b> the <b>.md5</b> extension. /// </para> /// <para> /// This method will continue downloading a partially downloaded file. This works by validating the already /// downloaded parts against their MD5 hashes and then continuing part downloads after the last valid part. /// Nothing will be downloaded when the existing file is fully formed. /// </para> /// <note> /// The target files (output and MD5) will be deleted when download appears to be corrupt. /// </note> /// </remarks> public static async Task <string> DownloadMultiPartAsync( DownloadManifest manifest, string targetPath, DownloadProgressDelegate progressAction = null, TimeSpan partTimeout = default, IRetryPolicy retry = null, CancellationToken cancellationToken = default) { await SyncContext.Clear; Covenant.Requires <ArgumentNullException>(manifest != null, nameof(manifest)); Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(targetPath), nameof(targetPath)); retry = retry ?? new ExponentialRetryPolicy(TransientDetector.NetworkOrHttp, maxAttempts: 5); if (partTimeout <= TimeSpan.Zero) { partTimeout = TimeSpan.FromMinutes(10); } var targetFolder = Path.GetDirectoryName(targetPath); Directory.CreateDirectory(targetFolder); var targetMd5Path = Path.Combine(Path.GetDirectoryName(targetPath), Path.GetFileName(targetPath) + ".md5"); var nextPartNumber = 0; // If the target file already exists along with its MD5 hash file: // // 1. Compare the manifest MD5 with the local MD5 hash file and // quickly continue with the download when these don't match. // // 2. When the MD5 files match, compute the MD5 of the downloaded // file and compare that with the manifest and continue with // the download when these don't match. if (File.Exists(targetPath) && File.Exists(targetMd5Path) && File.ReadAllText(targetMd5Path).Trim() == manifest.Md5) { if (File.ReadAllText(targetMd5Path).Trim() == manifest.Md5) { using (var downloadStream = File.OpenRead(targetPath)) { if (CryptoHelper.ComputeMD5String(downloadStream) == manifest.Md5) { return(targetPath); } } } } NeonHelper.DeleteFile(targetMd5Path); // We'll recompute this below // Validate the parts of any existing target file to determine where // to start downloading missing parts. if (File.Exists(targetPath)) { using (var output = new FileStream(targetPath, System.IO.FileMode.Open, FileAccess.ReadWrite)) { var pos = 0L; foreach (var part in manifest.Parts.OrderBy(part => part.Number)) { progressAction?.Invoke(DownloadProgressType.Check, (int)((double)pos / (double)manifest.Size * 100.0)); // Handle a partially downloaded part. We're going to truncate the file to // remove the partial part and then break to start re-downloading the part. if (output.Length < pos + part.Size) { output.SetLength(pos); nextPartNumber = part.Number; break; } // Validate the part MD5. We're going to truncate the file to remove the // partial part and then break to start re-downloading the part. using (var partStream = new SubStream(output, pos, part.Size)) { if (CryptoHelper.ComputeMD5String(partStream) != part.Md5) { output.SetLength(pos); nextPartNumber = part.Number; break; } } pos += part.Size; nextPartNumber = part.Number + 1; } } } // Download any remaining parts. if (progressAction != null && !progressAction.Invoke(DownloadProgressType.Download, 0)) { return(targetPath); } if (nextPartNumber == manifest.Parts.Count) { progressAction?.Invoke(DownloadProgressType.Download, 100); return(targetPath); } try { using (var httpClient = new HttpClient()) { httpClient.Timeout = partTimeout; using (var output = new FileStream(targetPath, System.IO.FileMode.OpenOrCreate, FileAccess.ReadWrite)) { // Determine the starting position of the next part to be downloaded. var pos = manifest.Parts .Where(part => part.Number < nextPartNumber) .Sum(part => part.Size); // Download the remaining parts. foreach (var part in manifest.Parts .Where(part => part.Number >= nextPartNumber) .OrderBy(part => part.Number)) { await retry.InvokeAsync( async() => { output.Position = pos; var response = await httpClient.GetAsync(part.Uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); using (var contentStream = await response.Content.ReadAsStreamAsync()) { await contentStream.CopyToAsync(output, cancellationToken); } }); // Ensure that the downloaded part size matches the specification. if (output.Position - pos != part.Size) { throw new IOException($"[{manifest.Name}]: Part [{part.Number}] actual size [{output.Position - pos}] does not match the expected size [{part.Size}]."); } // Ensure that the downloaded part MD5 matches the specification. using (var subStream = new SubStream(output, pos, part.Size)) { var actualMd5 = CryptoHelper.ComputeMD5String(subStream); if (actualMd5 != part.Md5) { throw new IOException($"[{manifest.Name}]: Part [{part.Number}] actual MD5 [{actualMd5}] does not match the expected MD5 [{part.Md5}]."); } } pos += part.Size; if (progressAction != null && !progressAction.Invoke(DownloadProgressType.Download, (int)(100.0 * ((double)part.Number / (double)manifest.Parts.Count)))) { return(targetPath); } } if (output.Length != manifest.Size) { throw new IOException($"[{manifest.Name}]: Expected size [{manifest.Size}] got [{output.Length}]."); } } progressAction?.Invoke(DownloadProgressType.Download, 100); File.WriteAllText(targetMd5Path, manifest.Md5, Encoding.ASCII); return(targetPath); } } catch (IOException) { NeonHelper.DeleteFile(targetPath); NeonHelper.DeleteFile(targetMd5Path); throw; } }