diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da3704a --- /dev/null +++ b/.gitignore @@ -0,0 +1,293 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +tools/** +!tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + + + + +/coverage.xml \ No newline at end of file diff --git a/Assets/icon.png b/Assets/icon.png new file mode 100644 index 0000000..2d5f498 Binary files /dev/null and b/Assets/icon.png differ diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..b4c554a --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,4 @@ +mode: Mainline +branches: {} +ignore: + sha: [] \ No newline at end of file diff --git a/LICENSE.MD b/LICENSE.MD new file mode 100644 index 0000000..cf1ab25 --- /dev/null +++ b/LICENSE.MD @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/PinSharp.Tests/Api/Exceptions/PinSharpExceptionsTest.cs b/PinSharp.Tests/Api/Exceptions/PinSharpExceptionsTest.cs new file mode 100644 index 0000000..bdcda8a --- /dev/null +++ b/PinSharp.Tests/Api/Exceptions/PinSharpExceptionsTest.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using PinSharp.Api.Exceptions; +using Xunit; + +namespace PinSharp.Tests.Api.Exceptions +{ + public class PinSharpExceptionsTest + { + [Fact] + public void Create_HttpStatusCode_ShouldBeSetToPassedValue() + { + var expectedStatusCode = 500; + + var exception = PinSharpException.Create(null, null, null, expectedStatusCode); + + exception.HttpStatusCode.Should().Be(expectedStatusCode); + } + + [Fact] + public void Create_HttpStatusCode_NullValueShouldNotOverwriteExceptionValue() + { + var expectedStatusCode = MockException.DefaultStatusCode; + + var exception = PinSharpException.Create(null, null, null, null); + + exception.HttpStatusCode.Should().Be(expectedStatusCode); + } + + public class MockException : PinSharpException + { + public const int DefaultStatusCode = 400; + + public MockException(string message) : base(message) + { + HttpStatusCode = DefaultStatusCode; + } + } + } +} diff --git a/PinSharp.Tests/PathBuilderTests.cs b/PinSharp.Tests/PathBuilderTests.cs new file mode 100644 index 0000000..7efd9cc --- /dev/null +++ b/PinSharp.Tests/PathBuilderTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using PinSharp.Api; +using Xunit; + +namespace PinSharp.Tests +{ + public class PathBuilderTests + { + [Fact] + public void BuildPath_Ensures_Trailing_Forward_Slash_On_Path() + { + var pathWithoutTrailingSlash = PathBuilder.BuildPath("some/path", null); + var pathWithTrailingSlash = PathBuilder.BuildPath("some/path/", null); + + pathWithoutTrailingSlash.Should().Be("some/path/"); + pathWithTrailingSlash.Should().Be("some/path/"); + } + + [Fact] + public void BuildPath_Adds_Query_Parameter() + { + var query = "search query"; + var path = PathBuilder.BuildPath("some/path", new RequestOptions(query)); + + path.Should().Be("some/path/?query=search query"); + } + + [Fact] + public void BuildPath_Adds_Fields_Parameter() + { + var fields = new[] { "field1", "field2(f1,f2,f3)" }; + var path = PathBuilder.BuildPath("some/path", new RequestOptions(fields)); + + path.Should().Be("some/path/?fields=field1,field2(f1,f2,f3)"); + } + + [Fact] + public void BuildPath_Adds_Cursor_Parameter() + { + var cursor = "abcdefg"; + var path = PathBuilder.BuildPath("some/path", new RequestOptions {Cursor = cursor}); + + path.Should().Be("some/path/?cursor=abcdefg"); + } + + [Fact] + public void BuildPath_Adds_Limit_Parameter_Above_Zero() + { + var limit = 10; + var path = PathBuilder.BuildPath("some/path", new RequestOptions {Limit = limit}); + + path.Should().Be("some/path/?limit=10"); + } + + [Fact] + public void BuildPath_Does_Not_Add_Limit_Parameter_Zero() + { + var limit = 0; + var path = PathBuilder.BuildPath("some/path", new RequestOptions {Limit = limit}); + + path.Should().Be("some/path/"); + } + + [Fact] + public void BuildPath_Adds_Query_Fields_Cursor_And_Limit_Parameters() + { + var query = "search query"; + var fields = new[] {"field1", "field2(f1,f2,f3)"}; + var cursor = "abcdefg"; + var limit = 10; + var path = PathBuilder.BuildPath("some/path", new RequestOptions(query, fields, cursor, limit)); + + path.Should().Be("some/path/?query=search query&fields=field1,field2(f1,f2,f3)&cursor=abcdefg&limit=10"); + } + } +} diff --git a/PinSharp.Tests/PinSharp.Tests.csproj b/PinSharp.Tests/PinSharp.Tests.csproj new file mode 100644 index 0000000..3c9316e --- /dev/null +++ b/PinSharp.Tests/PinSharp.Tests.csproj @@ -0,0 +1,32 @@ + + + net8.0 + + + + PreserveNewest + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + \ No newline at end of file diff --git a/PinSharp.Tests/app.config b/PinSharp.Tests/app.config new file mode 100644 index 0000000..195db1f --- /dev/null +++ b/PinSharp.Tests/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/PinSharp.sln b/PinSharp.sln new file mode 100644 index 0000000..ae94c3b --- /dev/null +++ b/PinSharp.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26730.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution files", "Solution files", "{B75C688B-8995-429E-9680-59D391B89792}" + ProjectSection(SolutionItems) = preProject + .gitattributes = .gitattributes + .gitignore = .gitignore + build.cake = build.cake + build.ps1 = build.ps1 + GitVersion.yml = GitVersion.yml + LICENSE.MD = LICENSE.MD + README.md = README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PinSharp", "PinSharp\PinSharp.csproj", "{DBF435BD-4C1F-403C-BF81-B9017DCE88E3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PinSharp.Tests", "PinSharp.Tests\PinSharp.Tests.csproj", "{B3374B25-BD99-4DFB-A874-980CBF462153}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DBF435BD-4C1F-403C-BF81-B9017DCE88E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBF435BD-4C1F-403C-BF81-B9017DCE88E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBF435BD-4C1F-403C-BF81-B9017DCE88E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBF435BD-4C1F-403C-BF81-B9017DCE88E3}.Release|Any CPU.Build.0 = Release|Any CPU + {B3374B25-BD99-4DFB-A874-980CBF462153}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3374B25-BD99-4DFB-A874-980CBF462153}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3374B25-BD99-4DFB-A874-980CBF462153}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3374B25-BD99-4DFB-A874-980CBF462153}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {ACAF897C-55F8-424B-8121-3E4CC1D10FD9} + EndGlobalSection +EndGlobal diff --git a/PinSharp/Api/Exceptions/PinSharpAuthorizationException.cs b/PinSharp/Api/Exceptions/PinSharpAuthorizationException.cs new file mode 100644 index 0000000..5b667a8 --- /dev/null +++ b/PinSharp/Api/Exceptions/PinSharpAuthorizationException.cs @@ -0,0 +1,11 @@ +namespace PinSharp.Api.Exceptions +{ + public class PinSharpAuthorizationException : PinSharpException + { + public PinSharpAuthorizationException(string message) + : base(message) + { + HttpStatusCode = 401; + } + } +} diff --git a/PinSharp/Api/Exceptions/PinSharpBadRequestException.cs b/PinSharp/Api/Exceptions/PinSharpBadRequestException.cs new file mode 100644 index 0000000..1997b39 --- /dev/null +++ b/PinSharp/Api/Exceptions/PinSharpBadRequestException.cs @@ -0,0 +1,13 @@ +using System; + +namespace PinSharp.Api.Exceptions +{ + public class PinSharpBadRequestException : PinSharpException + { + public PinSharpBadRequestException(string message) + : base(message) + { + HttpStatusCode = 400; + } + } +} diff --git a/PinSharp/Api/Exceptions/PinSharpException.cs b/PinSharp/Api/Exceptions/PinSharpException.cs new file mode 100644 index 0000000..3ab8f0f --- /dev/null +++ b/PinSharp/Api/Exceptions/PinSharpException.cs @@ -0,0 +1,43 @@ +using System; + +namespace PinSharp.Api.Exceptions +{ + public class PinSharpException : Exception + { + public int? HttpStatusCode { get; internal set; } + + public string RequestUrl { get; internal set; } + + public string ResponseContent { get; internal set; } + + public PinSharpException() + { + + } + + public PinSharpException(string message) + : base(message) + { + + } + + public PinSharpException(string message, Exception inner) + : base(message, inner) + { + + } + + internal static T Create(string message, string requestUrl, string responseContent, int? httpStatusCode = null) + where T : PinSharpException + { + var exception = (T) Activator.CreateInstance(typeof (T), message); + exception.RequestUrl = requestUrl; + exception.ResponseContent = responseContent; + + if (httpStatusCode != null) + exception.HttpStatusCode = httpStatusCode; + + return exception; + } + } +} diff --git a/PinSharp/Api/Exceptions/PinSharpForbiddenException.cs b/PinSharp/Api/Exceptions/PinSharpForbiddenException.cs new file mode 100644 index 0000000..7c8c9e5 --- /dev/null +++ b/PinSharp/Api/Exceptions/PinSharpForbiddenException.cs @@ -0,0 +1,11 @@ +namespace PinSharp.Api.Exceptions +{ + public class PinSharpForbiddenException : PinSharpException + { + public PinSharpForbiddenException(string message) + : base(message) + { + HttpStatusCode = 403; + } + } +} diff --git a/PinSharp/Api/Exceptions/PinSharpNotFoundException.cs b/PinSharp/Api/Exceptions/PinSharpNotFoundException.cs new file mode 100644 index 0000000..4682b54 --- /dev/null +++ b/PinSharp/Api/Exceptions/PinSharpNotFoundException.cs @@ -0,0 +1,11 @@ +namespace PinSharp.Api.Exceptions +{ + public class PinSharpNotFoundException : PinSharpException + { + public PinSharpNotFoundException(string message) + : base(message) + { + HttpStatusCode = 404; + } + } +} diff --git a/PinSharp/Api/Exceptions/PinSharpRateLimitExceededException.cs b/PinSharp/Api/Exceptions/PinSharpRateLimitExceededException.cs new file mode 100644 index 0000000..54557ac --- /dev/null +++ b/PinSharp/Api/Exceptions/PinSharpRateLimitExceededException.cs @@ -0,0 +1,24 @@ +using System; + +namespace PinSharp.Api.Exceptions +{ + public class PinSharpRateLimitExceededException : PinSharpException + { + public PinSharpRateLimitExceededException() + { + HttpStatusCode = 429; + } + + public PinSharpRateLimitExceededException(string message) + : base(message) + { + HttpStatusCode = 429; + } + + public PinSharpRateLimitExceededException(string message, Exception inner) + : base(message, inner) + { + HttpStatusCode = 429; + } + } +} diff --git a/PinSharp/Api/Exceptions/PinSharpServerErrorException.cs b/PinSharp/Api/Exceptions/PinSharpServerErrorException.cs new file mode 100644 index 0000000..934210a --- /dev/null +++ b/PinSharp/Api/Exceptions/PinSharpServerErrorException.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PinSharp.Api.Exceptions +{ + public class PinSharpServerErrorException : PinSharpException + { + public PinSharpServerErrorException() + { + + } + + public PinSharpServerErrorException(string message) + : base(message) + { + + } + + public PinSharpServerErrorException(string message, Exception inner) + : base(message, inner) + { + + } + } +} diff --git a/PinSharp/Api/Exceptions/PinSharpTimeoutException.cs b/PinSharp/Api/Exceptions/PinSharpTimeoutException.cs new file mode 100644 index 0000000..bc07314 --- /dev/null +++ b/PinSharp/Api/Exceptions/PinSharpTimeoutException.cs @@ -0,0 +1,24 @@ +using System; + +namespace PinSharp.Api.Exceptions +{ + public class PinSharpTimeoutException : PinSharpException + { + public PinSharpTimeoutException() + { + HttpStatusCode = 408; + } + + public PinSharpTimeoutException(string message) + : base(message) + { + HttpStatusCode = 408; + } + + public PinSharpTimeoutException(string message, Exception inner) + : base(message, inner) + { + HttpStatusCode = 408; + } + } +} diff --git a/PinSharp/Api/IBoardsApi.cs b/PinSharp/Api/IBoardsApi.cs new file mode 100644 index 0000000..7eb71f6 --- /dev/null +++ b/PinSharp/Api/IBoardsApi.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using PinSharp.Api.Responses; +using PinSharp.Models; + +namespace PinSharp.Api +{ + public interface IBoardsApi + { + Task GetBoardAsync(string board); + Task GetBoardAsync(string board, IEnumerable fields); + + Task> GetPinsAsync(string board); + Task> GetPinsAsync(string board, int limit); + Task> GetPinsAsync(string board, string cursor); + Task> GetPinsAsync(string board, string cursor, int limit); + + Task> GetPinsAsync(string board, IEnumerable fields); + Task> GetPinsAsync(string board, IEnumerable fields, int limit); + Task> GetPinsAsync(string board, IEnumerable fields, string cursor); + Task> GetPinsAsync(string board, IEnumerable fields, string cursor, int limit); + + Task CreateBoardAsync(string name, string description = null); + Task UpdateBoardAsync(string board, string name, string description = null); + Task DeleteBoardAsync(string board); + } +} \ No newline at end of file diff --git a/PinSharp/Api/IMeApi.cs b/PinSharp/Api/IMeApi.cs new file mode 100644 index 0000000..3489cc6 --- /dev/null +++ b/PinSharp/Api/IMeApi.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using PinSharp.Api.Responses; +using PinSharp.Models; + +namespace PinSharp.Api +{ + public interface IMeApi + { + /// + /// Returns information about the user linked with the used access token. + /// + /// + Task GetUserAsync(); + Task> GetBoardsAsync(); + + Task> GetPinsAsync(string cursor = null, int limit = 0); + Task> GetFollowersAsync(string cursor = null, int limit = 0); + Task> GetSuggestedBoardsAsync(string cursor = null, int limit = 0); + Task> GetSuggestedBoardsAsync(string pinId, string cursor = null, int limit = 0); + Task> GetFollowingBoardsAsync(string cursor = null, int limit = 0); + Task> GetFollowingInterestsAsync(string cursor = null, int limit = 0); + Task> GetFollowingUsersAsync(string cursor = null, int limit = 0); + Task> SearchBoardsAsync(string query, string cursor = null, int limit = 0); + Task> SearchPinsAsync(string query, string cursor = null, int limit = 0); + + Task FollowBoardAsync(string board); + Task UnfollowBoardAsync(string board); + Task FollowUserAsync(string user); + Task UnfollowUserAsync(string user); + } +} \ No newline at end of file diff --git a/PinSharp/Api/IPinsApi.cs b/PinSharp/Api/IPinsApi.cs new file mode 100644 index 0000000..9d38328 --- /dev/null +++ b/PinSharp/Api/IPinsApi.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using PinSharp.Models; + +namespace PinSharp.Api +{ + public interface IPinsApi + { + Task GetPinAsync(string id, IEnumerable fields); + Task GetPinAsync(string id); + + /// + /// + /// + /// The board ID or slug (user/board-name) + /// + /// + /// + /// + Task CreatePinAsync(string board, string imageUrl, string note, string link = null); + + /// + /// + /// + /// The board ID or slug (user/board-name) + /// + /// + /// + /// + Task CreatePinFromBase64Async(string board, string imageBase64, string note, string link = null); + + /// + /// + /// + /// + /// The new board ID or slug (user/board-name) + /// + /// + /// + Task UpdatePinAsync(string id, string board, string note, string link); + + Task DeletePinAsync(string id); + } +} \ No newline at end of file diff --git a/PinSharp/Api/IRateLimits.cs b/PinSharp/Api/IRateLimits.cs new file mode 100644 index 0000000..c416fb3 --- /dev/null +++ b/PinSharp/Api/IRateLimits.cs @@ -0,0 +1,11 @@ +using System; + +namespace PinSharp.Api +{ + public interface IRateLimits + { + int Limit { get; } + int Remaining { get; } + DateTimeOffset LastUpdated { get; } + } +} \ No newline at end of file diff --git a/PinSharp/Api/IUsersApi.cs b/PinSharp/Api/IUsersApi.cs new file mode 100644 index 0000000..cc55e7f --- /dev/null +++ b/PinSharp/Api/IUsersApi.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using PinSharp.Models; + +namespace PinSharp.Api +{ + public interface IUsersApi + { + Task GetUserAsync(string userName, IEnumerable fields); + Task GetUserAsync(string userName); + } +} \ No newline at end of file diff --git a/PinSharp/Api/PathBuilder.cs b/PinSharp/Api/PathBuilder.cs new file mode 100644 index 0000000..3940a51 --- /dev/null +++ b/PinSharp/Api/PathBuilder.cs @@ -0,0 +1,58 @@ +using System.Linq; +using PinSharp.Extensions; + +namespace PinSharp.Api +{ + internal class PathBuilder + { + public static string BuildPath(string basePath, RequestOptions options) + { + var path = basePath; + + if (!path.EndsWith("/")) + path += "/"; + + if (options?.SearchQuery != null) + path = path.AddQueryParam("query", options.SearchQuery); + + if (options?.Fields?.Any() == true) + { + var fields = string.Join(",", options.Fields); + path = path.AddQueryParam("fields", fields); + } + + if (options?.Cursor != null) + path = path.AddQueryParam("cursor", options.Cursor); + + if (options?.Limit > 0) + path = path.AddQueryParam("limit", options.Limit); + + if (options?.CustomData != null) + { + foreach (var prop in options.CustomData.GetType().GetProperties()) + { + var value = prop.GetValue(options.CustomData); + if (value == null) + continue; + if (value is int && (int) value == 0) + continue; + + var key = prop.Name.ToLower(); + path = path.AddQueryParam(key, value); + } + } + + return path; + } + } + + internal static class QueryStringExtensions + { + public static string AddQueryParam(this string original, string name, object value) + { + original += original.Contains("?") ? "&" : "?"; + original += $"{name}={value}"; + return original; + } + } +} diff --git a/PinSharp/Api/PinterestApi.Boards.cs b/PinSharp/Api/PinterestApi.Boards.cs new file mode 100644 index 0000000..60b812a --- /dev/null +++ b/PinSharp/Api/PinterestApi.Boards.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using PinSharp.Api.Responses; +using PinSharp.Models; + +namespace PinSharp.Api +{ + internal partial class PinterestApi : IBoardsApi + { + public Task GetBoardAsync(string board) + { + return GetBoardAsync(board, BoardFields); + } + + public Task GetBoardAsync(string board, IEnumerable fields) + { + return GetAsync($"boards/{board}", new RequestOptions(fields)); + } + + public Task> GetPinsAsync(string board) + { + return GetPinsAsync(board, PinFields, null, 0); + } + + public Task> GetPinsAsync(string board, int limit) + { + return GetPinsAsync(board, PinFields, null, limit); + } + + public Task> GetPinsAsync(string board, string cursor) + { + return GetPinsAsync(board, PinFields, cursor, 0); + } + + public Task> GetPinsAsync(string board, string cursor, int limit) + { + return GetPinsAsync(board, PinFields, cursor, limit); + } + + public Task> GetPinsAsync(string board, IEnumerable fields) + { + return GetPinsAsync(board, fields, null, 0); + } + + public Task> GetPinsAsync(string board, IEnumerable fields, int limit) + { + return GetPinsAsync(board, fields, null, limit); + } + + public Task> GetPinsAsync(string board, IEnumerable fields, string cursor) + { + return GetPinsAsync(board, fields, cursor, 0); + } + + public Task> GetPinsAsync(string board, IEnumerable fields, string cursor, int limit) + { + var responseTask = GetPagedAsync($"boards/{board}/pins", new RequestOptions(fields, cursor, limit)); + return PagedResponse.FromTask(responseTask); + } + + public Task CreateBoardAsync(string name, string description = null) + { + return PostAsync("boards", new {name, description}, new RequestOptions(BoardFields)); + } + + public Task UpdateBoardAsync(string board, string name, string description = null) + { + return PatchAsync($"boards/{board}", new {board, name, description}, new RequestOptions(BoardFields)); + } + + public Task DeleteBoardAsync(string board) + { + return DeleteAsync($"boards/{board}"); + } + } +} diff --git a/PinSharp/Api/PinterestApi.Me.cs b/PinSharp/Api/PinterestApi.Me.cs new file mode 100644 index 0000000..0956bad --- /dev/null +++ b/PinSharp/Api/PinterestApi.Me.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PinSharp.Api.Responses; +using PinSharp.Models; + +namespace PinSharp.Api +{ + internal partial class PinterestApi : IMeApi + { + public Task GetUserAsync() + { + return GetAsync("me", new RequestOptions(UserFields)); + } + + public Task> GetBoardsAsync() + { + return GetAsync>("me/boards", new RequestOptions(BoardFields)); + } + + Task> IMeApi.GetPinsAsync(string cursor, int limit) + { + var responseTask = GetPagedAsync("me/pins", new RequestOptions(PinFields, cursor, limit)); + return PagedResponse.FromTask(responseTask); + } + + public Task> GetFollowersAsync(string cursor, int limit) + { + var responseTask = GetPagedAsync("me/followers", new RequestOptions(cursor, limit)); + return PagedResponse.FromTask(responseTask); + } + + public Task> GetSuggestedBoardsAsync(string pin, string cursor, int limit) + { + // NOTE: This endpoint uses 'count' instead of 'limit' for some reason + var responseTask = GetPagedAsync("me/boards/suggested", new RequestOptions(BoardFields, cursor, new {count = limit})); + return PagedResponse.FromTask(responseTask); + } + + public Task> GetSuggestedBoardsAsync(string cursor, int limit) + { + // NOTE: This endpoint uses 'count' instead of 'limit' for some reason + var responseTask = GetPagedAsync("me/boards/suggested", new RequestOptions(BoardFields, cursor, new {count = limit})); + return PagedResponse.FromTask(responseTask); + } + + public Task> GetFollowingBoardsAsync(string cursor, int limit) + { + var responseTask = GetPagedAsync("me/following/boards", new RequestOptions(BoardFields, cursor, limit)); + return PagedResponse.FromTask(responseTask); + } + + public Task> GetFollowingInterestsAsync(string cursor, int limit) + { + var responseTask = GetPagedAsync("me/following/interests", new RequestOptions( cursor, limit)); + return PagedResponse.FromTask(responseTask); + } + + public Task> GetFollowingUsersAsync(string cursor, int limit) + { + var responseTask = GetPagedAsync("me/following/users", new RequestOptions(cursor, limit)); + return PagedResponse.FromTask(responseTask); + } + + public Task> SearchBoardsAsync(string query, string cursor, int limit) + { + var responseTask = GetPagedAsync($"me/search/boards", new RequestOptions(query, BoardFields, cursor, limit)); + return PagedResponse.FromTask(responseTask); + } + + public Task> SearchPinsAsync(string query, string cursor, int limit) + { + var responseTask = GetPagedAsync($"me/search/pins", new RequestOptions(query, PinFields, cursor, limit)); + return PagedResponse.FromTask(responseTask); + } + + public Task FollowBoardAsync(string board) + { + return PostAsync("me/following/boards", new {board}); + } + + public Task UnfollowBoardAsync(string board) + { + return DeleteAsync($"me/following/boards/{board}"); + } + + public Task FollowUserAsync(string user) + { + return PostAsync("me/following/users", new {user}); + } + + public Task UnfollowUserAsync(string user) + { + return DeleteAsync($"me/following/users/{user}"); + } + } +} diff --git a/PinSharp/Api/PinterestApi.Pins.cs b/PinSharp/Api/PinterestApi.Pins.cs new file mode 100644 index 0000000..cd9986b --- /dev/null +++ b/PinSharp/Api/PinterestApi.Pins.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using PinSharp.Models; + +namespace PinSharp.Api +{ + internal partial class PinterestApi : IPinsApi + { + public Task GetPinAsync(string id, params string[] fields) + { + return GetPinAsync(id, fields.AsEnumerable()); + } + + public Task GetPinAsync(string id, IEnumerable fields) + { + return GetAsync($"pins/{id}", new RequestOptions(fields)); + } + + public Task GetPinAsync(string id) + { + return GetAsync($"pins/{id}", new RequestOptions(PinFields)); + } + + public Task CreatePinAsync(string board, string imageUrl, string note, string link = null) + { + if (!IsValidUrl(imageUrl)) + throw new ArgumentException($"'{imageUrl}' is not a valid URL", nameof(imageUrl)); + + return PostAsync("pins", new {board, note, link, image_url = imageUrl}, new RequestOptions(PinFields)); + } + + public Task CreatePinFromBase64Async(string board, string imageBase64, string note, string link = null) + { + if (!IsBase64String(imageBase64)) + throw new ArgumentException("The string is not valid base64", nameof(imageBase64)); + + return PostAsync("pins", new { board, note, link, image_base64 = imageBase64 }, new RequestOptions(PinFields)); + } + + public Task DeletePinAsync(string id) + { + return DeleteAsync($"pins/{id}"); + } + + public Task UpdatePinAsync(string id, string board, string note, string link) + { + // TODO: Pin id needs to be included in content as well - maybe we need to do this in other places when updating + return PatchAsync($"pins/{id}", new { pin = id, board, note, link }, new RequestOptions(PinFields)); + } + + private static bool IsBase64String(string s) + { + s = s.Trim(); + return (s.Length % 4 == 0) && Regex.IsMatch(s, @"^[a-zA-Z0-9\+/]*={0,3}$", RegexOptions.None); + } + + private static bool IsValidUrl(string url) + { + Uri uri; + return Uri.TryCreate(url, UriKind.Absolute, out uri); + } + } +} diff --git a/PinSharp/Api/PinterestApi.Users.cs b/PinSharp/Api/PinterestApi.Users.cs new file mode 100644 index 0000000..f2f3f2c --- /dev/null +++ b/PinSharp/Api/PinterestApi.Users.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using PinSharp.Models; + +namespace PinSharp.Api +{ + internal partial class PinterestApi : IUsersApi + { + public Task GetUserAsync(string userName, IEnumerable fields) + { + return GetAsync($"users/{userName}", new RequestOptions(fields)); + } + + public Task GetUserAsync(string userName) + { + return GetAsync($"users/{userName}", new RequestOptions(UserFields)); + } + } +} diff --git a/PinSharp/Api/PinterestApi.cs b/PinSharp/Api/PinterestApi.cs new file mode 100644 index 0000000..5d2e006 --- /dev/null +++ b/PinSharp/Api/PinterestApi.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Newtonsoft.Json; +using PinSharp.Api.Exceptions; +using PinSharp.Api.Responses; +using PinSharp.Extensions; +using PinSharp.Http; + +namespace PinSharp.Api +{ + internal partial class PinterestApi + { + private const string RateLimitHeader = "X-Ratelimit-Limit"; + private const string RateLimitRemainingHeader = "X-Ratelimit-Remaining"; + private static readonly object RateLimitsLock = new object(); + + private const string BaseUrl = "https://api.pinterest.com"; + + private IHttpClient Client { get; } + + public IRateLimits RateLimits { get; private set; } + + internal PinterestApi(string accessToken, string apiVersion) + { + Client = new UrlEncodedHttpClient($"{BaseUrl}/{apiVersion}/", accessToken); + } + + internal PinterestApi(IHttpClient httpClient) + { + Client = httpClient; + } + + // TODO: NOTE: Returns null if not found (404) + private async Task GetAsync(string path, RequestOptions options = null) + { + path = PathBuilder.BuildPath(path, options); + + using (var response = await Client.GetAsync(path).ConfigureAwait(false)) + { + UpdateRateLimits(response.Headers); + + if (response.StatusCode == HttpStatusCode.NotFound) + return default(T); + + if (!response.IsSuccessStatusCode) + throw await CreateException(response).ConfigureAwait(false); + + var content = await response.Content.ReadAsAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(content.data.ToString()); + } + } + + // TODO: NOTE: Returns null if not found (404) + private async Task>> GetPagedAsync(string path, RequestOptions options = null) + { + path = PathBuilder.BuildPath(path, options); + + using (var response = await Client.GetAsync(path).ConfigureAwait(false)) + { + UpdateRateLimits(response.Headers); + + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + if (!response.IsSuccessStatusCode) + throw await CreateException(response).ConfigureAwait(false); + + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject>>(content); + } + } + + private async Task PostAsync(string path, object value) + { + await PostAsyncInternal(path, value).ConfigureAwait(false); + } + + private async Task PostAsync(string path, object value, RequestOptions options = null) + { + var content = await PostAsyncInternal(path, value, options).ConfigureAwait(false); + return JsonConvert.DeserializeObject(content.data.ToString()); + } + + private async Task PostAsyncInternal(string path, object value, RequestOptions options = null) + { + path = PathBuilder.BuildPath(path, options); + + using (var response = await Client.PostAsync(path, value).ConfigureAwait(false)) + { + UpdateRateLimits(response.Headers); + + if (!response.IsSuccessStatusCode) + throw await CreateException(response).ConfigureAwait(false); + + return await response.Content.ReadAsAsync().ConfigureAwait(false); + } + } + + private async Task PatchAsync(string path, object value, RequestOptions options = null) + { + path = PathBuilder.BuildPath(path, options); + + using (var response = await Client.PatchAsync(path, value).ConfigureAwait(false)) + { + UpdateRateLimits(response.Headers); + + if (!response.IsSuccessStatusCode) + throw await CreateException(response).ConfigureAwait(false); + + var content = await response.Content.ReadAsAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(content.data.ToString()); + } + } + + private async Task DeleteAsync(string path) + { + using (var response = await Client.DeleteAsync($"{path}/").ConfigureAwait(false)) + { + UpdateRateLimits(response.Headers); + + if (!response.IsSuccessStatusCode) + throw await CreateException(response).ConfigureAwait(false); + } + } + + private void UpdateRateLimits(HttpHeaders headers) + { + if (!headers.Contains(RateLimitHeader)) return; + if (!headers.Contains(RateLimitRemainingHeader)) return; + + var limit = headers.GetValues(RateLimitHeader).First(); + var remaining = headers.GetValues(RateLimitRemainingHeader).First(); + + lock (RateLimitsLock) + { + var now = DateTimeOffset.Now; + + if (RateLimits?.LastUpdated >= now) + return; + + RateLimits = new RateLimits(int.Parse(limit), int.Parse(remaining), now); + } + } + + private static async Task CreateException(HttpResponseMessage response) + { + var url = response.RequestMessage.RequestUri.ToString(); + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var error = await response.Content.ReadAsAsync().ConfigureAwait(false); + var message = error.Message; + var status = (int)response.StatusCode; + switch (status) + { + case 400: + return PinSharpException.Create(message, url, content); + case 401: + return PinSharpException.Create(message, url, content); + case 403: + return PinSharpException.Create(message, url, content); + case 404: + return PinSharpException.Create(message, url, content); + case 408: + return PinSharpException.Create(message, url, content); + case 429: + return PinSharpException.Create(message, url, content, 429); + case 500: + case 502: + case 599: + return PinSharpException.Create(message, url, content, status); + default: + return new PinSharpException(message) + { + RequestUrl = url, + ResponseContent = content, + HttpStatusCode = status + }; + } + } + + private static string[] UserFields => new[] + { + "id", + "username", + "first_name", + "last_name", + "url", + "created_at", + "counts", + "account_type", + "bio", + "image" + }; + + private static string[] PinFields => new[] + { + "id", + "url", + "link", + "note", + "attribution", + "original_link", + "color", + "board", + "counts", + "created_at", + "image", + "media", + "metadata" + }; + + private static string[] BoardFields => new[] + { + "id", + "url", + "name", + "created_at", + "counts", + "description", + "reason", + "privacy", + "image" + }; + } +} diff --git a/PinSharp/Api/RateLimits.cs b/PinSharp/Api/RateLimits.cs new file mode 100644 index 0000000..c9f80bd --- /dev/null +++ b/PinSharp/Api/RateLimits.cs @@ -0,0 +1,20 @@ +using System; + +namespace PinSharp.Api +{ + public class RateLimits : IRateLimits + { + public RateLimits(int limit, int remaining, DateTimeOffset lastUpdated) + { + Limit = limit; + Remaining = remaining; + LastUpdated = lastUpdated; + } + + public int Limit { get; } + + public int Remaining { get; } + + public DateTimeOffset LastUpdated { get; } + } +} \ No newline at end of file diff --git a/PinSharp/Api/RequestOptions.cs b/PinSharp/Api/RequestOptions.cs new file mode 100644 index 0000000..82694a3 --- /dev/null +++ b/PinSharp/Api/RequestOptions.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Linq; + +namespace PinSharp.Api +{ + internal class RequestOptions + { + public string SearchQuery { get; set; } + public IEnumerable Fields { get; set; } + public string Cursor { get; set; } + public int Limit { get; set; } + public object CustomData { get; set; } + + public RequestOptions() + : this(Enumerable.Empty()) + { + } + + public RequestOptions(string cursor, int limit) + : this(Enumerable.Empty(), cursor, limit) + { + } + + public RequestOptions(IEnumerable fields) + : this(fields, null, 0) + { + } + + public RequestOptions(IEnumerable fields, object customData) + : this(fields, null, 0, customData) + { + } + + public RequestOptions(IEnumerable fields, int limit) + : this(fields, null, limit) + { + } + + public RequestOptions(IEnumerable fields, int limit, object customData) + : this(fields, null, limit, customData) + { + } + + public RequestOptions(IEnumerable fields, string cursor) + : this(fields, cursor, 0) + { + } + + public RequestOptions(IEnumerable fields, string cursor, object customData) + : this(fields, cursor, 0, customData) + { + } + + public RequestOptions(IEnumerable fields, string cursor, int limit) + : this(fields, cursor, limit, null) + { + } + + public RequestOptions(IEnumerable fields, string cursor, int limit, object customData) + { + Fields = fields ?? Enumerable.Empty(); + Cursor = cursor; + Limit = limit; + CustomData = customData; + } + + public RequestOptions(string searchQuery) + : this(searchQuery, Enumerable.Empty()) + { + } + + public RequestOptions(string searchQuery, IEnumerable fields) + : this(searchQuery, fields, null, 0) + { + } + + public RequestOptions(string searchQuery, IEnumerable fields, int limit) + : this(searchQuery, fields, null, limit) + { + } + + public RequestOptions(string searchQuery, IEnumerable fields, string cursor) + : this(searchQuery, fields, cursor, 0) + { + } + + public RequestOptions(string searchQuery, IEnumerable fields, string cursor, int limit) + : this(searchQuery, fields, cursor, limit, null) + { + } + + public RequestOptions(string searchQuery, IEnumerable fields, string cursor, int limit, object customData) + { + SearchQuery = searchQuery; + Fields = fields ?? Enumerable.Empty(); + Cursor = cursor; + Limit = limit; + CustomData = customData; + } + } +} diff --git a/PinSharp/Api/Responses/ErrorResponse.cs b/PinSharp/Api/Responses/ErrorResponse.cs new file mode 100644 index 0000000..9848f14 --- /dev/null +++ b/PinSharp/Api/Responses/ErrorResponse.cs @@ -0,0 +1,9 @@ +namespace PinSharp.Api.Responses +{ + internal class ErrorResponse + { + public string Message { get; set; } + + public string Type { get; set; } + } +} diff --git a/PinSharp/Api/Responses/PagedApiResponse.cs b/PinSharp/Api/Responses/PagedApiResponse.cs new file mode 100644 index 0000000..09906aa --- /dev/null +++ b/PinSharp/Api/Responses/PagedApiResponse.cs @@ -0,0 +1,14 @@ +namespace PinSharp.Api.Responses +{ + internal class PagedApiResponse + { + public T Data { get; set; } + public PagingInfo Page { get; set; } + } + + internal class PagingInfo + { + public string Cursor { get; set; } + public string Next { get; set; } + } +} diff --git a/PinSharp/Api/Responses/PagedResponse.cs b/PinSharp/Api/Responses/PagedResponse.cs new file mode 100644 index 0000000..bd014d8 --- /dev/null +++ b/PinSharp/Api/Responses/PagedResponse.cs @@ -0,0 +1,42 @@ +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace PinSharp.Api.Responses +{ + public class PagedResponse : IReadOnlyList + { + private IReadOnlyList Items { get; } + + public PagedResponse(IEnumerable pins) : this(pins, null) + { + } + + public PagedResponse(IEnumerable pins, string cursor) + { + Items = new List(pins); + NextPageCursor = cursor; + } + + internal static async Task> FromTask(Task>> task) + { + var response = await task.ConfigureAwait(false); + if (response == null) + return null; + return new PagedResponse(response.Data, response.Page?.Cursor); + } + + public string NextPageCursor { get; set; } + + public int? Ratelimit { get; set; } + public int? RatelimitRemaining { get; set; } + + public IEnumerator GetEnumerator() => Items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int Count => Items.Count; + + public T this[int index] => Items[index]; + } +} diff --git a/PinSharp/Api/Scopes.cs b/PinSharp/Api/Scopes.cs new file mode 100644 index 0000000..0d03108 --- /dev/null +++ b/PinSharp/Api/Scopes.cs @@ -0,0 +1,53 @@ +using System; + +namespace PinSharp.Api +{ + // TODO: Implement saving these with the access token and/or the PinSharpClient + /// + /// The different permission scopes you can request access when requesting an access token. + /// + [Flags] + public enum Scopes + { + // TODO: Implement this in PinSharpAuthClient + /// + /// Use GET method on a user’s profile, board and pin details, and the pins on a board. + /// + None = 0 << 0, + + /// + /// Use GET method on a user’s pins, boards and likes. + /// + ReadPublic = 1 << 0, + + /// + /// Use PATCH, POST and DELETE methods on a user’s pins and boards. + /// + WritePublic = 1 << 1, + + /// + /// Use GET method on a user’s follows and followers (on boards, users and interests). + /// + ReadRelationships = 1 << 2, + + /// + /// Use PATCH, POST and DELETE methods on a user’s follows and followers (on boards, users and interests). + /// + WriteRelationships = 1 << 3, + + /// + /// Combination of and . + /// + ReadAll = ReadPublic | ReadRelationships, + + /// + /// Combination of and . + /// + WriteAll = WritePublic | WriteRelationships, + + /// + /// Combination of all scopes - , , and . + /// + All = ReadAll | WriteAll, + } +} diff --git a/PinSharp/Extensions/HttpContentExtensions.cs b/PinSharp/Extensions/HttpContentExtensions.cs new file mode 100644 index 0000000..7354c27 --- /dev/null +++ b/PinSharp/Extensions/HttpContentExtensions.cs @@ -0,0 +1,18 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace PinSharp.Extensions +{ + internal static class HttpContentExtensions + { + public static async Task ReadAsAsync(this HttpContent content) + { + if (content.Headers.ContentLength == 0) + return default(T); + + var data = await content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(data); + } + } +} diff --git a/PinSharp/Extensions/ReflectionExtensions.cs b/PinSharp/Extensions/ReflectionExtensions.cs new file mode 100644 index 0000000..dc25c06 --- /dev/null +++ b/PinSharp/Extensions/ReflectionExtensions.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace PinSharp.Extensions +{ + internal static class ReflectionExtensions + { + public static IEnumerable GetProperties(this Type type) + { +#if NETSTANDARD + return type.GetTypeInfo().DeclaredProperties; +#else + return type.GetProperties(); +#endif + } + + public static bool IsInterface(this Type type) + { +#if NETSTANDARD + return type.GetTypeInfo().IsInterface; +#else + return type.IsInterface; +#endif + } + +#if NETSTANDARD + public static bool IsAssignableFrom(this Type type, Type otherType) + { + return type.GetTypeInfo().IsAssignableFrom(otherType.GetTypeInfo()); + } +#endif + } +} diff --git a/PinSharp/Http/FormUrlEncodedContent.cs b/PinSharp/Http/FormUrlEncodedContent.cs new file mode 100644 index 0000000..b6a262a --- /dev/null +++ b/PinSharp/Http/FormUrlEncodedContent.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; + +namespace PinSharp.Http +{ + public class FormUrlEncodedContent : ByteArrayContent + { + private static readonly Encoding DefaultHttpEncoding = Encoding.GetEncoding("ISO-8859-1"); + + public FormUrlEncodedContent(IEnumerable> nameValueCollection) + : base(GetContentByteArray(nameValueCollection)) + { + Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); + } + + protected static byte[] GetContentByteArray(IEnumerable> nameValueCollection) + { + if (nameValueCollection == null) throw new ArgumentNullException(nameof(nameValueCollection)); + + var stringBuilder = new StringBuilder(); + foreach (var keyValuePair in nameValueCollection) + { + if (stringBuilder.Length > 0) + stringBuilder.Append('&'); + stringBuilder.Append(Encode(keyValuePair.Key)); + stringBuilder.Append('='); + stringBuilder.Append(Encode(keyValuePair.Value)); + } + return DefaultHttpEncoding.GetBytes(stringBuilder.ToString()); + } + + protected static string Encode(string data) + { + if (string.IsNullOrEmpty(data)) + return ""; + + return EscapeLongDataString(data); + } + + protected static string EscapeLongDataString(string data) + { + // Uri.EscapeDataString() does not support strings longer than this + const int maxLength = 65519; + + var sb = new StringBuilder(); + var iterationsNeeded = data.Length / maxLength; + + for (var i = 0; i <= iterationsNeeded; i++) + { + sb.Append(i < iterationsNeeded + ? Uri.EscapeDataString(data.Substring(maxLength * i, maxLength)) + : Uri.EscapeDataString(data.Substring(maxLength * i))); + } + + return sb.ToString().Replace("%20", "+"); + } + } +} diff --git a/PinSharp/Http/IHttpClient.cs b/PinSharp/Http/IHttpClient.cs new file mode 100644 index 0000000..f8ed401 --- /dev/null +++ b/PinSharp/Http/IHttpClient.cs @@ -0,0 +1,13 @@ +using System.Net.Http; +using System.Threading.Tasks; + +namespace PinSharp.Http +{ + public interface IHttpClient + { + Task GetAsync(string requestUri); + Task PostAsync(string requestUri, T value); + Task PatchAsync(string requestUri, T value); + Task DeleteAsync(string requestUri); + } +} diff --git a/PinSharp/Http/UrlEncodedHttpClient.cs b/PinSharp/Http/UrlEncodedHttpClient.cs new file mode 100644 index 0000000..9678b04 --- /dev/null +++ b/PinSharp/Http/UrlEncodedHttpClient.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using PinSharp.Extensions; + +namespace PinSharp.Http +{ + public class UrlEncodedHttpClient : IHttpClient + { + private HttpClient Client { get; } + + public UrlEncodedHttpClient(string baseAddress, string accessToken) + { + var handler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; + + Client = new HttpClient(handler) + { + BaseAddress = new Uri(baseAddress), + DefaultRequestHeaders = + { + Authorization = new AuthenticationHeaderValue("Bearer", accessToken) + } + }; + } + + public virtual Task GetAsync(string requestUri) + { + return Client.GetAsync(requestUri); + } + + public virtual Task PostAsync(string requestUri, T value) + { + var content = GetFormUrlEncodedContent(value); + return Client.PostAsync(requestUri, content); + } + + public virtual Task PatchAsync(string requestUri, T value) + { + var request = new HttpRequestMessage(new HttpMethod("PATCH"), requestUri); + request.Headers.ExpectContinue = false; + request.Content = GetFormUrlEncodedContent(value); + return Client.SendAsync(request); + } + + public virtual Task DeleteAsync(string requestUri) + { + return Client.DeleteAsync(requestUri); + } + + protected static FormUrlEncodedContent GetFormUrlEncodedContent(object obj) + { + // TODO: Add attribute to ignore property? + var data = + obj.GetType() + .GetProperties() + .Select(prop => new KeyValuePair(prop.Name, prop.GetValue(obj, null)?.ToString())) + .Where(x => x.Value != null); + + return new FormUrlEncodedContent(data); + } + } +} diff --git a/PinSharp/InternalsVisibleTo.cs b/PinSharp/InternalsVisibleTo.cs new file mode 100644 index 0000000..4c92124 --- /dev/null +++ b/PinSharp/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("PinSharp.Tests")] \ No newline at end of file diff --git a/PinSharp/Models/Board.cs b/PinSharp/Models/Board.cs new file mode 100644 index 0000000..c88ffee --- /dev/null +++ b/PinSharp/Models/Board.cs @@ -0,0 +1,31 @@ +using System; +using Newtonsoft.Json; +using PinSharp.Models.Counts; +using PinSharp.Models.Images; + +namespace PinSharp.Models +{ + public class Board : IDetailedBoard, IUserBoard, IBoard + { + public string Id { get; set; } + + public string Url { get; set; } + + public string Name { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + public string Description { get; set; } + + public IBoardCounts Counts { get; set; } + + [JsonProperty("image")] + public IBoardImageList Images { get; set; } + + public string Reason { get; set; } + + public string Privacy { get; set; } + + } +} diff --git a/PinSharp/Models/Counts/Counts.cs b/PinSharp/Models/Counts/Counts.cs new file mode 100644 index 0000000..576ee9f --- /dev/null +++ b/PinSharp/Models/Counts/Counts.cs @@ -0,0 +1,19 @@ +using System; +using Newtonsoft.Json; + +namespace PinSharp.Models.Counts +{ + public class Counts : IBoardCounts, IPinCounts, IUserCounts + { + public int Boards { get; set; } + public int Collaborators { get; set; } + public int Comments { get; set; } + public int Followers { get; set; } + public int Following { get; set; } + public int Pins { get; set; } + public int Saves { get; set; } + + [Obsolete("Use 'Saves' instead. This property will be removed in a future version")] + public int Repins => Saves; + } +} diff --git a/PinSharp/Models/Counts/IBoardCounts.cs b/PinSharp/Models/Counts/IBoardCounts.cs new file mode 100644 index 0000000..ae09a8b --- /dev/null +++ b/PinSharp/Models/Counts/IBoardCounts.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using PinSharp.Serialization; + +namespace PinSharp.Models.Counts +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IBoardCounts + { + int Collaborators { get; set; } + int Followers { get; set; } + int Pins { get; set; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/Counts/IPinCounts.cs b/PinSharp/Models/Counts/IPinCounts.cs new file mode 100644 index 0000000..4710a83 --- /dev/null +++ b/PinSharp/Models/Counts/IPinCounts.cs @@ -0,0 +1,16 @@ +using System; +using Newtonsoft.Json; +using PinSharp.Serialization; + +namespace PinSharp.Models.Counts +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IPinCounts + { + int Comments { get; set; } + int Saves { get; set; } + + [Obsolete("Use 'Saves' instead. This property will be removed in a future version")] + int Repins { get; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/Counts/IUserCounts.cs b/PinSharp/Models/Counts/IUserCounts.cs new file mode 100644 index 0000000..e8670c3 --- /dev/null +++ b/PinSharp/Models/Counts/IUserCounts.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; +using PinSharp.Serialization; + +namespace PinSharp.Models.Counts +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IUserCounts + { + int Boards { get; set; } + int Followers { get; set; } + int Following { get; set; } + int Pins { get; set; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/IBoard.cs b/PinSharp/Models/IBoard.cs new file mode 100644 index 0000000..5fd4845 --- /dev/null +++ b/PinSharp/Models/IBoard.cs @@ -0,0 +1,39 @@ +using System; +using Newtonsoft.Json; +using PinSharp.Models.Counts; +using PinSharp.Models.Images; +using PinSharp.Serialization; + +namespace PinSharp.Models +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IDetailedBoard : IUserBoard + { + } + + [JsonConverter(typeof(InterfaceConverter))] + public interface IUserBoard : IBoard + { + DateTime CreatedAt { get; set; } + + string Description { get; set; } + + IBoardCounts Counts { get; set; } + + IBoardImageList Images { get; set; } + + string Reason { get; set; } + + string Privacy { get; set; } + } + + [JsonConverter(typeof(InterfaceConverter))] + public interface IBoard + { + string Id { get; set; } + + string Url { get; set; } + + string Name { get; set; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/IPin.cs b/PinSharp/Models/IPin.cs new file mode 100644 index 0000000..fe75262 --- /dev/null +++ b/PinSharp/Models/IPin.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using PinSharp.Models.Counts; +using PinSharp.Models.Images; +using PinSharp.Serialization; + +namespace PinSharp.Models +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IUserPin + { + string Id { get; set; } + + string Url { get; set; } + + DateTime CreatedAt { get; set; } + + string Note { get; set; } + + IBoard Board { get; set; } + + IPinCounts Counts { get; set; } + + IPinImageList Images { get; set; } + + string Link { get; set; } + + string OriginalLink { get; set; } + + string Color { get; set; } + + IDictionary Media { get; set; } + + IDictionary Attribution { get; set; } + + IDictionary Metadata { get; set; } + } + + [JsonConverter(typeof(InterfaceConverter))] + public interface IPin : IUserPin + { + } +} \ No newline at end of file diff --git a/PinSharp/Models/IUser.cs b/PinSharp/Models/IUser.cs new file mode 100644 index 0000000..4ed0a14 --- /dev/null +++ b/PinSharp/Models/IUser.cs @@ -0,0 +1,42 @@ +using System; +using Newtonsoft.Json; +using PinSharp.Models.Counts; +using PinSharp.Models.Images; +using PinSharp.Serialization; + +namespace PinSharp.Models +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IUser + { + string Id { get; set; } + + string Url { get; set; } + + //[JsonProperty("first_name")] + string FirstName { get; set; } + + //[JsonProperty("last_name")] + string LastName { get; set; } + + string UserName { get; set; } + + //[JsonProperty("image")] + IUserImageList Images { get; set; } + } + + [JsonConverter(typeof(InterfaceConverter))] + public interface IDetailedUser : IUser + { + + //[JsonProperty("account_type")] + string AccountType { get; set; } + + string Bio { get; set; } + + //[JsonProperty("created_at")] + DateTime CreatedAt { get; set; } + + IUserCounts Counts { get; set; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/Images/IBoardImageList.cs b/PinSharp/Models/Images/IBoardImageList.cs new file mode 100644 index 0000000..92602cf --- /dev/null +++ b/PinSharp/Models/Images/IBoardImageList.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using PinSharp.Serialization; + +namespace PinSharp.Models.Images +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IBoardImageList + { + ImageInfo W60 { get; set; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/Images/IPinImageList.cs b/PinSharp/Models/Images/IPinImageList.cs new file mode 100644 index 0000000..72019c2 --- /dev/null +++ b/PinSharp/Models/Images/IPinImageList.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using PinSharp.Serialization; + +namespace PinSharp.Models.Images +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IPinImageList + { + ImageInfo Original { get; set; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/Images/IUserImageList.cs b/PinSharp/Models/Images/IUserImageList.cs new file mode 100644 index 0000000..d337d66 --- /dev/null +++ b/PinSharp/Models/Images/IUserImageList.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using PinSharp.Serialization; + +namespace PinSharp.Models.Images +{ + [JsonConverter(typeof(InterfaceConverter))] + public interface IUserImageList + { + ImageInfo W60 { get; set; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/Images/ImageInfo.cs b/PinSharp/Models/Images/ImageInfo.cs new file mode 100644 index 0000000..97b2076 --- /dev/null +++ b/PinSharp/Models/Images/ImageInfo.cs @@ -0,0 +1,9 @@ +namespace PinSharp.Models.Images +{ + public class ImageInfo + { + public string Url { get; set; } + public int Width { get; set; } + public int Height { get; set; } + } +} diff --git a/PinSharp/Models/Images/ImageList.cs b/PinSharp/Models/Images/ImageList.cs new file mode 100644 index 0000000..5ca6b0a --- /dev/null +++ b/PinSharp/Models/Images/ImageList.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace PinSharp.Models.Images +{ + public class ImageList : IPinImageList, IUserImageList, IBoardImageList + { + public ImageInfo Original { get; set; } + + [JsonProperty("60x60")] + public ImageInfo W60 { get; set; } + } +} diff --git a/PinSharp/Models/Interest.cs b/PinSharp/Models/Interest.cs new file mode 100644 index 0000000..efda368 --- /dev/null +++ b/PinSharp/Models/Interest.cs @@ -0,0 +1,8 @@ +namespace PinSharp.Models +{ + public class Interest + { + public string Id { get; set; } + public string Name { get; set; } + } +} diff --git a/PinSharp/Models/Pin.cs b/PinSharp/Models/Pin.cs new file mode 100644 index 0000000..d0aff15 --- /dev/null +++ b/PinSharp/Models/Pin.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using PinSharp.Models.Counts; +using PinSharp.Models.Images; + +namespace PinSharp.Models +{ + public class Pin : IPin, IUserPin + { + public string Id { get; set; } + + public string Url { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + public string Note { get; set; } + + public IBoard Board { get; set; } + + public IPinCounts Counts { get; set; } + + [JsonProperty("image")] + public IPinImageList Images { get; set; } + + public string Link { get; set; } + + [JsonProperty("original_link")] + public string OriginalLink { get; set; } + + public string Color { get; set; } + + public IDictionary Media { get; set; } + + public IDictionary Attribution { get; set; } + + public IDictionary Metadata { get; set; } + } +} \ No newline at end of file diff --git a/PinSharp/Models/User.cs b/PinSharp/Models/User.cs new file mode 100644 index 0000000..1e7bedc --- /dev/null +++ b/PinSharp/Models/User.cs @@ -0,0 +1,35 @@ +using System; +using Newtonsoft.Json; +using PinSharp.Models.Counts; +using PinSharp.Models.Images; + +namespace PinSharp.Models +{ + public class User : IDetailedUser, IUser + { + public string Id { get; set; } + + public string Url { get; set; } + + [JsonProperty("first_name")] + public string FirstName { get; set; } + + [JsonProperty("last_name")] + public string LastName { get; set; } + + public string UserName { get; set; } + + [JsonProperty("image")] + public IUserImageList Images { get; set; } + + [JsonProperty("account_type")] + public string AccountType { get; set; } + + public string Bio { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + public IUserCounts Counts { get; set; } + } +} diff --git a/PinSharp/PinSharp.csproj b/PinSharp/PinSharp.csproj new file mode 100644 index 0000000..7633184 --- /dev/null +++ b/PinSharp/PinSharp.csproj @@ -0,0 +1,39 @@ + + + net8.0 + 1.6.0 + true + + + + full + + + PinSharp + $(SemVer) + Søren Kruse + + PinSharp + An async wrapper library for the Pinterest API + https://github.com/Krusen/PinSharp/blob/master/LICENSE.md + https://github.com/Krusen/PinSharp + https://raw.githubusercontent.com/Krusen/PinSharp/master/Assets/icon.png + https://github.com/Krusen/PinSharp + git + pinsharp;pinterest;api;client;wrapper;library + Updated with .NET Core support, rate limit information and custom exceptions. + True + True + True + + + $(DefineConstants);NETSTANDARD + + + + + + + + + \ No newline at end of file diff --git a/PinSharp/PinSharpAuthClient.cs b/PinSharp/PinSharpAuthClient.cs new file mode 100644 index 0000000..9bdc78c --- /dev/null +++ b/PinSharp/PinSharpAuthClient.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Security.Cryptography; +using System.Threading.Tasks; +using PinSharp.Api; +using PinSharp.Extensions; + +namespace PinSharp +{ + // TODO: Add Oauth exception classes and handling + /// + /// Static class used for getting an authorization URL and to get an access token from the code returned from Pinterest. + /// + public static class PinSharpAuthClient + { + private const string BaseUrl = "https://api.pinterest.com/"; + + /// + /// + /// Generates a login URL with the required parameters. + /// Users will need to visit this URL to authorize your app to use the API on their behalf. + /// + /// + /// If they accept they will be redirected to the + /// with two query string parameters - "state" and "code". + /// + /// + /// Call if you want to specify the state value yourself to be able to prevent spoofing. + /// + /// + /// "code" is used with to + /// get an access token to use with . + /// + /// + /// The Client ID (also known as App ID) of your app. See https://developers.pinterest.com/apps/ + /// + /// The URL you want your user to be redirected to after authorizing your app. + /// The code needed for will be added as query string parameter "code". + /// + /// The scopes you want to request from the user. + /// + public static string BuildAuthorizationUrl(string clientId, string redirectUri, Scopes scopes) + { + return BuildAuthorizationUrl(clientId, redirectUri, scopes, CreateRandomState()); + } + + /// + /// + /// Generates a login URL with the required parameters. + /// Users will need to visit this URL to authorize your app to use the API on their behalf. + /// + /// + /// If they accept they will be redirected to the + /// with two query string parameters - "state" and "code". + /// + /// + /// "state" verifies that this comes from you. + /// "code" is used with to + /// get an access token to use with . + /// + /// + /// The Client ID (also known as App ID) of your app. See https://developers.pinterest.com/apps/ + /// + /// The URL you want your user to be redirected to after authorizing your app. + /// The code needed for will be added as query string parameter "code". + /// + /// The scopes you want to request from the user. + /// A string that is added to as query string parameter "state". This is to prevent spoofing. + /// + public static string BuildAuthorizationUrl(string clientId, string redirectUri, Scopes scopes, string state) + { + var scope = GetScope(scopes); + + return $"{BaseUrl}oauth/?response_type=code&client_id={clientId}&redirect_uri={redirectUri}&scope={scope}&state={state}"; + } + + /// + /// Gets an access token which you can then use with . + /// + /// The Client ID (also known as App ID) of your app. See https://developers.pinterest.com/apps/ + /// The Client secret (also known as App secret) of your app. See https://developers.pinterest.com/apps/ + /// The code that was passed to your redirectUri as a query string parameter. + /// The API version. Defaults to "v1" if left out. + /// An access token for use with . + public static async Task GetAccessTokenAsync(string clientId, string clientSecret, string code, string apiVersion = "v1") + { + var url = $"{BaseUrl}{apiVersion}/oauth/token?grant_type=authorization_code&client_id={clientId}&client_secret={clientSecret}&code={code}"; + + var client = new HttpClient(); + var response = await client.PostAsync(url, null).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsAsync().ConfigureAwait(false); + return json.access_token; + } + + /// + /// Generates a random string that you can use to verify that + /// the redirect back to your site or app wasn't spoofed. + /// + /// + /// Pass this to to get the correct login URL. + /// + /// + /// The length of the random string. + /// + public static string CreateRandomState(int length = 10) + { + var data = new byte[length/2]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(data); + } + return BitConverter.ToString(data).Replace("-", "").ToLower(); + } + + private static string GetScope(Scopes scopes) + { + var values = new List(); + + if (scopes.HasFlag(Scopes.ReadPublic)) + values.Add("read_public"); + + if (scopes.HasFlag(Scopes.WritePublic)) + values.Add("write_public"); + + if (scopes.HasFlag(Scopes.ReadRelationships)) + values.Add("read_relationships"); + + if (scopes.HasFlag(Scopes.WriteRelationships)) + values.Add("write_relationships"); + + return string.Join(",", values); + } + } +} diff --git a/PinSharp/PinSharpClient.cs b/PinSharp/PinSharpClient.cs new file mode 100644 index 0000000..10c08ee --- /dev/null +++ b/PinSharp/PinSharpClient.cs @@ -0,0 +1,47 @@ +using PinSharp.Api; +using PinSharp.Http; + +namespace PinSharp +{ + public class PinSharpClient + { + private PinterestApi Api { get; } + + /// + /// Endpoints for getting board information, pins on boards and managing boards. + /// + public IBoardsApi Boards => Api; + + /// + /// Endspoints related to the user associated with the used access token. + /// + public IMeApi Me => Api; + + /// + /// Endspoints for creating, updating and deleting pins. + /// + public IPinsApi Pins => Api; + + /// + /// Endpoints for getting user information. + /// + public IUsersApi Users => Api; + + /// + /// Contains information about your request limit and remaining requests. + /// Rate limits uses a 60-minute sliding window. + /// This is updated after each request and will be null until the first requests has been made. + /// + public IRateLimits RateLimits => Api.RateLimits; + + public PinSharpClient(string accessToken, string apiVersion) + { + Api = new PinterestApi(accessToken, apiVersion); + } + + public PinSharpClient(IHttpClient httpClient) + { + Api = new PinterestApi(httpClient); + } + } +} diff --git a/PinSharp/Serialization/InterfaceConverter.cs b/PinSharp/Serialization/InterfaceConverter.cs new file mode 100644 index 0000000..7338c30 --- /dev/null +++ b/PinSharp/Serialization/InterfaceConverter.cs @@ -0,0 +1,25 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using PinSharp.Extensions; + +namespace PinSharp.Serialization +{ + public class InterfaceConverter : JsonConverter where T : class, new() + { + public override bool CanConvert(Type objectType) => objectType.IsInterface() && objectType.IsAssignableFrom(typeof(T)); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var value = new T(); + var objectReader = JObject.Load(reader).CreateReader(); + serializer.Populate(objectReader, value); + return value; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + serializer.Serialize(writer, value); + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f783d7 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# PinSharp + +[![license](https://img.shields.io/badge/license-Unlicense-blue.svg)](https://github.com/Krusen/PinSharp/blob/master/LICENSE.MD) +[![AppVeyor](https://ci.appveyor.com/api/projects/status/to2o4ik0nw5d98js/branch/master?svg=true)](https://ci.appveyor.com/project/Krusen/pinsharp) +[![Coverage](https://coveralls.io/repos/github/Krusen/PinSharp/badge.svg?branch=master)](https://coveralls.io/github/Krusen/PinSharp?branch=master) +[![CodeFactor](https://www.codefactor.io/repository/github/krusen/pinsharp/badge)](https://www.codefactor.io/repository/github/krusen/pinsharp) +[![NuGet](https://buildstats.info/nuget/pinsharp?includePreReleases=false)](https://www.nuget.org/packages/PinSharp) +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FKrusen%2FPinSharp.svg?type=shield)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FKrusen%2FPinSharp?ref=badge_shield) + +An async C# wrapper library for the Pinterest API. + +- https://developers.pinterest.com/docs/getting-started/introduction/ + + +# Notice +I'm not maintaining this regularly as I don't use it that much. +If you have any issues or request please create a new issue and I'll have a look. + + +# Overview + +- [New in version 2.0](#new-in-version-20) + - [Breaking changes](#breaking-changes-in-version-20) +- [Examples](#examples) + + +# New in version 2.0 + +A lot of the changes are code cleanup and refactoring, but there is a few new features. + +### Rate limit information + +Information about rate limits are now stored on the `PinSharpClient` in the property `RateLimits` + +It contains information about the request limit and remaining requests and when this information was last updated +(i.e. the time of your last request through this client). + +### Exceptions + +The client now throws its own exceptions all extending from `PinSharpException`. + +For example a `PinSharpRateLimitExceededException` will be thrown if the rate limit has been exceeded. + + +## Breaking changes in version 2.0 + +All return types have generally been changed from a concrete class to an interface, e.g. `Pin` to `IPin`. +`BoardDetails` and `UserDetails` have also been renamed in the process to `IDetailedBoard` and `IDetailedUsers`. + +### Renamed + +- `PinterestClient` renamed to `PinSharpClient` +- `PinterestAuthClient` renamed to `PinSharpAuthClient` +- `PinterestApi` made `internal` +- `Scopes.WriteRelationShips` renamed to `Scopes.WriteRelationsships` + +### Moved + +- `PinSharp.IHttpClient` moved to `PinSharp.Http.IHttpClient` +- `PinSharp.Models.ImageInfo` moved to `PinSharp.Models.Images.ImageInfo` + +### Refactored/combined + +- Models + - `BoardDetails` removed - merged into `Board` and exposed as `IDetaildBoard` interface + - `UserDetails` removed - merged into `User` and exposed as `IDetailedUser` interface + - `UserBoard` removed - merged into `Board` and exposed as `IUserBoard` interface + - `UserPin` removed - merged into `Pin` and exposed as `IUserPin` interface +- Counts + - `BoardCounts` removed - merged into **new** `Counts` and exposed as `IBoardCounts` interface + - `PinCounts` removed - merged into **new** `Counts` and exposed as `IPinCounts` interface + - `UserCounts` removed - merged into **new** `Counts` and exposed as `IUserCounts` interface +- Images + - `BoardImages` removed - merged into **new** `ImageList` and exposed as `IBoardImageList` interface + - `PinImages` removed - merged into **new** `ImageList` and exposed as `IPinImageList` interface + - `UserImages` removed - merged into **new** `ImageList` and exposed as `IUserImageList` interface + + +# Examples + +You need an access token to use the API. + +If you don't have one already you can generate one here: https://developers.pinterest.com/tools/access_token/ + +```C# +// Create a client with your access token +var client = new PinSharpClient("AB_IBS7Q0fFQbXJ90JGtSDXNMV-tEBkfLftbK6JCpEWkGoA_MwAAAAA"); + +// Get board information +var board = await client.Boards.GetBoardAsync("machineshopcafe/best-of-mclaren-machine"); + +// Get pins on board +var pins = await client.Boards.GetPinsAsync("machineshopcafe/best-of-mclaren-machine"); + +// Get pins on board but only with the 'board' field as dynamic or your own type +var pins = await client.Boards.GetPinsAsync("rice_up/tableware", new[] { "board" }); + +// Get user info of the user associated with the access token +var user = await client.Me.GetUserAsync(); + +// Get pins of the user associated with the access token +var pins = await client.Me.GetPinsAsync(); + +// Get boards of the user associated with the access token +var boards = await client.Me.GetBoardsAsync(); + +// Search the associated user's pins or boards +var pins = await client.Me.SearchPinsAsync("mclaren"); +var boards = await client.Me.SearchBoardsAsync("mclaren"); + +// Create new pin +var newPin = await client.Pins.CreatePinAsync("machineshopcafe/best-of-mclaren-machine", "http://i.imgur.com/abcdef.jpg", "Looks so cool!"); + +// Follow/unfollow board or user +await client.Me.FollowBoardAsync("machineshopcafe/best-of-mclaren-machine"); +await client.Me.UnfollowBoardAsync("machineshopcafe/best-of-mclaren-machine"); +await client.Me.FollowUserAsync("machineshopcafe"); +await client.Me.UnfollowUserAsync("machineshopcafe"); +``` + + +## License +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FKrusen%2FPinSharp.svg?type=large)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FKrusen%2FPinSharp?ref=badge_large) \ No newline at end of file diff --git a/build.cake b/build.cake new file mode 100644 index 0000000..1036c4e --- /dev/null +++ b/build.cake @@ -0,0 +1,168 @@ +// For inspiration see: https://github.com/Jericho/Picton/blob/develop/build.cake + +// Install addins. +#addin "nuget:?package=Cake.Coveralls&version=0.5.0" +#addin "nuget:?package=Cake.Json&version=1.0.2.13" + +// Install tools. +#tool "nuget:?package=GitVersion.CommandLine&version=4.0.0-beta0012" +#tool "nuget:?package=OpenCover&version=4.6.519" +#tool "nuget:?package=coveralls.io&version=1.3.4" +#tool "nuget:?package=xunit.runner.console&version=2.2.0" + + +/////////////////////////////////////////////////////////////////////////////// +// ARGUMENTS +/////////////////////////////////////////////////////////////////////////////// + +var target = Argument("target", "Default"); +var configuration = Argument("configuration", "Release"); + + +/////////////////////////////////////////////////////////////////////////////// +// GLOBAL VARIABLES +/////////////////////////////////////////////////////////////////////////////// + +var libraryName = "PinSharp"; + +var sourceFolder = "./"; + +var xunitRunnerJsonFile = "./PinSharp.Tests/xunit.runner.json"; +var testProjectFile = "./PinSharp.Tests/PinSharp.Tests.csproj"; +var codeCoverageOutput = "coverage.xml"; +var codeCoverageFilter = "+[*]* -[*.Tests]*"; + +var cakeVersion = typeof(ICakeContext).Assembly.GetName().Version.ToString(); +var versionInfo = GitVersion(new GitVersionSettings() { OutputType = GitVersionOutput.Json }); +var milestone = string.Concat("v", versionInfo.MajorMinorPatch); +var buildVersion = $"{versionInfo.SemVer}+{AppVeyor.Environment.Build.Number}"; +var packageVersion = versionInfo.LegacySemVer; + +var isLocalBuild = BuildSystem.IsLocalBuild; +var isPullRequest = BuildSystem.AppVeyor.Environment.PullRequest.IsPullRequest; +var isTagged = ( + BuildSystem.AppVeyor.Environment.Repository.Tag.IsTag && + !string.IsNullOrWhiteSpace(BuildSystem.AppVeyor.Environment.Repository.Tag.Name) +); + +/////////////////////////////////////////////////////////////////////////////// +// SETUP / TEARDOWN +/////////////////////////////////////////////////////////////////////////////// + +Setup(context => +{ + Information($"Building version {packageVersion} of {libraryName} using version {cakeVersion} of Cake" + Environment.NewLine); + + Information("Variables:" + Environment.NewLine + + $"\t IsLocalBuild: {isLocalBuild}" + Environment.NewLine + + $"\t IsPullRequest: {isPullRequest}" + Environment.NewLine + + $"\t IsTagged: {isTagged}" + Environment.NewLine + ); + + Information("Versions:" + Environment.NewLine + + $"\t Milestone: {milestone}" + Environment.NewLine + + $"\t BuildNumber: {AppVeyor.Environment.Build.Number}" + Environment.NewLine + + $"\t BuildVersion: {buildVersion}" + Environment.NewLine + + $"\t PackageVersion: {packageVersion}" + Environment.NewLine + ); +}); + + +/////////////////////////////////////////////////////////////////////////////// +// TASK DEFINITIONS +/////////////////////////////////////////////////////////////////////////////// + +Task("AppVeyor_Set-Build-Version") + .WithCriteria(() => AppVeyor.IsRunningOnAppVeyor) + .Does(() => +{ + AppVeyor.UpdateBuildVersion(buildVersion); +}); + +Task("Restore-NuGet-Packages") + .Does(() => +{ + DotNetCoreRestore(); +}); + +Task("Build") + .IsDependentOn("Restore-NuGet-Packages") + .Does(() => +{ + DotNetCoreBuild(sourceFolder + libraryName + ".sln", new DotNetCoreBuildSettings + { + Configuration = configuration, + ArgumentCustomization = args => args.Append("/p:SemVer=" + packageVersion) + }); +}); + +Task("Run-Unit-Tests") + .IsDependentOn("Build") + .Does(() => +{ + DotNetCoreTest(testProjectFile, new DotNetCoreTestSettings + { + NoBuild = true, + Configuration = configuration + }); +}); + +Task("Disable-xUnit-ShadowCopy") + .Does(() => +{ + Information("Disabling xUnit shadow copying assemblies for code coverage to work..."); + SerializeJsonToFile(xunitRunnerJsonFile, new { shadowCopy = false }); +}); + +Task("Run-Code-Coverage") + .IsDependentOn("Disable-xUnit-ShadowCopy") + .IsDependentOn("Build") + .Does(() => +{ + Action testAction = ctx => ctx.DotNetCoreTest(testProjectFile, new DotNetCoreTestSettings + { + NoBuild = true, + Configuration = configuration + }); + + OpenCover(testAction, + codeCoverageOutput, + new OpenCoverSettings + { + OldStyle = true, + SkipAutoProps = true, + MergeOutput = false + } + .WithFilter(codeCoverageFilter) + ); + + if (FileExists(xunitRunnerJsonFile)) + DeleteFile(xunitRunnerJsonFile); +}); + +Task("Upload-Coverage-Result") + .WithCriteria(() => !isLocalBuild) + .Does(() => +{ + CoverallsIo(codeCoverageOutput); +}); + + +/////////////////////////////////////////////////////////////////////////////// +// TARGETS +/////////////////////////////////////////////////////////////////////////////// + +Task("AppVeyor") + .IsDependentOn("AppVeyor_Set-Build-Version") + .IsDependentOn("Run-Code-Coverage") + .IsDependentOn("Upload-Coverage-Result"); + +Task("Default") + .IsDependentOn("AppVeyor"); + + +/////////////////////////////////////////////////////////////////////////////// +// EXECUTION +/////////////////////////////////////////////////////////////////////////////// + +RunTarget(target); \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..df2fd64 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,224 @@ +########################################################################## +# This is the Cake bootstrapper script for PowerShell. +# This file was downloaded from https://github.com/cake-build/resources +# Feel free to change this file to fit your needs. +########################################################################## + +<# +.SYNOPSIS +This is a Powershell script to bootstrap a Cake build. +.DESCRIPTION +This Powershell script will download NuGet if missing, restore NuGet tools (including Cake) +and execute your Cake build script with the parameters you provide. +.PARAMETER Script +The build script to execute. +.PARAMETER Target +The build script target to run. +.PARAMETER Configuration +The build configuration to use. +.PARAMETER Verbosity +Specifies the amount of information to be displayed. +.PARAMETER Experimental +Tells Cake to use the latest Roslyn release. +.PARAMETER WhatIf +Performs a dry run of the build script. +No tasks will be executed. +.PARAMETER Mono +Tells Cake to use the Mono scripting engine. +.PARAMETER SkipToolPackageRestore +Skips restoring of packages. +.PARAMETER ScriptArgs +Remaining arguments are added here. +.LINK +http://cakebuild.net +#> + +[CmdletBinding()] +Param( + [string]$Script = "build.cake", + [string]$Target = "Default", + [ValidateSet("Release", "Debug")] + [string]$Configuration = "Release", + [ValidateSet("Quiet", "Minimal", "Normal", "Verbose", "Diagnostic")] + [string]$Verbosity = "Verbose", + [switch]$Experimental, + [Alias("DryRun","Noop")] + [switch]$WhatIf, + [switch]$Mono, + [switch]$SkipToolPackageRestore, + [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)] + [string[]]$ScriptArgs +) + +[Reflection.Assembly]::LoadWithPartialName("System.Security") | Out-Null +function MD5HashFile([string] $filePath) +{ + if ([string]::IsNullOrEmpty($filePath) -or !(Test-Path $filePath -PathType Leaf)) + { + return $null + } + + [System.IO.Stream] $file = $null; + [System.Security.Cryptography.MD5] $md5 = $null; + try + { + $md5 = [System.Security.Cryptography.MD5]::Create() + $file = [System.IO.File]::OpenRead($filePath) + return [System.BitConverter]::ToString($md5.ComputeHash($file)) + } + finally + { + if ($file -ne $null) + { + $file.Dispose() + } + } +} + +Write-Host "Preparing to run build script..." + +if(!$PSScriptRoot){ + $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent +} + +$TOOLS_DIR = Join-Path $PSScriptRoot "tools" +$ADDINS_DIR = Join-Path $TOOLS_DIR "Addins" +$MODULES_DIR = Join-Path $TOOLS_DIR "Modules" +$NUGET_EXE = Join-Path $TOOLS_DIR "nuget.exe" +$CAKE_EXE = Join-Path $TOOLS_DIR "Cake/Cake.exe" +$NUGET_URL = "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" +$PACKAGES_CONFIG = Join-Path $TOOLS_DIR "packages.config" +$PACKAGES_CONFIG_MD5 = Join-Path $TOOLS_DIR "packages.config.md5sum" +$ADDINS_PACKAGES_CONFIG = Join-Path $ADDINS_DIR "packages.config" +$MODULES_PACKAGES_CONFIG = Join-Path $MODULES_DIR "packages.config" + +# Should we use mono? +$UseMono = ""; +if($Mono.IsPresent) { + Write-Verbose -Message "Using the Mono based scripting engine." + $UseMono = "-mono" +} + +# Should we use the new Roslyn? +# $UseExperimental = ""; +$UseExperimental = "-experimental"; # Always use experimental +if($Experimental.IsPresent -and !($Mono.IsPresent)) { + Write-Verbose -Message "Using experimental version of Roslyn." + $UseExperimental = "-experimental" +} + +# Is this a dry run? +$UseDryRun = ""; +if($WhatIf.IsPresent) { + $UseDryRun = "-dryrun" +} + +# Make sure tools folder exists +if ((Test-Path $PSScriptRoot) -and !(Test-Path $TOOLS_DIR)) { + Write-Verbose -Message "Creating tools directory..." + New-Item -Path $TOOLS_DIR -Type directory | out-null +} + +# Make sure that packages.config exist. +if (!(Test-Path $PACKAGES_CONFIG)) { + Write-Verbose -Message "Downloading packages.config..." + try { (New-Object System.Net.WebClient).DownloadFile("http://cakebuild.net/download/bootstrapper/packages", $PACKAGES_CONFIG) } catch { + Throw "Could not download packages.config." + } +} + +# Try find NuGet.exe in path if not exists +if (!(Test-Path $NUGET_EXE)) { + Write-Verbose -Message "Trying to find nuget.exe in PATH..." + $existingPaths = $Env:Path -Split ';' | Where-Object { (![string]::IsNullOrEmpty($_)) -and (Test-Path $_ -PathType Container) } + $NUGET_EXE_IN_PATH = Get-ChildItem -Path $existingPaths -Filter "nuget.exe" | Select -First 1 + if ($NUGET_EXE_IN_PATH -ne $null -and (Test-Path $NUGET_EXE_IN_PATH.FullName)) { + Write-Verbose -Message "Found in PATH at $($NUGET_EXE_IN_PATH.FullName)." + $NUGET_EXE = $NUGET_EXE_IN_PATH.FullName + } +} + +# Try download NuGet.exe if not exists +if (!(Test-Path $NUGET_EXE)) { + Write-Verbose -Message "Downloading NuGet.exe..." + try { + (New-Object System.Net.WebClient).DownloadFile($NUGET_URL, $NUGET_EXE) + } catch { + Throw "Could not download NuGet.exe." + } +} + +# Save nuget.exe path to environment to be available to child processed +$ENV:NUGET_EXE = $NUGET_EXE + +# Restore tools from NuGet? +if(-Not $SkipToolPackageRestore.IsPresent) { + Push-Location + Set-Location $TOOLS_DIR + + # Check for changes in packages.config and remove installed tools if true. + [string] $md5Hash = MD5HashFile($PACKAGES_CONFIG) + if((!(Test-Path $PACKAGES_CONFIG_MD5)) -Or + ($md5Hash -ne (Get-Content $PACKAGES_CONFIG_MD5 ))) { + Write-Verbose -Message "Missing or changed package.config hash..." + Remove-Item * -Recurse -Exclude packages.config,nuget.exe + } + + Write-Verbose -Message "Restoring tools from NuGet..." + $NuGetOutput = Invoke-Expression "&`"$NUGET_EXE`" install -ExcludeVersion -OutputDirectory `"$TOOLS_DIR`"" + + if ($LASTEXITCODE -ne 0) { + Throw "An error occured while restoring NuGet tools." + } + else + { + $md5Hash | Out-File $PACKAGES_CONFIG_MD5 -Encoding "ASCII" + } + Write-Verbose -Message ($NuGetOutput | out-string) + + Pop-Location +} + +# Restore addins from NuGet +if (Test-Path $ADDINS_PACKAGES_CONFIG) { + Push-Location + Set-Location $ADDINS_DIR + + Write-Verbose -Message "Restoring addins from NuGet..." + $NuGetOutput = Invoke-Expression "&`"$NUGET_EXE`" install -ExcludeVersion -OutputDirectory `"$ADDINS_DIR`"" + + if ($LASTEXITCODE -ne 0) { + Throw "An error occured while restoring NuGet addins." + } + + Write-Verbose -Message ($NuGetOutput | out-string) + + Pop-Location +} + +# Restore modules from NuGet +if (Test-Path $MODULES_PACKAGES_CONFIG) { + Push-Location + Set-Location $MODULES_DIR + + Write-Verbose -Message "Restoring modules from NuGet..." + $NuGetOutput = Invoke-Expression "&`"$NUGET_EXE`" install -ExcludeVersion -OutputDirectory `"$MODULES_DIR`"" + + if ($LASTEXITCODE -ne 0) { + Throw "An error occured while restoring NuGet modules." + } + + Write-Verbose -Message ($NuGetOutput | out-string) + + Pop-Location +} + +# Make sure that Cake has been installed. +if (!(Test-Path $CAKE_EXE)) { + Throw "Could not find Cake.exe at $CAKE_EXE" +} + +# Start Cake +Write-Host "Running build script..." +Invoke-Expression "& `"$CAKE_EXE`" `"$Script`" -target=`"$Target`" -configuration=`"$Configuration`" -verbosity=`"$Verbosity`" $UseMono $UseDryRun $UseExperimental $ScriptArgs" +exit $LASTEXITCODE \ No newline at end of file diff --git a/tools/packages.config b/tools/packages.config new file mode 100644 index 0000000..ba021c5 --- /dev/null +++ b/tools/packages.config @@ -0,0 +1,4 @@ + + + +