Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add S3 storage service #144

Merged
merged 5 commits into from Dec 11, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/BaGet.AWS/BaGet.AWS.csproj
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
LordMike marked this conversation as resolved.
Show resolved Hide resolved

<PropertyGroup>
<TargetFrameworks>netstandard2.0;net461</TargetFrameworks>

<Description>The libraries to host BaGet on AWS.</Description>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.3.30" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="2.1.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\BaGet.Core\BaGet.Core.csproj" />
</ItemGroup>

</Project>
22 changes: 22 additions & 0 deletions src/BaGet.AWS/Configuration/S3StorageOptions.cs
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using BaGet.Core.Validation;

namespace BaGet.AWS.Configuration
{
public class S3StorageOptions
{
[RequiredIf(nameof(KeySecret), null, IsInverted = true)]
public string KeyId { get; set; }
LordMike marked this conversation as resolved.
Show resolved Hide resolved

[RequiredIf(nameof(KeyId), null, IsInverted = true)]
public string KeySecret { get; set; }

[Required]
public string Region { get; set; }

[Required]
public string Bucket { get; set; }

public string Prefix { get; set; }
LordMike marked this conversation as resolved.
Show resolved Hide resolved
}
}
35 changes: 35 additions & 0 deletions src/BaGet.AWS/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,35 @@
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using BaGet.AWS.Configuration;
using BaGet.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace BaGet.AWS.Extensions
{
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddS3StorageService(this IServiceCollection services)
{
services.AddSingleton(provider =>
{
var options = provider.GetRequiredService<IOptions<S3StorageOptions>>().Value;

AmazonS3Config config = new AmazonS3Config
{
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region)
};

if (!string.IsNullOrEmpty(options.KeyId))
return new AmazonS3Client(new BasicAWSCredentials(options.KeyId, options.KeySecret), config);

return new AmazonS3Client(config);
});

services.AddTransient<S3StorageService>();

return services;
}
}
}
98 changes: 98 additions & 0 deletions src/BaGet.AWS/S3StorageService.cs
@@ -0,0 +1,98 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3;
using Amazon.S3.Model;
using BaGet.AWS.Configuration;
using BaGet.Core.Services;
using Microsoft.Extensions.Options;

namespace BaGet.AWS
{
public class S3StorageService : IStorageService
{
private const string Separator = "/";
private readonly string _bucket;
private readonly string _prefix;
private readonly AmazonS3Client _client;

public S3StorageService(IOptions<S3StorageOptions> options, AmazonS3Client client)
{
_bucket = options.Value.Bucket;
_prefix = options.Value.Prefix;
_client = client;

if (!string.IsNullOrEmpty(_prefix) && !_prefix.EndsWith(Separator))
_prefix += Separator;
}

private string PrepareKey(string path)
{
return _prefix + path.Replace("\\", Separator);
}

public async Task<Stream> GetAsync(string path, CancellationToken cancellationToken = default)
{
MemoryStream stream = new MemoryStream();

try
{
using (GetObjectResponse res = await _client.GetObjectAsync(_bucket, PrepareKey(path), cancellationToken))
await res.ResponseStream.CopyToAsync(stream);

stream.Seek(0, SeekOrigin.Begin);
}
catch (Exception)
{
stream.Dispose();

// TODO
throw;
}

return stream;
}

public Task<Uri> GetDownloadUriAsync(string path, CancellationToken cancellationToken = default)
{
string res = _client.GetPreSignedURL(new GetPreSignedUrlRequest
{
BucketName = _bucket,
Key = PrepareKey(path)
});

return Task.FromResult(new Uri(res));
}

public async Task<PutResult> PutAsync(string path, Stream content, string contentType, CancellationToken cancellationToken = default)
{
// TODO: Uploads should be idempotent. This should fail if and only if the blob
// already exists but has different content.

using (MemoryStream ms = new MemoryStream())
LordMike marked this conversation as resolved.
Show resolved Hide resolved
{
await content.CopyToAsync(ms, 4096, cancellationToken);

ms.Seek(0, SeekOrigin.Begin);

await _client.PutObjectAsync(new PutObjectRequest
{
BucketName = _bucket,
Key = PrepareKey(path),
InputStream = ms,
ContentType = contentType,
AutoResetStreamPosition = false,
AutoCloseStream = false
}, cancellationToken);
}

return PutResult.Success;
}

public async Task DeleteAsync(string path, CancellationToken cancellationToken = default)
{
await _client.DeleteObjectAsync(_bucket, PrepareKey(path), cancellationToken);
}
}
}
1 change: 1 addition & 0 deletions src/BaGet.Core/Configuration/StorageOptions.cs
Expand Up @@ -9,5 +9,6 @@ public enum StorageType
{
FileSystem = 0,
AzureBlobStorage = 1,
AwsS3 = 2
}
}
135 changes: 135 additions & 0 deletions src/BaGet.Core/Validation/RequiredIfAttribute.cs
@@ -0,0 +1,135 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Reflection;

