diff --git a/Octokit.Reactive/Clients/ObservableRepositoriesClient.cs b/Octokit.Reactive/Clients/ObservableRepositoriesClient.cs index 2e3c02f6..2a8d8a89 100644 --- a/Octokit.Reactive/Clients/ObservableRepositoriesClient.cs +++ b/Octokit.Reactive/Clients/ObservableRepositoriesClient.cs @@ -1,18 +1,21 @@ using System; using System.Reactive; using System.Reactive.Threading.Tasks; +using Octokit.Reactive.Helpers; namespace Octokit.Reactive.Clients { public class ObservableRepositoriesClient : IObservableRepositoriesClient { readonly IRepositoriesClient _client; + readonly IConnection _connection; - public ObservableRepositoriesClient(IRepositoriesClient client) + public ObservableRepositoriesClient(IGitHubClient client) { Ensure.ArgumentNotNull(client, "client"); - _client = client; + _client = client.Repository; + _connection = client.Connection; } /// @@ -68,23 +71,23 @@ namespace Octokit.Reactive.Clients return _client.Get(owner, name).ToObservable(); } - public IObservable> GetAllForCurrent() + public IObservable GetAllForCurrent() { - return _client.GetAllForCurrent().ToObservable(); + return _connection.GetAndFlattenAllPages(ApiUrls.Repositories()); } - public IObservable> GetAllForUser(string login) + public IObservable GetAllForUser(string login) { Ensure.ArgumentNotNullOrEmptyString(login, "login"); - return _client.GetAllForUser(login).ToObservable(); + return _connection.GetAndFlattenAllPages(ApiUrls.Repositories(login)); } - public IObservable> GetAllForOrg(string organization) + public IObservable GetAllForOrg(string organization) { Ensure.ArgumentNotNullOrEmptyString(organization, "organization"); - return _client.GetAllForOrg(organization).ToObservable(); + return _connection.GetAndFlattenAllPages(ApiUrls.OrganizationRepositories(organization)); } public IObservable GetReadme(string owner, string name) diff --git a/Octokit.Reactive/Helpers/ConnectionExtensions.cs b/Octokit.Reactive/Helpers/ConnectionExtensions.cs new file mode 100644 index 00000000..612839dc --- /dev/null +++ b/Octokit.Reactive/Helpers/ConnectionExtensions.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; + +namespace Octokit.Reactive.Helpers +{ + internal static class ConnectionExtensions + { + public static IObservable GetAndFlattenAllPages(this IConnection connection, Uri url) + { + return GetPages(url, nextPageUrl => connection.GetAsync>(nextPageUrl).ToObservable()); + } + + static IObservable GetPages(Uri uri, + Func>>> getPageFunc) + { + return getPageFunc(uri).Expand(resp => + { + var nextPageUrl = resp.ApiInfo.GetNextPageUrl(); + return nextPageUrl == null + ? Observable.Empty>>() + : Observable.Defer(() => getPageFunc(nextPageUrl)); + }) + .Where(resp => resp != null) + .SelectMany(resp => resp.BodyAsObject); + } + } +} diff --git a/Octokit.Reactive/Helpers/ObservableExtensions.cs b/Octokit.Reactive/Helpers/ObservableExtensions.cs new file mode 100644 index 00000000..fbbe65b1 --- /dev/null +++ b/Octokit.Reactive/Helpers/ObservableExtensions.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; + +namespace Octokit.Reactive.Helpers +{ + public static class ObservableExtensions + { + /* + Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. + Microsoft Open Technologies would like to thank its contributors, a list + of whom are at http://rx.codeplex.com/wikipage?title=Contributors. + + Licensed 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. + */ + [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", + Justification = "Tell this to the Rx team")] + [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Tell this to the Rx team")] + internal static IObservable Expand( + this IObservable source, + Func> selector, + IScheduler scheduler) + { + return new AnonymousObservable(observer => + { + var outGate = new object(); + var q = new Queue>(); + var m = new SerialDisposable(); + var d = new CompositeDisposable {m}; + var activeCount = 0; + var isAcquired = false; + + var ensureActive = default(Action); + + ensureActive = () => + { + var isOwner = false; + + lock (q) + { + if (q.Count > 0) + { + isOwner = !isAcquired; + isAcquired = true; + } + } + + if (isOwner) + { + m.Disposable = scheduler.Schedule(self => + { + var work = default(IObservable); + + lock (q) + { + if (q.Count > 0) + work = q.Dequeue(); + else + { + isAcquired = false; + return; + } + } + + var m1 = new SingleAssignmentDisposable(); + d.Add(m1); + m1.Disposable = work.Subscribe( + x => + { + lock (outGate) + observer.OnNext(x); + + var result = default(IObservable); + try + { + result = selector(x); + } + catch (Exception exception) + { + lock (outGate) + observer.OnError(exception); + } + + lock (q) + { + q.Enqueue(result); + activeCount++; + } + + ensureActive(); + }, + exception => + { + lock (outGate) + observer.OnError(exception); + }, + () => + { + d.Remove(m1); + + var done = false; + lock (q) + { + activeCount--; + if (activeCount == 0) + done = true; + } + if (done) + lock (outGate) + observer.OnCompleted(); + }); + self(); + }); + } + }; + + lock (q) + { + q.Enqueue(source); + activeCount++; + } + ensureActive(); + + return d; + }); + } + + internal static IObservable Expand(this IObservable source, + Func> selector) + { + return source.Expand(selector, DefaultScheduler.Instance); + } + } +} diff --git a/Octokit.Reactive/IObservableRepositoriesClient.cs b/Octokit.Reactive/IObservableRepositoriesClient.cs index 710a6281..88059b19 100644 --- a/Octokit.Reactive/IObservableRepositoriesClient.cs +++ b/Octokit.Reactive/IObservableRepositoriesClient.cs @@ -49,7 +49,7 @@ namespace Octokit.Reactive /// A of . [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "Makes a network request")] - IObservable> GetAllForCurrent(); + IObservable GetAllForCurrent(); /// /// Retrieves every that belongs to the specified user. @@ -60,7 +60,7 @@ namespace Octokit.Reactive /// A of . [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "Makes a network request")] - IObservable> GetAllForUser(string login); + IObservable GetAllForUser(string login); /// /// Retrieves every that belongs to the specified organization. @@ -71,7 +71,7 @@ namespace Octokit.Reactive /// A of . [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "Makes a network request")] - IObservable> GetAllForOrg(string organization); + IObservable GetAllForOrg(string organization); /// /// Returns the HTML rendered README. diff --git a/Octokit.Reactive/ObservableGitHubClient.cs b/Octokit.Reactive/ObservableGitHubClient.cs index 611f976a..94be6850 100644 --- a/Octokit.Reactive/ObservableGitHubClient.cs +++ b/Octokit.Reactive/ObservableGitHubClient.cs @@ -15,7 +15,7 @@ namespace Octokit.Reactive Authorization = new ObservableAuthorizationsClient(gitHubClient.Authorization); Miscellaneous = new ObservableMiscellaneousClient(gitHubClient.Miscellaneous); Organization = new ObservableOrganizationsClient(gitHubClient.Organization); - Repository = new ObservableRepositoriesClient(gitHubClient.Repository); + Repository = new ObservableRepositoriesClient(gitHubClient); SshKey = new ObservableSshKeysClient(gitHubClient.SshKey); User = new ObservableUsersClient(gitHubClient.User); } diff --git a/Octokit.Reactive/Octokit.Reactive.csproj b/Octokit.Reactive/Octokit.Reactive.csproj index 637f7c51..403d321a 100644 --- a/Octokit.Reactive/Octokit.Reactive.csproj +++ b/Octokit.Reactive/Octokit.Reactive.csproj @@ -90,6 +90,8 @@ + + diff --git a/Octokit.Tests/Clients/RepositoriesClientTests.cs b/Octokit.Tests/Clients/RepositoriesClientTests.cs index 23606186..77aac4e5 100644 --- a/Octokit.Tests/Clients/RepositoriesClientTests.cs +++ b/Octokit.Tests/Clients/RepositoriesClientTests.cs @@ -2,7 +2,6 @@ using System; using System.Text; using System.Threading.Tasks; using NSubstitute; -using Octokit.Internal; using Octokit.Tests.Helpers; using Xunit; @@ -71,14 +70,16 @@ namespace Octokit.Tests.Clients } [Fact] - public async Task UsesTheUserReposUrl() + public async Task UsesTheOrganizatinosReposUrl() { var client = Substitute.For(); var repositoriesClient = new RepositoriesClient(client); await repositoriesClient.Create("theLogin", new NewRepository { Name = "aName" }); - client.Received().Post(Arg.Is(u => u.ToString() == "orgs/theLogin/repos"), Arg.Any()); + client.Received().Post( + Arg.Is(u => u.ToString() == "/orgs/theLogin/repos"), + Args.NewRepository); } [Fact] diff --git a/Octokit.Tests/Helpers/Arg.cs b/Octokit.Tests/Helpers/Arg.cs index 184856f2..d1a3d87c 100644 --- a/Octokit.Tests/Helpers/Arg.cs +++ b/Octokit.Tests/Helpers/Arg.cs @@ -40,5 +40,10 @@ namespace Octokit.Tests { get { return Arg.Any(); } } + + public static NewRepository NewRepository + { + get { return Arg.Any(); } + } } } diff --git a/Octokit.Tests/Octokit.Tests.csproj b/Octokit.Tests/Octokit.Tests.csproj index 2404a825..ea073d6e 100644 --- a/Octokit.Tests/Octokit.Tests.csproj +++ b/Octokit.Tests/Octokit.Tests.csproj @@ -89,6 +89,7 @@ + diff --git a/Octokit.Tests/Reactive/ObservableRepositoriesClientTests.cs b/Octokit.Tests/Reactive/ObservableRepositoriesClientTests.cs new file mode 100644 index 00000000..1c1ac5ef --- /dev/null +++ b/Octokit.Tests/Reactive/ObservableRepositoriesClientTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading.Tasks; +using NSubstitute; +using Octokit.Internal; +using Octokit.Reactive.Clients; +using Xunit; + +namespace Octokit.Tests.Reactive +{ + public class ObservableRepositoriesClientTests + { + public class TheGetAllForCurrentMethod + { + [Fact] + public void ReturnsEveryPageOfRepositories() + { + var firstPageUrl = new Uri("user/repos", UriKind.Relative); + var secondPageUrl = new Uri("https://example.com/page/2"); + var firstPageLinks = new Dictionary {{"next", secondPageUrl}}; + var scopes = new List(); + var firstPageResponse = new ApiResponse> + { + BodyAsObject = new List + { + new Repository {Id = 1}, + new Repository {Id = 2}, + new Repository {Id = 3} + }, + ApiInfo = new ApiInfo(firstPageLinks, scopes, scopes, "etag", 100, 100) + }; + var thirdPageUrl = new Uri("https://example.com/page/3"); + var secondPageLinks = new Dictionary {{"next", thirdPageUrl}}; + var secondPageResponse = new ApiResponse> + { + BodyAsObject = new List + { + new Repository {Id = 4}, + new Repository {Id = 5}, + new Repository {Id = 6} + }, + ApiInfo = new ApiInfo(secondPageLinks, scopes, scopes, "etag", 100, 100) + }; + var lastPageResponse = new ApiResponse> + { + BodyAsObject = new List + { + new Repository {Id = 7} + }, + ApiInfo = new ApiInfo(new Dictionary(), scopes, scopes, "etag", 100, 100) + }; + var gitHubClient = Substitute.For(); + gitHubClient.Connection.GetAsync>(firstPageUrl) + .Returns(Task.Factory.StartNew>>(() => firstPageResponse)); + gitHubClient.Connection.GetAsync>(secondPageUrl) + .Returns(Task.Factory.StartNew>>(() => secondPageResponse)); + gitHubClient.Connection.GetAsync>(thirdPageUrl) + .Returns(Task.Factory.StartNew>>(() => lastPageResponse)); + var repositoriesClient = new ObservableRepositoriesClient(gitHubClient); + + var results = repositoriesClient.GetAllForCurrent().ToArray().Wait(); + + Assert.Equal(7, results.Length); + gitHubClient.Connection.Received(1).GetAsync>(firstPageUrl, null, null); + gitHubClient.Connection.Received(1).GetAsync>(secondPageUrl, null, null); + gitHubClient.Connection.Received(1).GetAsync>(thirdPageUrl, null, null); + } + + [Fact] + public void StopsMakingNewRequestsWhenTakeIsFulfilled() + { + var firstPageUrl = new Uri("user/repos", UriKind.Relative); + var secondPageUrl = new Uri("https://example.com/page/2"); + var firstPageLinks = new Dictionary { { "next", secondPageUrl } }; + var scopes = new List(); + var firstPageResponse = new ApiResponse> + { + BodyAsObject = new List + { + new Repository {Id = 1}, + new Repository {Id = 2}, + new Repository {Id = 3} + }, + ApiInfo = new ApiInfo(firstPageLinks, scopes, scopes, "etag", 100, 100) + }; + var thirdPageUrl = new Uri("https://example.com/page/3"); + var secondPageLinks = new Dictionary { { "next", thirdPageUrl } }; + var secondPageResponse = new ApiResponse> + { + BodyAsObject = new List + { + new Repository {Id = 4}, + new Repository {Id = 5}, + new Repository {Id = 6} + }, + ApiInfo = new ApiInfo(secondPageLinks, scopes, scopes, "etag", 100, 100) + }; + var fourthPageUrl = new Uri("https://example.com/page/4"); + var thirdPageLinks = new Dictionary { { "next", fourthPageUrl } }; + var thirdPageResponse = new ApiResponse> + { + BodyAsObject = new List + { + new Repository {Id = 7} + }, + ApiInfo = new ApiInfo(thirdPageLinks, scopes, scopes, "etag", 100, 100) + }; + var lastPageResponse = new ApiResponse> + { + BodyAsObject = new List + { + new Repository {Id = 8} + }, + ApiInfo = new ApiInfo(new Dictionary(), scopes, scopes, "etag", 100, 100) + }; + var gitHubClient = Substitute.For(); + gitHubClient.Connection.GetAsync>(firstPageUrl) + .Returns(Task.Factory.StartNew>>(() => firstPageResponse)); + gitHubClient.Connection.GetAsync>(secondPageUrl) + .Returns(Task.Factory.StartNew>>(() => secondPageResponse)); + gitHubClient.Connection.GetAsync>(thirdPageUrl) + .Returns(Task.Factory.StartNew>>(() => thirdPageResponse)); + gitHubClient.Connection.GetAsync>(fourthPageUrl) + .Returns(Task.Factory.StartNew>>(() => lastPageResponse)); + var repositoriesClient = new ObservableRepositoriesClient(gitHubClient); + + var results = repositoriesClient.GetAllForCurrent().Take(4).ToArray().Wait(); + + Assert.Equal(4, results.Length); + gitHubClient.Connection.Received(1).GetAsync>(firstPageUrl, null, null); + gitHubClient.Connection.Received(1).GetAsync>(secondPageUrl, null, null); + gitHubClient.Connection.Received(0).GetAsync>(thirdPageUrl, null, null); + gitHubClient.Connection.Received(0).GetAsync>(fourthPageUrl, null, null); + } + } + } +} diff --git a/Octokit/Clients/RepositoriesClient.cs b/Octokit/Clients/RepositoriesClient.cs index db9b627e..713bfa87 100644 --- a/Octokit/Clients/RepositoriesClient.cs +++ b/Octokit/Clients/RepositoriesClient.cs @@ -23,8 +23,7 @@ namespace Octokit if (string.IsNullOrEmpty(newRepository.Name)) throw new ArgumentException("The new repository's name must not be null."); - var endpoint = new Uri("user/repos", UriKind.Relative); - return await Client.Post(endpoint, newRepository); + return await Client.Post(ApiUrls.Repositories(), newRepository); } /// @@ -40,8 +39,7 @@ namespace Octokit if (string.IsNullOrEmpty(newRepository.Name)) throw new ArgumentException("The new repository's name must not be null."); - var endpoint = "orgs/{0}/repos".FormatUri(organizationLogin); - return await Client.Post(endpoint, newRepository); + return await Client.Post(ApiUrls.OrganizationRepositories(organizationLogin), newRepository); } /// @@ -70,26 +68,21 @@ namespace Octokit public async Task> GetAllForCurrent() { - var endpoint = new Uri("user/repos", UriKind.Relative); - return await Client.GetAll(endpoint); + return await Client.GetAll(ApiUrls.Repositories()); } public async Task> GetAllForUser(string login) { Ensure.ArgumentNotNullOrEmptyString(login, "login"); - var endpoint = "/users/{0}/repos".FormatUri(login); - - return await Client.GetAll(endpoint); + return await Client.GetAll(ApiUrls.Repositories(login)); } public async Task> GetAllForOrg(string organization) { Ensure.ArgumentNotNullOrEmptyString(organization, "organization"); - var endpoint = "/orgs/{0}/repos".FormatUri(organization); - - return await Client.GetAll(endpoint); + return await Client.GetAll(ApiUrls.OrganizationRepositories(organization)); } public async Task GetReadme(string owner, string name) diff --git a/Octokit/Models/Account.cs b/Octokit/Models/Account.cs index 185c6c68..6be5097d 100644 --- a/Octokit/Models/Account.cs +++ b/Octokit/Models/Account.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics; +using System.Globalization; namespace Octokit { diff --git a/Octokit/Models/Organization.cs b/Octokit/Models/Organization.cs index d17edff6..a4e6afd6 100644 --- a/Octokit/Models/Organization.cs +++ b/Octokit/Models/Organization.cs @@ -1,5 +1,10 @@ +using System; +using System.Diagnostics; +using System.Globalization; + namespace Octokit { + [DebuggerDisplay("{DebuggerDisplay,nq}")] public class Organization : Account { /// @@ -7,5 +12,14 @@ namespace Octokit /// an organization. /// public string BillingAddress { get; set; } + + internal string DebuggerDisplay + { + get + { + return String.Format(CultureInfo.InvariantCulture, + "Organization: Id: {0} Login: {1}", Id, Login); + } + } } } \ No newline at end of file diff --git a/Octokit/Models/Repository.cs b/Octokit/Models/Repository.cs index 393e1178..fee66e02 100644 --- a/Octokit/Models/Repository.cs +++ b/Octokit/Models/Repository.cs @@ -1,7 +1,10 @@ using System; +using System.Diagnostics; +using System.Globalization; namespace Octokit { + [DebuggerDisplay("{DebuggerDisplay,nq}")] public class Repository { public string Url { get; set; } @@ -34,5 +37,14 @@ namespace Octokit public bool HasIssues { get; set; } public bool HasWiki { get; set; } public bool HasDownloads { get; set; } + + internal string DebuggerDisplay + { + get + { + return String.Format(CultureInfo.InvariantCulture, + "Repository: Id: {0} Owner: {1}, Name: {2}", Id, Name, Owner); + } + } } } \ No newline at end of file diff --git a/Octokit/Models/User.cs b/Octokit/Models/User.cs index e79fcb00..5e55bf35 100644 --- a/Octokit/Models/User.cs +++ b/Octokit/Models/User.cs @@ -1,8 +1,13 @@ +using System; +using System.Diagnostics; +using System.Globalization; + namespace Octokit { /// /// Represents a user on GitHub. /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] public class User : Account { /// @@ -14,5 +19,14 @@ namespace Octokit /// Whether or not the user is an administrator of the site /// public bool SiteAdmin { get; set; } + + internal string DebuggerDisplay + { + get + { + return String.Format(CultureInfo.InvariantCulture, + "User: Id: {0} Login: {1}", Id, Login); + } + } } } \ No newline at end of file