diff --git a/.gitignore b/.gitignore index 3d671a7e..ed4aec45 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ TestResults/ *.mdf *.psess *.vsp +.idea/ /**/project.lock.json @@ -18,4 +19,4 @@ StyleCop.Cache /.vs /packages /RedditSharpTests/secrets.json -/RedditSharpTests/private.config +/RedditSharpTests/private.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0c5796a..9c0f9931 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Development -Use a private.config file to pull in config for tests. The required format is as follows +Use a private.json file to pull in config for tests. The required format is as follows ```json { diff --git a/RedditSharp/Data/Collections/AddOrRemovePostsFromCollectionParams.cs b/RedditSharp/Data/Collections/AddOrRemovePostsFromCollectionParams.cs new file mode 100644 index 00000000..df38eed2 --- /dev/null +++ b/RedditSharp/Data/Collections/AddOrRemovePostsFromCollectionParams.cs @@ -0,0 +1,17 @@ +namespace RedditSharp.Data.Collections +{ + internal class AddOrRemovePostsFromCollectionParams + { + /// + /// UUID of a collection + /// + [RedditAPIName("collection_id")] + internal string CollectionId { get; set; } + + /// + /// Full name of link, e.g. t3_xyz + /// + [RedditAPIName("link_fullname")] + internal string LinkFullName { get; set; } + } +} \ No newline at end of file diff --git a/RedditSharp/Data/Collections/CollectionCreationParams.cs b/RedditSharp/Data/Collections/CollectionCreationParams.cs new file mode 100644 index 00000000..13d97ac3 --- /dev/null +++ b/RedditSharp/Data/Collections/CollectionCreationParams.cs @@ -0,0 +1,23 @@ +namespace RedditSharp.Data.Collections +{ + internal class CollectionCreationParams + { + /// + /// Title of the submission. Maximum 300 characters. + /// + [RedditAPIName("title")] + internal string Title { get; set; } + + /// + /// Description of the collection. Maximum of 500 characters. + /// + [RedditAPIName("description")] + internal string Description { get; set; } + + /// + /// Name of the subreddit to which you are submitting. + /// + [RedditAPIName("sr_fullname")] + internal string Subreddit { get; set; } + } +} \ No newline at end of file diff --git a/RedditSharp/Data/Collections/GetCollectionParams.cs b/RedditSharp/Data/Collections/GetCollectionParams.cs new file mode 100644 index 00000000..9a2616db --- /dev/null +++ b/RedditSharp/Data/Collections/GetCollectionParams.cs @@ -0,0 +1,20 @@ +namespace RedditSharp.Data.Collections +{ + internal class CollectionBaseParams + { + /// + /// UUID of a collection + /// + [RedditAPIName("collection_id")] + internal string CollectionId { get; set; } + } + + internal class GetCollectionParams : CollectionBaseParams + { + /// + /// Should include all the links + /// + [RedditAPIName("include_links")] + internal bool IncludeLinks { get; set; } + } +} \ No newline at end of file diff --git a/RedditSharp/Data/Urls.cs b/RedditSharp/Data/Urls.cs new file mode 100644 index 00000000..1bdb7e88 --- /dev/null +++ b/RedditSharp/Data/Urls.cs @@ -0,0 +1,15 @@ +namespace RedditSharp.Data +{ + internal static class Urls + { + internal static class Collections + { + internal const string AddPost = "/api/v1/collections/add_post_to_collection"; + internal static string Get(string collectionId, bool includeLinks) => $"/api/v1/collections/collection.json?collection_id={collectionId}&include_links={includeLinks}"; + internal const string CreateCollectionUrl = "/api/v1/collections/create_collection"; + internal const string Delete = "/api/v1/collections/delete_collection"; + internal const string RemovePost = "/api/v1/collections/remove_post_in_collection"; + internal static string SubredditCollectionsUrl(string fullName) => $"/api/v1/collections/subreddit_collections.json?sr_fullname={fullName}"; + } + } +} \ No newline at end of file diff --git a/RedditSharp/Exceptions/RedditException.cs b/RedditSharp/Exceptions/RedditException.cs index e6ff600e..9a1118fd 100644 --- a/RedditSharp/Exceptions/RedditException.cs +++ b/RedditSharp/Exceptions/RedditException.cs @@ -1,4 +1,5 @@ using System; +using Newtonsoft.Json.Linq; namespace RedditSharp { @@ -8,6 +9,8 @@ namespace RedditSharp [Serializable] public class RedditException : Exception { + public JArray Errors { get; } + /// /// Initializes a new instance of the RedditException class. /// @@ -26,6 +29,17 @@ public RedditException(string message) } + /// + /// Initializes a new instance of the RedditException class with a specified error message and a JArray of errors + /// + /// The message that describes the error. + /// List of errors. + public RedditException(string message, JArray errors) + : base(message) + { + Errors = errors; + } + /// /// Initializes a new instance of the RedditException class with a specified error message and /// a referenced inner exception that is the cause of this exception. diff --git a/RedditSharp/Extensions/JTokenExtensions/JTokenExtensions.cs b/RedditSharp/Extensions/JTokenExtensions/JTokenExtensions.cs new file mode 100644 index 00000000..a029590d --- /dev/null +++ b/RedditSharp/Extensions/JTokenExtensions/JTokenExtensions.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json.Linq; + +namespace RedditSharp.Extensions.JTokenExtensions +{ + public static class JTokenExtensions + { + public static void ThrowIfHasErrors(this JToken json, string message) + { + if (json["errors"].IsNonEmptyArray(out var errors)) + { + throw new RedditException($"{message} {errors}", errors); + } + } + + public static bool IsNonEmptyArray(this JToken json, out JArray array) + { + var isArray = _IsArray(json, out array); + return isArray && array.Count > 0; + } + + private static bool _IsArray(JToken json, out JArray array) + { + if (json != null && json.Type == JTokenType.Array) + { + array = (JArray)json; + return true; + } + array = default; + return false; + + } + } +} \ No newline at end of file diff --git a/RedditSharp/Helpers/Helpers.PopulateObjects.cs b/RedditSharp/Helpers/Helpers.PopulateObjects.cs new file mode 100644 index 00000000..df7dfb83 --- /dev/null +++ b/RedditSharp/Helpers/Helpers.PopulateObjects.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using RedditSharp.Interfaces; + +namespace RedditSharp +{ + public partial class Helpers + { + internal static List PopulateObjects(JToken json, IWebAgent webAgent) + where T : ISettableWebAgent, new() + { + if (json.Type != JTokenType.Array) + throw new ArgumentException("must be of type array", nameof(json)); + + var objects = new List(); + + for (var i = 0; i < json.Count(); i++) + { + var item = new T(); + PopulateObject(json[i], item); + item.WebAgent = webAgent; + objects.Add(item); + } + + return objects; + } + } +} \ No newline at end of file diff --git a/RedditSharp/Interfaces/ISettableWebAgent.cs b/RedditSharp/Interfaces/ISettableWebAgent.cs new file mode 100644 index 00000000..463b06c5 --- /dev/null +++ b/RedditSharp/Interfaces/ISettableWebAgent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace RedditSharp.Interfaces +{ + internal interface ISettableWebAgent + { + [JsonIgnore] + IWebAgent WebAgent { set; } + } +} \ No newline at end of file diff --git a/RedditSharp/Reddit.cs b/RedditSharp/Reddit.cs index e5cedfaa..dca55986 100644 --- a/RedditSharp/Reddit.cs +++ b/RedditSharp/Reddit.cs @@ -5,6 +5,9 @@ using System.Linq.Expressions; using System.Security.Authentication; using System.Threading.Tasks; +using RedditSharp.Data; +using RedditSharp.Data.Collections; +using RedditSharp.Extensions.JTokenExtensions; using DefaultWebAgent = RedditSharp.WebAgent; namespace RedditSharp @@ -477,5 +480,23 @@ public Listing GetListing(string url, int maxLimit = -1, int limitPerReque { return new Listing(this.WebAgent, url, maxLimit, limitPerRequest); } + + public async Task GetCollectionAsync(string collectionId, bool includePostsContent = true) + { + var json = await WebAgent.Get(Urls.Collections.Get(collectionId, includePostsContent)); + json.ThrowIfHasErrors("Could not retrieve the collection."); + return new Collection(json, WebAgent); + } + + /// + /// Deletes the specified collection. Must be a mod of the subreddit to delete. + /// + /// + /// + public async Task DeleteCollectionAsync(string collectionId) + { + var json = await WebAgent.Post(Urls.Collections.Delete, new CollectionBaseParams { CollectionId = collectionId }); + json.ThrowIfHasErrors("Could not delete the collection."); + } } } diff --git a/RedditSharp/RedditSharp.csproj b/RedditSharp/RedditSharp.csproj index 419f3cf4..fbbed94c 100755 --- a/RedditSharp/RedditSharp.csproj +++ b/RedditSharp/RedditSharp.csproj @@ -12,12 +12,12 @@ - + - + diff --git a/RedditSharp/Things/Collection.cs b/RedditSharp/Things/Collection.cs new file mode 100644 index 00000000..74a9d105 --- /dev/null +++ b/RedditSharp/Things/Collection.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RedditSharp.Data; +using RedditSharp.Data.Collections; +using RedditSharp.Extensions.JTokenExtensions; +using RedditSharp.Interfaces; + +namespace RedditSharp.Things +{ + public class Collection : ISettableWebAgent + { + [JsonProperty("subreddit_id")] + public string SubredditId { get; internal set; } + + [JsonProperty("description")] + public string Description { get; internal set; } + + [JsonProperty("author_name")] + public string AuthorName { get; internal set; } + + [JsonProperty("collection_id")] + public string CollectionId { get; internal set; } + + [JsonProperty("display_layout")] + public string DisplayLayout { get; internal set; } + + [JsonProperty("permalink")] + public string Permalink { get; internal set; } + + [JsonProperty("link_ids")] + public string[] LinkIds { get; internal set; } + + [JsonProperty("title")] + public string Title { get; internal set; } + + [JsonProperty("created_at_utc"), JsonConverter(typeof(UnixTimestampConverter))] + public DateTime CreatedAtUtc { get; internal set; } + + [JsonProperty("author_id")] + public string AuthorId { get; internal set; } + + [JsonProperty("last_update_utc"), JsonConverter(typeof(UnixTimestampConverter))] + public DateTime LastUpdateUtc { get; internal set; } + + public Post[] Posts { get; } + + public IWebAgent WebAgent { private get; set; } + + public Collection() + { + } + + public Collection(JToken json, IWebAgent agent) + { + WebAgent = agent; + + Helpers.PopulateObject(json, this); + + var posts = new List(); + var children = json.SelectToken("sorted_links.data.children"); + if (children != null && children.Type == JTokenType.Array) + { + posts.AddRange(children.Select(item => new Post(WebAgent, item))); + } + + Posts = posts.ToArray(); + } + + /// + /// Adds a post to the collection + /// + /// Full name of link, e.g. t3_xyz + public async Task AddPostAsync(string linkFullName) + { + var data = new AddOrRemovePostsFromCollectionParams + { + CollectionId = CollectionId, + LinkFullName = linkFullName, + }; + var json = await WebAgent.Post(Urls.Collections.AddPost, data); + json.ThrowIfHasErrors("Could not add post to collection."); + } + + /// + /// Removes a post from the collection + /// + /// Full name of link, e.g. t3_xyz + public async Task RemovePostAsync(string linkFullName) + { + var data = new AddOrRemovePostsFromCollectionParams + { + CollectionId = CollectionId, + LinkFullName = linkFullName, + }; + var json = await WebAgent.Post(Urls.Collections.RemovePost, data); + json.ThrowIfHasErrors("Could not remove post from collection."); + } + + public async Task DeleteAsync() + { + var json = await WebAgent.Post(Urls.Collections.Delete, null); + json.ThrowIfHasErrors("Could not remove collection."); + } + } +} \ No newline at end of file diff --git a/RedditSharp/Things/Subreddit.cs b/RedditSharp/Things/Subreddit.cs index c1e08afc..b8fc4f5d 100644 --- a/RedditSharp/Things/Subreddit.cs +++ b/RedditSharp/Things/Subreddit.cs @@ -6,6 +6,9 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using RedditSharp.Data; +using RedditSharp.Data.Collections; +using RedditSharp.Extensions.JTokenExtensions; namespace RedditSharp.Things { @@ -936,6 +939,28 @@ public Listing GetModerationLog(ModActionType action, IEnumerable.Create(WebAgent, url, max, 500); } + public async Task CreateCollectionAsync(string title, string description) + { + var data = new CollectionCreationParams + { + Subreddit = FullName, + Title = title, + Description = description, + }; + var json = await WebAgent.Post(Urls.Collections.CreateCollectionUrl, data).ConfigureAwait(false); + json.ThrowIfHasErrors("Could not create collection."); + var result = new Collection(json, WebAgent); + return result; + } + + public async Task> GetCollectionsAsync() + { + var json = await WebAgent.Get(Urls.Collections.SubredditCollectionsUrl(FullName)).ConfigureAwait(false); + json.ThrowIfHasErrors("Could not retrieve collections."); + var result = Helpers.PopulateObjects(json, WebAgent); + return result; + } + #region Static Operations public static async Task> GetModeratorsAsync(IWebAgent agent, string subreddit ) { diff --git a/RedditSharpTests/AuthenticatedTestsFixture.cs b/RedditSharpTests/AuthenticatedTestsFixture.cs index f51b7a36..3ea01d5d 100644 --- a/RedditSharpTests/AuthenticatedTestsFixture.cs +++ b/RedditSharpTests/AuthenticatedTestsFixture.cs @@ -12,7 +12,7 @@ public class AuthenticatedTestsFixture public AuthenticatedTestsFixture() { ConfigurationBuilder builder = new ConfigurationBuilder(); - builder.AddJsonFile("private.config",true) + builder.AddJsonFile("private.json",true) .AddEnvironmentVariables(); Config = builder.Build(); WebAgent = new RedditSharp.BotWebAgent(Config["TestUserName"], Config["TestUserPassword"], Config["RedditClientID"], Config["RedditClientSecret"], Config["RedditRedirectURI"]); diff --git a/RedditSharpTests/Collections/CollectionTests.cs b/RedditSharpTests/Collections/CollectionTests.cs new file mode 100644 index 00000000..33fbf1da --- /dev/null +++ b/RedditSharpTests/Collections/CollectionTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using RedditSharp; +using RedditSharp.Things; +using Retry; +using Xunit; + +namespace RedditSharpTests.Collections +{ + [Collection("AuthenticatedTests")] + public class CollectionTests + { + private readonly Reddit _reddit; + private readonly string _subredditName; + + public CollectionTests(AuthenticatedTestsFixture authenticatedFixture) + { + var authFixture = authenticatedFixture; + var agent = new WebAgent(authFixture.AccessToken); + _reddit = new Reddit(agent, true); + _subredditName = authFixture.Config["TestSubreddit"]; + } + + [SkippableFact] + public async Task CreatingACollection() + { + var guid = GenerateGuid(); + var currentDate = DateTime.UtcNow.AddMinutes(-2); + + var sub = await _reddit.GetSubredditAsync(_subredditName); + SkipIfNotModerator(sub); + + var title = $"A collection with no posts {guid}"; + var description = $"Collection description {GenerateGuid()}"; + + var result = await sub.CreateCollectionAsync(title, description); + + Assert.Equal(description, result.Description); + Assert.Equal(title, result.Title); + Assert.Equal(sub.FullName, result.SubredditId); + Assert.True(result.CreatedAtUtc >= currentDate); + Assert.True(result.LastUpdateUtc >= currentDate); + + var collections = await sub.GetCollectionsAsync(); + Assert.True(collections.Count >= 1, "there should be at least one collection"); + var collection = collections.FirstOrDefault(x => x.CollectionId == result.CollectionId); + Assert.NotNull(collection); + + await _reddit.DeleteCollectionAsync(collection.CollectionId); + } + + [SkippableFact] + public async Task CreatingACollectionAndAddingPosts() + { + var post1Guid = GenerateGuid(); + var post2Guid = GenerateGuid(); + var title = $"Collection of {post1Guid} and {post2Guid}"; + var description = $"Awesome new collection {GenerateGuid()}"; + + var sub = await _reddit.GetSubredditAsync(_subredditName); + SkipIfNotModerator(sub); + + var post1Task = sub.SubmitPostAsync($"Post {post1Guid}", "https://github.com/CrustyJew/RedditSharp", resubmit: true); + var post2Task = sub.SubmitTextPostAsync($"Post {post2Guid}", $"Post {post2Guid}"); + + var createCollectionTask = sub.CreateCollectionAsync(title, description); + + var post1 = await post1Task; + var post2 = await post2Task; + var collectionResult = await createCollectionTask; + + Assert.NotNull(post1); + Assert.NotNull(post2); + + var addPost1Task = collectionResult.AddPostAsync(post1.FullName); + var addPost2Task = collectionResult.AddPostAsync(post2.FullName); + + await addPost1Task; + await addPost2Task; + + var collection = await RetryHelper.Instance + .Try(() => _reddit.GetCollectionAsync(collectionResult.CollectionId)) + .WithTryInterval(TimeSpan.FromSeconds(0.5)) + .WithMaxTryCount(10) + .Until(c => c.LinkIds.Length > 1); + + Assert.Equal(2, collection.LinkIds.Length); + Assert.Contains(post1.FullName, collection.LinkIds); + Assert.Contains(post2.FullName, collection.LinkIds); + + Assert.Equal(2, collection.Posts.Length); + + var collectionWithLinkContent = await _reddit.GetCollectionAsync(collectionResult.CollectionId, includePostsContent: false); + + Assert.Empty(collectionWithLinkContent.Posts); + + await _reddit.DeleteCollectionAsync(collection.CollectionId); + } + + [Fact] + public async Task DeletingANonExistentCollection() + { + var exception = await Assert.ThrowsAsync(() => _reddit.DeleteCollectionAsync("00000000-0000-0000-1111-111111111111")); + Assert.Contains(exception.Errors, error => error[0].ToString().Equals("INVALID_COLLECTION_ID")); + } + + private void SkipIfNotModerator(Subreddit sub) + { + Skip.If(sub.UserIsModerator != true, $"User isn't a moderator of ${_subredditName} so a collection cannot be made."); + } + + private static string GenerateGuid() + { + return Guid.NewGuid().ToString("N").Substring(0, 5); + } + } +} \ No newline at end of file diff --git a/RedditSharpTests/RedditSharpTests.csproj b/RedditSharpTests/RedditSharpTests.csproj index 329244ef..0ff26325 100644 --- a/RedditSharpTests/RedditSharpTests.csproj +++ b/RedditSharpTests/RedditSharpTests.csproj @@ -1,13 +1,12 @@  - netcoreapp1.1 + netcoreapp2.2 RedditSharpTests RedditSharpTests true aspnet-RedditSharpTests - $(PackageTargetFallback);dotnet5.6;portable-net45+win8 - 1.1.1 + true @@ -15,28 +14,22 @@ - - + + - + - - + + + - + + - - - - - - - - - + PreserveNewest