namespace BaGet.Core.Validation
{
/// <summary>
/// Provides conditional validation based on related property value.
///
/// Inspiration: https://stackoverflow.com/a/27666044
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class RequiredIfAttribute : ValidationAttribute
{
#region Properties

/// <summary>
/// Gets or sets the other property name that will be used during validation.
/// </summary>
/// <value>
/// The other property name.
/// </value>
public string OtherProperty { get; }

/// <summary>
/// Gets or sets the display name of the other property.
/// </summary>
/// <value>
/// The display name of the other property.
/// </value>
public string OtherPropertyDisplayName { get; set; }

/// <summary>
/// Gets or sets the other property value that will be relevant for validation.
/// </summary>
/// <value>
/// The other property value.
/// </value>
public object OtherPropertyValue { get; }

/// <summary>
/// Gets or sets a value indicating whether other property's value should match or differ from provided other property's value (default is <c>false</c>).
/// </summary>
/// <value>
/// <c>true</c> if other property's value validation should be inverted; otherwise, <c>false</c>.
/// </value>
/// <remarks>
/// How this works
/// - true: validated property is required when other property doesn't equal provided value
/// - false: validated property is required when other property matches provided value
/// </remarks>
public bool IsInverted { get; set; }

/// <summary>
/// Gets a value that indicates whether the attribute requires validation context.
/// </summary>
/// <returns><c>true</c> if the attribute requires validation context; otherwise, <c>false</c>.</returns>
public override bool RequiresValidationContext => true;

#endregion

#region Constructor

/// <summary>
/// Initializes a new instance of the <see cref="RequiredIfAttribute"/> class.
/// </summary>
/// <param name="otherProperty">The other property.</param>
/// <param name="otherPropertyValue">The other property value.</param>
public RequiredIfAttribute(string otherProperty, object otherPropertyValue)
: base("'{0}' is required because '{1}' has a value {3}'{2}'.")
{
OtherProperty = otherProperty;
OtherPropertyValue = otherPropertyValue;
IsInverted = false;
}

#endregion

/// <summary>
/// Applies formatting to an error message, based on the data field where the error occurred.
/// </summary>
/// <param name="name">The name to include in the formatted message.</param>
/// <returns>
/// An instance of the formatted error message.
/// </returns>
public override string FormatErrorMessage(string name)
{
return string.Format(
CultureInfo.CurrentCulture,
ErrorMessageString,
name,
OtherPropertyDisplayName ?? OtherProperty,
OtherPropertyValue,
IsInverted ? "other than " : "of ");
}

/// <summary>
/// Validates the specified value with respect to the current validation attribute.
/// </summary>
/// <param name="value">The value to validate.</param>
/// <param name="validationContext">The context information about the validation operation.</param>
/// <returns>
/// An instance of the <see cref="T:System.ComponentModel.DataAnnotations.ValidationResult" /> class.
/// </returns>
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (validationContext == null)
throw new ArgumentNullException(nameof(validationContext));

PropertyInfo otherProperty = validationContext.ObjectType.GetProperty(OtherProperty);
if (otherProperty == null)
{
return new ValidationResult(
string.Format(CultureInfo.CurrentCulture, "Could not find a property named '{0}'.", OtherProperty));
}

object otherValue = otherProperty.GetValue(validationContext.ObjectInstance);

// check if this value is actually required and validate it
if (!IsInverted && Equals(otherValue, OtherPropertyValue) ||
IsInverted && !Equals(otherValue, OtherPropertyValue))
{
if (value == null)
return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));

// additional check for strings so they're not empty
if (value is string val && val.Trim().Length == 0)
return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));
}

return ValidationResult.Success;
}
}
}
2 changes: 2 additions & 0 deletions src/BaGet.Tools.AzureSearchImporter/Program.cs
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using BaGet.AWS.Extensions;
using BaGet.Azure.Extensions;
using BaGet.Core.Configuration;
using BaGet.Core.Services;
Expand Down Expand Up @@ -60,6 +61,7 @@ private static IServiceProvider GetServiceProvider(IConfiguration configuration)

services.Configure<BaGetOptions>(configuration);
services.ConfigureAzure(configuration);
services.ConfigureAws(configuration);

services.AddLogging(logging =>
{
Expand Down
1 change: 1 addition & 0 deletions src/BaGet/BaGet.csproj
Expand Up @@ -19,6 +19,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\BaGet.AWS\BaGet.AWS.csproj" />
<ProjectReference Include="..\BaGet.Azure\BaGet.Azure.csproj" />
<ProjectReference Include="..\BaGet.Core\BaGet.Core.csproj" />
<ProjectReference Include="..\BaGet.Protocol\BaGet.Protocol.csproj" />
Expand Down