diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..660f3ad --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# To learn more about .editorconfig see https://aka.ms/editorconfigdocs + +# C# or VB files +[*.{cs,vb}] +guidelines = 120 + +#### Core EditorConfig Options #### + +#Formatting - header template +file_header_template = ---------------------------------------------------------------\nCopyright (c) 2023 Planet Dotnet. All rights reserved.\nLicensed under the MIT License.\nSee License.txt in the project root for license information.\n--------------------------------------------------------------- + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + diff --git a/Authors/mabroukmahdhi.json b/Authors/mabroukmahdhi.json new file mode 100644 index 0000000..78d0165 --- /dev/null +++ b/Authors/mabroukmahdhi.json @@ -0,0 +1,19 @@ +{ + "firstName": "Mabrouk", + "lastName": "Mahdhi", + "stateOrRegion": "Darmstadt, Germany", + "emailAddress": "contact@mahdhi.com", + "tagOrBio": "is a Senior Technical Consultant who blogs, talks and develops all around mobile and web development.", + "webSite": "https://mahdhi.com", + "twitterHandle": "mabrouk_mahdhi", + "githubHandle": "mabroukmahdhi", + "gravatarHash": "1f5b179abb9b9f8a34a4a9799e205c96", + "feedUris": [ + "https://medium.com/feed/@mabroukmahdhi" + ], + "position": { + "lat": 49.873207, + "lon": 8.650779 + }, + "languageCode": "en" +} \ No newline at end of file diff --git a/PlanetDotnetAuthors/Models/Author.cs b/PlanetDotnet.Authors/Models/Authors/Author.cs similarity index 80% rename from PlanetDotnetAuthors/Models/Author.cs rename to PlanetDotnet.Authors/Models/Authors/Author.cs index 0d1e683..da6a3af 100644 --- a/PlanetDotnetAuthors/Models/Author.cs +++ b/PlanetDotnet.Authors/Models/Authors/Author.cs @@ -1,9 +1,16 @@ -using Newtonsoft.Json; +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using PlanetDotnet.Authors.Models.GeoPositions; -namespace PlanetDotnetAuthors.Models +namespace PlanetDotnet.Authors.Models.Authors { public class Author { diff --git a/PlanetDotnet.Authors/Models/Authors/Exceptions/AuthorDependencyException.cs b/PlanetDotnet.Authors/Models/Authors/Exceptions/AuthorDependencyException.cs new file mode 100644 index 0000000..b4d01ab --- /dev/null +++ b/PlanetDotnet.Authors/Models/Authors/Exceptions/AuthorDependencyException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; + +namespace PlanetDotnet.Authors.Models.Authors.Exceptions +{ + public class AuthorDependencyException : Exception + { + public AuthorDependencyException(Exception innerException) : + base(message: "Author dependency error occurred, contact support.", innerException) + { } + } +} diff --git a/PlanetDotnet.Authors/Models/Authors/Exceptions/AuthorServiceException.cs b/PlanetDotnet.Authors/Models/Authors/Exceptions/AuthorServiceException.cs new file mode 100644 index 0000000..e8e195f --- /dev/null +++ b/PlanetDotnet.Authors/Models/Authors/Exceptions/AuthorServiceException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; + +namespace PlanetDotnet.Authors.Models.Authors.Exceptions +{ + public class AuthorServiceException : Exception + { + public AuthorServiceException(Exception innerException) + : base(message: "Author service error occurred, contact support.", innerException) { } + } +} diff --git a/PlanetDotnet.Authors/Models/Authors/Exceptions/FailedAuthorStorageException.cs b/PlanetDotnet.Authors/Models/Authors/Exceptions/FailedAuthorStorageException.cs new file mode 100644 index 0000000..c41dc7f --- /dev/null +++ b/PlanetDotnet.Authors/Models/Authors/Exceptions/FailedAuthorStorageException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; + +namespace PlanetDotnet.Authors.Models.Authors.Exceptions +{ + public class FailedAuthorStorageException : Exception + { + public FailedAuthorStorageException(Exception innerException) + : base("Failed authors storage error occurred, contact support.", innerException) + { } + } +} diff --git a/PlanetDotnetAuthors/Models/GeoPosition.cs b/PlanetDotnet.Authors/Models/GeoPositions/GeoPosition.cs similarity index 53% rename from PlanetDotnetAuthors/Models/GeoPosition.cs rename to PlanetDotnet.Authors/Models/GeoPositions/GeoPosition.cs index ccc81c2..e5f6204 100644 --- a/PlanetDotnetAuthors/Models/GeoPosition.cs +++ b/PlanetDotnet.Authors/Models/GeoPositions/GeoPosition.cs @@ -1,6 +1,12 @@ -using Newtonsoft.Json; +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- -namespace PlanetDotnetAuthors.Models +using Newtonsoft.Json; + +namespace PlanetDotnet.Authors.Models.GeoPositions { public class GeoPosition { diff --git a/PlanetDotnet.Authors/PlanetDotnet.Authors.csproj b/PlanetDotnet.Authors/PlanetDotnet.Authors.csproj new file mode 100644 index 0000000..6b825fc --- /dev/null +++ b/PlanetDotnet.Authors/PlanetDotnet.Authors.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.1 + disable + + + + + + + + + + + + diff --git a/PlanetDotnetAuthors/AuthorsLoader.cs b/PlanetDotnet.Authors/Services/AuthorService.cs similarity index 68% rename from PlanetDotnetAuthors/AuthorsLoader.cs rename to PlanetDotnet.Authors/Services/AuthorService.cs index 3648351..095b5fc 100644 --- a/PlanetDotnetAuthors/AuthorsLoader.cs +++ b/PlanetDotnet.Authors/Services/AuthorService.cs @@ -1,22 +1,28 @@ -using Newtonsoft.Json; -using PlanetDotnetAuthors.Models; +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Newtonsoft.Json; +using PlanetDotnet.Authors.Models.Authors; -namespace PlanetDotnetAuthors +namespace PlanetDotnet.Authors.Services { - public static class AuthorsLoader + public static class AuthorService { public static async Task> GetAllAuthors() { var assembly = Assembly.GetExecutingAssembly(); var resourceNames = assembly.GetManifestResourceNames(); var authorsResourceNames = resourceNames.Where(res => - res.StartsWith("PlanetDotnetAuthors", StringComparison.OrdinalIgnoreCase) && + res.StartsWith("PlanetDotnet.Authors", StringComparison.OrdinalIgnoreCase) && res.EndsWith(".json", StringComparison.OrdinalIgnoreCase)); var authorsTasks = authorsResourceNames.Select(name => ReadAuthor(assembly, name)); diff --git a/PlanetDotnet.Tests.Unit/DeleteMe.cs b/PlanetDotnet.Tests.Unit/DeleteMe.cs new file mode 100644 index 0000000..bdf1bb0 --- /dev/null +++ b/PlanetDotnet.Tests.Unit/DeleteMe.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using Xunit; + +namespace PlanetDotnet.Tests.Unit +{ + public class DeleteMe + { + [Fact] + public void ShouldBeTrue() => Assert.True(true); + } +} \ No newline at end of file diff --git a/PlanetDotnet.Tests.Unit/PlanetDotnet.Tests.Unit.csproj b/PlanetDotnet.Tests.Unit/PlanetDotnet.Tests.Unit.csproj new file mode 100644 index 0000000..969426d --- /dev/null +++ b/PlanetDotnet.Tests.Unit/PlanetDotnet.Tests.Unit.csproj @@ -0,0 +1,32 @@ + + + + net7.0 + disable + disable + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.Exceptions.GetAll.cs b/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.Exceptions.GetAll.cs new file mode 100644 index 0000000..721acc1 --- /dev/null +++ b/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.Exceptions.GetAll.cs @@ -0,0 +1,89 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Moq; +using PlanetDotnet.Authors.Models.Authors.Exceptions; +using Xunit; + +namespace PlanetDotnet.Tests.Unit.Services.Foundations.Authors +{ + public partial class AuthorServiceTests + { + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowCriticalDependencyExceptionOnRetrieveAllIfDependencyErrorOccursAndLogIt( + Exception dependencyException) + { + // given + var failedStorageException = + new FailedAuthorStorageException(dependencyException); + + var expectedAuthorDependencyException = + new AuthorDependencyException(failedStorageException); + + this.authorBrokerMock.Setup(broker => + broker.GetAllAuthorsAsync()) + .Throws(dependencyException); + + // when + var retrieveAllAuthorsTask = + this.authorService.RetrieveAllAuthorsAsync(); + + // then + await Assert.ThrowsAsync(() => + retrieveAllAuthorsTask.AsTask()); + + this.authorBrokerMock.Verify(broker => + broker.GetAllAuthorsAsync(), + Times.Once); + + this.loggingBrokerMock.Verify(broker => + broker.LogCritical(It.Is(SameExceptionAs( + expectedAuthorDependencyException))), + Times.Once); + + this.authorBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowServiceExceptionOnRetrieveAllIfServiceErrorOccursAndLogItAsync() + { + // given + var serviceException = new Exception(); + + var expectedAuthorServiceException = + new AuthorServiceException(serviceException); + + this.authorBrokerMock.Setup(broker => + broker.GetAllAuthorsAsync()) + .ThrowsAsync(serviceException); + + // when + var retrievedAuthorTask = + this.authorService.RetrieveAllAuthorsAsync(); + + // then + await Assert.ThrowsAsync(() => + retrievedAuthorTask.AsTask()); + + this.authorBrokerMock.Verify(broker => + broker.GetAllAuthorsAsync(), + Times.Once); + + this.loggingBrokerMock.Verify(broker => + broker.LogError(It.Is(SameExceptionAs( + expectedAuthorServiceException))), + Times.Once); + + this.authorBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + } +} diff --git a/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.Logic.GetAll.cs b/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.Logic.GetAll.cs new file mode 100644 index 0000000..9d7e2ab --- /dev/null +++ b/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.Logic.GetAll.cs @@ -0,0 +1,44 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using PlanetDotnet.Authors.Models.Authors; +using Xunit; + +namespace PlanetDotnet.Tests.Unit.Services.Foundations.Authors +{ + public partial class AuthorServiceTests + { + [Fact] + public async Task ShouldRetrieveAllAuthorsAsync() + { + // given + IEnumerable randomAuthors = CreateRandomAuthors(); + IEnumerable storageAuthors = randomAuthors; + IEnumerable expectedAuthors = storageAuthors; + + this.authorBrokerMock.Setup(broker => + broker.GetAllAuthorsAsync()) + .ReturnsAsync(expectedAuthors); + + // when + var actualAuthors = + await this.authorService.RetrieveAllAuthorsAsync(); + + // then + actualAuthors.Should().BeEquivalentTo(expectedAuthors); + + this.authorBrokerMock.Verify(broker => + broker.GetAllAuthorsAsync(), + Times.Once()); + + this.authorBrokerMock.VerifyNoOtherCalls(); + } + } +} diff --git a/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.cs b/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.cs new file mode 100644 index 0000000..f107424 --- /dev/null +++ b/PlanetDotnet.Tests.Unit/Services/Foundations/Authors/AuthorServiceTests.cs @@ -0,0 +1,116 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq.Expressions; +using System.Net.Http; +using System.Reflection; +using System.Runtime.Serialization; +using System.Web.Http; +using Moq; +using Newtonsoft.Json; +using PlanetDotnet.Authors.Models.Authors; +using PlanetDotnet.Brokers.Authors; +using PlanetDotnet.Brokers.Loggings; +using PlanetDotnet.Services.Foundations.Authors; +using Tynamix.ObjectFiller; +using Xunit; + +namespace PlanetDotnet.Tests.Unit.Services.Foundations.Authors +{ + public partial class AuthorServiceTests + { + private readonly Mock authorBrokerMock; + private readonly Mock loggingBrokerMock; + private readonly IAuthorService authorService; + + public AuthorServiceTests() + { + this.authorBrokerMock = new Mock(); + this.loggingBrokerMock = new Mock(); + + this.authorService = new AuthorService( + authorBroker: this.authorBrokerMock.Object, + loggingBroker: this.loggingBrokerMock.Object); + } + + private static IEnumerable CreateRandomAuthors() => + CreateAuthorFiller().Create(count: GetRandomNumber()); + + private static Author CreateRandomAuthor() => + CreateAuthorFiller().Create(); + + private static Filler CreateAuthorFiller() => new(); + + private static int GetRandomNumber() => + new IntRange(min: 2, max: 15).GetValue(); + + private static Expression> SameExceptionAs(Exception expectedException) => + actualException => actualException.Message == expectedException.Message; + + private static TargetInvocationException GetTargetInvocationException() => + (TargetInvocationException)FormatterServices.GetUninitializedObject(typeof(TargetInvocationException)); + + private static Exception GetException() => + (Exception)FormatterServices.GetUninitializedObject(typeof(Exception)); + + private static string GetRandomString() => new MnemonicString().GetValue(); + + public static TheoryData DependencyExceptions() + { + string exceptionMessage = GetRandomString(); + + var exception = new Exception(exceptionMessage); + + var targetInvocationException = + new TargetInvocationException(exception); + + var argumentNullException = + new ArgumentNullException(); + + var invalidOperationException = + new InvalidOperationException(exceptionMessage); + + var aggregateException = + new AggregateException(); + + var operationCanceledException = + new OperationCanceledException(); + + var fileNotFoundException = + new FileNotFoundException(exceptionMessage); + + var directoryNotFoundException = + new DirectoryNotFoundException(exceptionMessage); + + var ioException = + new IOException(exceptionMessage); + + var jsonSerializationException = + new JsonSerializationException(); + + var jsonReaderException = + new JsonReaderException(); + + + return new TheoryData + { + targetInvocationException, + argumentNullException, + invalidOperationException, + aggregateException, + operationCanceledException, + fileNotFoundException, + directoryNotFoundException, + ioException, + jsonSerializationException, + jsonReaderException + }; + } + } +} diff --git a/PlanetDotnet.sln b/PlanetDotnet.sln index 0ecf4f3..550ffe7 100644 --- a/PlanetDotnet.sln +++ b/PlanetDotnet.sln @@ -3,14 +3,11 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.4.32804.182 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanetDotnet", "PlanetDotnet\PlanetDotnet.csproj", "{591E633A-5665-4D84-B4B6-02B627699AC4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanetDotnet", "PlanetDotnet\PlanetDotnet.csproj", "{AA1CF7A0-FF81-40FA-ABB2-519AF44C2460}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8FA44784-F2C3-4749-9272-38F4D8EA9398}" - ProjectSection(SolutionItems) = preProject - author-schema.json = author-schema.json - EndProjectSection +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanetDotnet.Tests.Unit", "PlanetDotnet.Tests.Unit\PlanetDotnet.Tests.Unit.csproj", "{ED070F5D-3C55-4E8F-B23A-381E09931D1C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanetDotnetAuthors", "PlanetDotnetAuthors\PlanetDotnetAuthors.csproj", "{57C40CD9-62CF-48D2-9A5E-C4CDD1CE6082}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanetDotnet.Authors", "PlanetDotnet.Authors\PlanetDotnet.Authors.csproj", "{0518A91D-95CA-4C21-BACF-E1E7E56D8754}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -18,14 +15,18 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {591E633A-5665-4D84-B4B6-02B627699AC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {591E633A-5665-4D84-B4B6-02B627699AC4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {591E633A-5665-4D84-B4B6-02B627699AC4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {591E633A-5665-4D84-B4B6-02B627699AC4}.Release|Any CPU.Build.0 = Release|Any CPU - {57C40CD9-62CF-48D2-9A5E-C4CDD1CE6082}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {57C40CD9-62CF-48D2-9A5E-C4CDD1CE6082}.Debug|Any CPU.Build.0 = Debug|Any CPU - {57C40CD9-62CF-48D2-9A5E-C4CDD1CE6082}.Release|Any CPU.ActiveCfg = Release|Any CPU - {57C40CD9-62CF-48D2-9A5E-C4CDD1CE6082}.Release|Any CPU.Build.0 = Release|Any CPU + {AA1CF7A0-FF81-40FA-ABB2-519AF44C2460}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA1CF7A0-FF81-40FA-ABB2-519AF44C2460}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA1CF7A0-FF81-40FA-ABB2-519AF44C2460}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA1CF7A0-FF81-40FA-ABB2-519AF44C2460}.Release|Any CPU.Build.0 = Release|Any CPU + {ED070F5D-3C55-4E8F-B23A-381E09931D1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED070F5D-3C55-4E8F-B23A-381E09931D1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED070F5D-3C55-4E8F-B23A-381E09931D1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED070F5D-3C55-4E8F-B23A-381E09931D1C}.Release|Any CPU.Build.0 = Release|Any CPU + {0518A91D-95CA-4C21-BACF-E1E7E56D8754}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0518A91D-95CA-4C21-BACF-E1E7E56D8754}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0518A91D-95CA-4C21-BACF-E1E7E56D8754}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0518A91D-95CA-4C21-BACF-E1E7E56D8754}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PlanetDotnet/.gitignore b/PlanetDotnet/.gitignore new file mode 100644 index 0000000..ff5b00c --- /dev/null +++ b/PlanetDotnet/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# 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 + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_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 + +# 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 ignoreable 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 +node_modules/ +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 + +# 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 + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# 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 \ No newline at end of file diff --git a/PlanetDotnet/Brokers/Authors/AuthorBroker.cs b/PlanetDotnet/Brokers/Authors/AuthorBroker.cs new file mode 100644 index 0000000..ad3d674 --- /dev/null +++ b/PlanetDotnet/Brokers/Authors/AuthorBroker.cs @@ -0,0 +1,19 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading.Tasks; +using PlanetDotnet.Authors.Models.Authors; +using PlanetDotnet.Authors.Services; + +namespace PlanetDotnet.Brokers.Authors +{ + public class AuthorBroker : IAuthorBroker + { + public async ValueTask> GetAllAuthorsAsync() => + await AuthorService.GetAllAuthors(); + } +} diff --git a/PlanetDotnet/Brokers/Authors/IAuthorBroker.cs b/PlanetDotnet/Brokers/Authors/IAuthorBroker.cs new file mode 100644 index 0000000..f7dfafb --- /dev/null +++ b/PlanetDotnet/Brokers/Authors/IAuthorBroker.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading.Tasks; +using PlanetDotnet.Authors.Models.Authors; + +namespace PlanetDotnet.Brokers.Authors +{ + public interface IAuthorBroker + { + ValueTask> GetAllAuthorsAsync(); + } +} diff --git a/PlanetDotnet/Brokers/DateTimes/DateTimeBroker.cs b/PlanetDotnet/Brokers/DateTimes/DateTimeBroker.cs new file mode 100644 index 0000000..2109e7d --- /dev/null +++ b/PlanetDotnet/Brokers/DateTimes/DateTimeBroker.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; + +namespace PlanetDotnet.Brokers.DateTimes +{ + public class DateTimeBroker : IDateTimeBroker + { + public DateTimeOffset GetCurrentDateTimeOffset() => + DateTimeOffset.UtcNow; + } +} \ No newline at end of file diff --git a/PlanetDotnet/Brokers/DateTimes/IDateTimeBroker.cs b/PlanetDotnet/Brokers/DateTimes/IDateTimeBroker.cs new file mode 100644 index 0000000..d4505c8 --- /dev/null +++ b/PlanetDotnet/Brokers/DateTimes/IDateTimeBroker.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; + +namespace PlanetDotnet.Brokers.DateTimes +{ + public interface IDateTimeBroker + { + DateTimeOffset GetCurrentDateTimeOffset(); + } +} \ No newline at end of file diff --git a/PlanetDotnet/Brokers/Feeds/FeedBroker.cs b/PlanetDotnet/Brokers/Feeds/FeedBroker.cs new file mode 100644 index 0000000..94ad3c9 --- /dev/null +++ b/PlanetDotnet/Brokers/Feeds/FeedBroker.cs @@ -0,0 +1,37 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.Net.Http; +using System.ServiceModel.Syndication; +using System.Threading.Tasks; +using System.Xml; + +namespace PlanetDotnet.Brokers.Feeds +{ + internal class FeedBroker : IFeedBroker + { + private readonly HttpClient httpClient; + + public FeedBroker(HttpClient httpClient) => + this.httpClient = httpClient; + + public async Task ReadFeedAsync(string feedUri) + { + var response = await httpClient.GetAsync(feedUri).ConfigureAwait(false); + using var feedStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Parse + }; + + using var reader = XmlReader.Create(feedStream, settings); + var feed = SyndicationFeed.Load(reader); + + return feed; + } + } +} diff --git a/PlanetDotnet/Brokers/Feeds/IFeedBroker.cs b/PlanetDotnet/Brokers/Feeds/IFeedBroker.cs new file mode 100644 index 0000000..8da0272 --- /dev/null +++ b/PlanetDotnet/Brokers/Feeds/IFeedBroker.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.ServiceModel.Syndication; +using System.Threading.Tasks; + +namespace PlanetDotnet.Brokers.Feeds +{ + public interface IFeedBroker + { + Task ReadFeedAsync(string feedUri); + } +} diff --git a/PlanetDotnet/Brokers/Loggings/ILoggingBroker.cs b/PlanetDotnet/Brokers/Loggings/ILoggingBroker.cs new file mode 100644 index 0000000..485f6a2 --- /dev/null +++ b/PlanetDotnet/Brokers/Loggings/ILoggingBroker.cs @@ -0,0 +1,21 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; + +namespace PlanetDotnet.Brokers.Loggings +{ + public interface ILoggingBroker + { + void LogInformation(string message); + void LogTrace(string message); + void LogDebug(string message); + void LogWarning(string message); + void LogError(Exception exception); + void LogError(Exception exception, string message); + void LogCritical(Exception exception); + } +} diff --git a/PlanetDotnet/Brokers/Loggings/LoggingBroker.cs b/PlanetDotnet/Brokers/Loggings/LoggingBroker.cs new file mode 100644 index 0000000..5dd375e --- /dev/null +++ b/PlanetDotnet/Brokers/Loggings/LoggingBroker.cs @@ -0,0 +1,40 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using Microsoft.Extensions.Logging; + +namespace PlanetDotnet.Brokers.Loggings +{ + public class LoggingBroker : ILoggingBroker + { + private readonly ILogger logger; + + public LoggingBroker(ILogger logger) => + this.logger = logger; + + public void LogInformation(string message) => + this.logger.LogInformation(message); + + public void LogTrace(string message) => + this.logger.LogTrace(message); + + public void LogDebug(string message) => + this.logger.LogDebug(message); + + public void LogWarning(string message) => + this.logger.LogWarning(message); + + public void LogError(Exception exception) => + this.logger.LogError(exception.Message, exception); + + public void LogError(Exception exception, string message) => + this.logger.LogError(exception, message); + + public void LogCritical(Exception exception) => + this.logger.LogCritical(exception, exception.Message); + } +} diff --git a/PlanetDotnet/Brokers/Serializations/ISerializationBroker.cs b/PlanetDotnet/Brokers/Serializations/ISerializationBroker.cs new file mode 100644 index 0000000..65a601d --- /dev/null +++ b/PlanetDotnet/Brokers/Serializations/ISerializationBroker.cs @@ -0,0 +1,18 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.IO; +using System.ServiceModel.Syndication; +using System.Threading.Tasks; + +namespace PlanetDotnet.Brokers.Serializations +{ + public interface ISerializationBroker + { + ValueTask SerializeFeedAsync(SyndicationFeed feed); + SyndicationFeed DeserializeFeed(Stream feedStream); + } +} diff --git a/PlanetDotnet/Brokers/Serializations/SerializationBroker.cs b/PlanetDotnet/Brokers/Serializations/SerializationBroker.cs new file mode 100644 index 0000000..5657951 --- /dev/null +++ b/PlanetDotnet/Brokers/Serializations/SerializationBroker.cs @@ -0,0 +1,81 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using System.IO; +using System.Linq; +using System.ServiceModel.Syndication; +using System.Threading.Tasks; +using System.Xml; + +namespace PlanetDotnet.Brokers.Serializations +{ + internal class SerializationBroker : ISerializationBroker + { + public SyndicationFeed DeserializeFeed(Stream feedStream) + { + feedStream.Position = 0; + using var xmlReader = XmlReader.Create(feedStream, new XmlReaderSettings + { + Async = true + }); + + var feed = SyndicationFeed.Load(xmlReader); + + return feed; + } + + public async ValueTask SerializeFeedAsync(SyndicationFeed feed) + { + var memoryStream = new MemoryStream(); + using var xmlWriter = XmlWriter.Create(memoryStream, new XmlWriterSettings + { + Async = true, + Indent = true + }); + + xmlWriter.WriteStartDocument(); + xmlWriter.WriteStartElement("rss"); + xmlWriter.WriteAttributeString("version", "2.0"); + xmlWriter.WriteStartElement("channel"); + xmlWriter.WriteElementString("title", feed.Title?.Text ?? string.Empty); + xmlWriter.WriteElementString("link", feed.Links.FirstOrDefault()?.Uri.AbsoluteUri ?? string.Empty); + xmlWriter.WriteElementString("description", feed.Description?.Text ?? string.Empty); + + if (feed.Language != null) + xmlWriter.WriteElementString("language", feed.Language); + + if (feed.LastUpdatedTime != DateTimeOffset.MinValue) + xmlWriter.WriteElementString("lastBuildDate", feed.LastUpdatedTime.ToString("R")); + + // Write items + foreach (var item in feed.Items) + { + xmlWriter.WriteStartElement("item"); + + xmlWriter.WriteElementString("title", item.Title?.Text ?? string.Empty); + xmlWriter.WriteElementString("link", item.Links.FirstOrDefault()?.Uri.AbsoluteUri ?? string.Empty); + xmlWriter.WriteElementString("description", item.Summary?.Text ?? string.Empty); + + if (item.Id != null) + xmlWriter.WriteElementString("guid", item.Id); + + if (item.PublishDate != DateTimeOffset.MinValue) + xmlWriter.WriteElementString("pubDate", item.PublishDate.ToString("R")); + + xmlWriter.WriteEndElement(); // item + } + + xmlWriter.WriteEndElement(); // channel + xmlWriter.WriteEndElement(); // rss + + await xmlWriter.FlushAsync(); + memoryStream.Seek(0, SeekOrigin.Begin); + + return memoryStream; + } + } +} diff --git a/PlanetDotnet/Brokers/Storages/IStorageBroker.cs b/PlanetDotnet/Brokers/Storages/IStorageBroker.cs new file mode 100644 index 0000000..d52d8d4 --- /dev/null +++ b/PlanetDotnet/Brokers/Storages/IStorageBroker.cs @@ -0,0 +1,18 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.IO; +using System.Threading.Tasks; + +namespace PlanetDotnet.Brokers.Storages +{ + public interface IStorageBroker + { + ValueTask InitializeAsync(); + ValueTask UploadBlobAsync(string language, Stream content); + ValueTask ReadBlobAsync(string language); + } +} diff --git a/PlanetDotnet/Brokers/Storages/StorageBroker.cs b/PlanetDotnet/Brokers/Storages/StorageBroker.cs new file mode 100644 index 0000000..19424d9 --- /dev/null +++ b/PlanetDotnet/Brokers/Storages/StorageBroker.cs @@ -0,0 +1,57 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using System.IO; +using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; + +namespace PlanetDotnet.Brokers.Storages +{ + public class StorageBroker : IStorageBroker + { + private readonly BlobContainerClient blobContainerClient; + private const string BlobContainerName = "feeds"; + private const string FeedBlobStorageKey = "FeedBlobStorage"; + private const string BlobName = "feed.{0}.rss"; + + public StorageBroker() + { + var blobConnectString = Environment.GetEnvironmentVariable( + variable: FeedBlobStorageKey, + target: EnvironmentVariableTarget.Process); + + this.blobContainerClient = new BlobContainerClient( + connectionString: blobConnectString, + blobContainerName: BlobContainerName); + } + + public async ValueTask InitializeAsync() + { + await this.blobContainerClient.CreateIfNotExistsAsync(); + await this.blobContainerClient.SetAccessPolicyAsync(PublicAccessType.Blob); + } + + public async ValueTask UploadBlobAsync(string language, Stream content) + { + var blobName = string.Format(BlobName, language); + + var blobClient = this.blobContainerClient.GetBlobClient(blobName); + + await blobClient.UploadAsync(content, overwrite: true); + } + + public async ValueTask ReadBlobAsync(string language) + { + var blobName = string.Format(BlobName, language); + var blobClient = this.blobContainerClient.GetBlobClient(blobName); + + var response = await blobClient.DownloadAsync(); + return response.Value.Content; + } + } +} diff --git a/PlanetDotnet/Extensions/ExceptionExtensions.cs b/PlanetDotnet/Extensions/ExceptionExtensions.cs new file mode 100644 index 0000000..a563f30 --- /dev/null +++ b/PlanetDotnet/Extensions/ExceptionExtensions.cs @@ -0,0 +1,23 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; + +namespace PlanetDotnet.Extensions +{ + internal static class ExceptionExtensions + { + public static TException WithData( + this TException exception, + string key, + object value) + where TException : Exception + { + exception.Data[key] = value; + return exception; + } + } +} diff --git a/PlanetDotnet/Extensions/SyndicationItemExtensions.cs b/PlanetDotnet/Extensions/SyndicationItemExtensions.cs index 5cb476d..716ba08 100644 --- a/PlanetDotnet/Extensions/SyndicationItemExtensions.cs +++ b/PlanetDotnet/Extensions/SyndicationItemExtensions.cs @@ -1,3 +1,9 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + using System.Linq; using System.ServiceModel.Syndication; @@ -45,4 +51,5 @@ public static string ToHtml(this SyndicationContent content) return content.ToString(); } } -} \ No newline at end of file + +} diff --git a/PlanetDotnet/Functions/AuthorFunctions.cs b/PlanetDotnet/Functions/AuthorFunctions.cs new file mode 100644 index 0000000..6067cc5 --- /dev/null +++ b/PlanetDotnet/Functions/AuthorFunctions.cs @@ -0,0 +1,50 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.Threading.Tasks; +using System.Web.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using PlanetDotnet.Brokers.Loggings; +using PlanetDotnet.Services.Foundations.Authors; + +namespace PlanetDotnet.Functions +{ + public class AuthorFunctions + { + private readonly ILoggingBroker loggingBroker; + private readonly IAuthorService authorService; + + public AuthorFunctions( + ILoggingBroker loggingBroker, + IAuthorService authorService) + { + this.loggingBroker = loggingBroker; + this.authorService = authorService; + } + + [FunctionName("GetAllAuthors")] + public async Task GetAllAuthorsAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "authors")] HttpRequest req) + { + try + { + this.loggingBroker.LogInformation("Started loading all authors."); + + var authors = await this.authorService.RetrieveAllAuthorsAsync(); + + this.loggingBroker.LogInformation("Finished loading all authors."); + return new OkObjectResult(authors); + } + catch + { + return new InternalServerErrorResult(); + } + } + } +} diff --git a/PlanetDotnet/Functions/FeedFunctions.cs b/PlanetDotnet/Functions/FeedFunctions.cs new file mode 100644 index 0000000..dc111aa --- /dev/null +++ b/PlanetDotnet/Functions/FeedFunctions.cs @@ -0,0 +1,41 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs; +using Microsoft.Extensions.Logging; +using PlanetDotnet.Services.Processings.Feeds; + +namespace PlanetDotnet.Functions +{ + public class FeedFunctions + { + private readonly IFeedProcessingService feedProcessingService; + + public FeedFunctions(IFeedProcessingService feedProcessingService) => + this.feedProcessingService = feedProcessingService; + + [FunctionName("LoadFeedsFunction")] + public async Task Run( + [TimerTrigger("0 0 */1 * * *", RunOnStartup = true)] TimerInfo myTimer, + ILogger log) + { + try + { + log.LogInformation($"Load feeds Timer trigger function executed at: {DateTime.Now}"); + + await feedProcessingService.ProcessFeedLoadingAsync(); + + log.LogInformation($"Load feeds Finished at: {DateTime.Now}"); + } + catch (Exception ex) + { + log.LogError(ex, "Loading feeds could'nt be processed."); + } + } + } +} diff --git a/PlanetDotnet/Infrastructure/CombinedFeedSource.cs b/PlanetDotnet/Infrastructure/CombinedFeedSource.cs deleted file mode 100644 index 7e77cb3..0000000 --- a/PlanetDotnet/Infrastructure/CombinedFeedSource.cs +++ /dev/null @@ -1,213 +0,0 @@ -using Microsoft.Extensions.Logging; -using PlanetDotnet.Extensions; -using PlanetDotnetAuthors.Models; -using Polly; -using Polly.Retry; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.ServiceModel.Syndication; -using System.Threading.Tasks; -using System.Xml; - -namespace PlanetDotnet.Infrastructure -{ - public class CombinedFeedSource - { - private static HttpClient _httpClient; - private static AsyncRetryPolicy _retryPolicy; - private readonly IEnumerable _authors; - private readonly ILogger _logger; - private readonly string _rssFeedTitle; - private readonly string _rssFeedDescription; - private readonly string _rssFeedUrl; - private readonly string _rssFeedImageUrl; - - public CombinedFeedSource( - IEnumerable authors, - ILogger logger, - string rssFeedTitle, - string rssFeedDescription, - string rssFeedUrl, - string rssFeedImageUrl) - { - EnsureHttpClient(); - - if (_retryPolicy == null) - { - // retry policy with max 2 retries, delay by x*x^1.2 where x is retry attempt - // this will ensure we don't retry too quickly - _retryPolicy = Policy.Handle() - .WaitAndRetryAsync(2, retry => TimeSpan.FromSeconds(retry * Math.Pow(1.2, retry))); - } - - _authors = authors; - _logger = logger; - _rssFeedTitle = rssFeedTitle; - _rssFeedDescription = rssFeedDescription; - _rssFeedUrl = rssFeedUrl; - _rssFeedImageUrl = rssFeedImageUrl; - } - - private void EnsureHttpClient() - { - if (_httpClient == null) - { - _httpClient = new HttpClient(); - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("PlanetDotnet", $"{GetType().Assembly.GetName().Version}")); - _httpClient.Timeout = TimeSpan.FromSeconds(15); - - ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls13; - } - } - - public async Task LoadFeed(int? numberOfItems, string languageCode = "mixed") - { - IEnumerable tamarins; - if (languageCode == null || languageCode == "mixed") // use all tamarins - { - tamarins = _authors; - } - else - { - tamarins = _authors.Where(t => t.FeedLanguageCode == languageCode); - } - - var feedTasks = tamarins.SelectMany(t => TryReadFeeds(t)).ToArray(); - - _logger?.LogInformation($"Loading feed for language: {languageCode} for {feedTasks.Length} authors"); - - var syndicationItems = await Task.WhenAll(feedTasks).ConfigureAwait(false); - var combinedFeed = GetCombinedFeed(syndicationItems.SelectMany(f => f), languageCode, tamarins, numberOfItems); - return combinedFeed; - } - - private IEnumerable>> TryReadFeeds(Author tamarin) - { - return tamarin.FeedUris.Select(uri => TryReadFeed(tamarin, uri.AbsoluteUri)); - } - - private async Task> TryReadFeed(Author tamarin, string feedUri) - { - try - { - return await _retryPolicy.ExecuteAsync(context => ReadFeed(feedUri), new Context(feedUri)).ConfigureAwait(false); - } - catch (FeedReadFailedException ex) - { - _logger.LogError(ex, $"{tamarin.FirstName} {tamarin.LastName}'s feed of {ex.Data["FeedUri"]} failed to load."); - } - - return new SyndicationItem[0]; - } - - private async Task> ReadFeed(string feedUri) - { - HttpResponseMessage response; - try - { - _logger?.LogInformation($"Loading feed {feedUri}"); - response = await _httpClient.GetAsync(feedUri).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - { - using var feedStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var reader = XmlReader.Create(feedStream); - var feed = SyndicationFeed.Load(reader); - var filteredItems = feed.Items - .Where(item => item.ApplyDefaultFilter()); - - return filteredItems; - } - } - catch (HttpRequestException hex) - { - throw new FeedReadFailedException("Loading remote syndication feed failed", hex) - .WithData("FeedUri", feedUri); - } - catch (WebException ex) - { - throw new FeedReadFailedException("Loading remote syndication feed timed out", ex) - .WithData("FeedUri", feedUri); - } - catch (XmlException ex) - { - throw new FeedReadFailedException("Failed parsing remote syndication feed", ex) - .WithData("FeedUri", feedUri); - } - catch (TaskCanceledException ex) - { - throw new FeedReadFailedException("Reading feed timed out", ex) - .WithData("FeedUri", feedUri); - } - catch (OperationCanceledException opcex) - { - throw new FeedReadFailedException("Reading feed timed out", opcex) - .WithData("FeedUri", feedUri); - } - - throw new FeedReadFailedException("Loading remote syndication feed failed.") - .WithData("FeedUri", feedUri) - .WithData("HttpStatusCode", (int)response.StatusCode); - } - - private SyndicationFeed GetCombinedFeed(IEnumerable items, string languageCode, - IEnumerable tamarins, int? numberOfItems) - { - DateTimeOffset GetMaxTime(SyndicationItem item) - { - return new[] { item.PublishDate.UtcDateTime, item.LastUpdatedTime.UtcDateTime }.Max(); - } - - var orderedItems = items - .Where(item => - GetMaxTime(item) <= DateTimeOffset.UtcNow) - .OrderByDescending(item => GetMaxTime(item)); - - var feed = new SyndicationFeed( - _rssFeedTitle, - _rssFeedDescription, - new Uri(_rssFeedUrl), - numberOfItems.HasValue ? orderedItems.Take(numberOfItems.Value) : orderedItems) - { - ImageUrl = new Uri(_rssFeedImageUrl), - Copyright = new TextSyndicationContent("The copyright for each post is retained by its author."), - Language = languageCode, - LastUpdatedTime = DateTimeOffset.UtcNow - }; - - foreach (var tamarin in tamarins) - { - feed.Contributors.Add(new SyndicationPerson( - tamarin.EmailAddress, $"{tamarin.FirstName} {tamarin.LastName}", tamarin.WebSite.ToString())); - } - - return feed; - } - } - - public class FeedReadFailedException : Exception - { - public FeedReadFailedException(string message) - : base(message) - { - } - - public FeedReadFailedException(string message, Exception inner) - : base(message, inner) - { - } - } - - internal static class ExceptionExtensions - { - public static TException WithData(this TException exception, string key, object value) where TException : Exception - { - exception.Data[key] = value; - return exception; - } - } -} \ No newline at end of file diff --git a/PlanetDotnet/LoadFeedsFunction.cs b/PlanetDotnet/LoadFeedsFunction.cs deleted file mode 100644 index 3b8b51e..0000000 --- a/PlanetDotnet/LoadFeedsFunction.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Microsoft.Azure.WebJobs; -using Microsoft.Extensions.Logging; -using PlanetDotnet.Infrastructure; -using PlanetDotnetAuthors; -using System; -using System.IO; -using System.Linq; -using System.ServiceModel.Syndication; -using System.Threading.Tasks; -using System.Xml; - -namespace PlanetDotnet -{ - public static class LoadFeedsFunction - { - [FunctionName("LoadFeedsFunction")] - public static async Task Run( - [TimerTrigger("0 0 */1 * * *", RunOnStartup = true)] TimerInfo myTimer, - ILogger log) - { - log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}"); - - var rssFeedTitle = GetEnvironmentVariable("RssFeedTitle"); - var rssFeedDescription = GetEnvironmentVariable("RssFeedDescription"); - var rssFeedUrl = GetEnvironmentVariable("RssFeedUrl"); - var rssFeedImageUrl = GetEnvironmentVariable("RssFeedImageUrl"); - - var authors = await AuthorsLoader.GetAllAuthors(); - var languages = authors.Select(author => author.FeedLanguageCode).Distinct().ToList(); - languages.Add("mixed"); - var feedSource = - new CombinedFeedSource( - authors, - log, - rssFeedTitle, - rssFeedDescription, - rssFeedUrl, - rssFeedImageUrl); - - var blobConnectString = GetEnvironmentVariable("FeedBlobStorage"); - var container = new BlobContainerClient(blobConnectString, "feeds"); - await container.CreateIfNotExistsAsync(); - await container.SetAccessPolicyAsync(PublicAccessType.Blob); - - foreach (var language in languages) - { - log.LogInformation($"Loading {language} combined author feed"); - var feed = await feedSource.LoadFeed(null, language); - using var stream = await SerializeFeed(feed); - await UploadBlob(container, stream, language, log); - } - } - - private static async Task UploadBlob(BlobContainerClient container, Stream feedStream, string language, ILogger log) - { - var feedName = $"feed.{language}.rss"; - var blob = container.GetBlobClient(feedName); - await blob.UploadAsync(feedStream, overwrite: true); - - log.LogInformation($"Uploaded {feedName} to {blob.Uri}"); - } - - private static async Task SerializeFeed(SyndicationFeed feed) - { - var memoryStream = new MemoryStream(); - using var xmlWriter = XmlWriter.Create(memoryStream, new XmlWriterSettings - { - Async = true - }); - - var rssFormatter = new Rss20FeedFormatter(feed); - rssFormatter.WriteTo(xmlWriter); - await xmlWriter.FlushAsync(); - - memoryStream.Seek(0, SeekOrigin.Begin); - - return memoryStream; - } - - private static string GetEnvironmentVariable(string name) - { - return Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process); - } - } -} diff --git a/PlanetDotnet/Models/Feeds/Exceptions/FailedFeedException.cs b/PlanetDotnet/Models/Feeds/Exceptions/FailedFeedException.cs new file mode 100644 index 0000000..0bf9409 --- /dev/null +++ b/PlanetDotnet/Models/Feeds/Exceptions/FailedFeedException.cs @@ -0,0 +1,21 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; + +namespace PlanetDotnet.Models.Feeds.Exceptions +{ + public class FailedFeedException : Exception + { + public FailedFeedException(string message) + : base(message) + { } + + public FailedFeedException(string message, Exception inner) + : base(message, inner) + { } + } +} diff --git a/PlanetDotnet/PlanetDotnet.csproj b/PlanetDotnet/PlanetDotnet.csproj index 1f7ca27..69d5a8c 100644 --- a/PlanetDotnet/PlanetDotnet.csproj +++ b/PlanetDotnet/PlanetDotnet.csproj @@ -1,16 +1,20 @@ - + - netcoreapp3.1 - v3 + net6.0 + v4 - - - - + - + + + + + + + + @@ -21,4 +25,4 @@ Never - \ No newline at end of file + diff --git a/PlanetDotnet/Properties/launchSettings.json b/PlanetDotnet/Properties/launchSettings.json new file mode 100644 index 0000000..7076a81 --- /dev/null +++ b/PlanetDotnet/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "PlanetDotnet": { + "commandName": "Project", + "commandLineArgs": "--port 7287", + "launchBrowser": false + } + } +} \ No newline at end of file diff --git a/PlanetDotnet/Properties/serviceDependencies.json b/PlanetDotnet/Properties/serviceDependencies.json index fcc92d1..df4dcc9 100644 --- a/PlanetDotnet/Properties/serviceDependencies.json +++ b/PlanetDotnet/Properties/serviceDependencies.json @@ -1,5 +1,8 @@ { "dependencies": { + "appInsights1": { + "type": "appInsights" + }, "storage1": { "type": "storage", "connectionId": "AzureWebJobsStorage" diff --git a/PlanetDotnet/Properties/serviceDependencies.local.json b/PlanetDotnet/Properties/serviceDependencies.local.json index 155d87e..b804a28 100644 --- a/PlanetDotnet/Properties/serviceDependencies.local.json +++ b/PlanetDotnet/Properties/serviceDependencies.local.json @@ -1,5 +1,8 @@ { "dependencies": { + "appInsights1": { + "type": "appInsights.sdk" + }, "storage1": { "type": "storage.emulator", "connectionId": "AzureWebJobsStorage" diff --git a/PlanetDotnet/Services/Foundations/Authors/AuthorService.Exceptions.cs b/PlanetDotnet/Services/Foundations/Authors/AuthorService.Exceptions.cs new file mode 100644 index 0000000..b7e5318 --- /dev/null +++ b/PlanetDotnet/Services/Foundations/Authors/AuthorService.Exceptions.cs @@ -0,0 +1,126 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using Newtonsoft.Json; +using PlanetDotnet.Authors.Models.Authors; +using PlanetDotnet.Authors.Models.Authors.Exceptions; + +namespace PlanetDotnet.Services.Foundations.Authors +{ + public partial class AuthorService + { + private delegate ValueTask> ReturningAuthorsFunction(); + + private async ValueTask> TryCatch(ReturningAuthorsFunction returningAuthorsFunction) + { + try + { + return await returningAuthorsFunction(); + } + catch (ArgumentNullException argumentNullException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(argumentNullException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (InvalidOperationException invalidOperationException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(invalidOperationException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (AggregateException aggregateException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(aggregateException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (OperationCanceledException operationCanceledException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(operationCanceledException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (FileNotFoundException fileNotFoundException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(fileNotFoundException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (DirectoryNotFoundException directoryNotFoundException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(directoryNotFoundException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (IOException ioException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(ioException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (JsonSerializationException jsonSerializationException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(jsonSerializationException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (JsonReaderException jsonReaderException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(jsonReaderException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (TargetInvocationException targetInvocationException) + { + var failedAuthorStorageException = + new FailedAuthorStorageException(targetInvocationException); + + throw CreateAndLogDependencyException(failedAuthorStorageException); + } + catch (Exception exception) + { + throw CreateAndLogServiceException(exception); + } + } + + private AuthorDependencyException CreateAndLogDependencyException( + Exception exception) + { + var authorDependencyException = + new AuthorDependencyException(exception); + + this.loggingBroker.LogCritical(authorDependencyException); + + return authorDependencyException; + } + + private AuthorServiceException CreateAndLogServiceException( + Exception exception) + { + var studentServiceException = + new AuthorServiceException(exception); + + this.loggingBroker.LogError(studentServiceException); + + return studentServiceException; + } + } +} diff --git a/PlanetDotnet/Services/Foundations/Authors/AuthorService.cs b/PlanetDotnet/Services/Foundations/Authors/AuthorService.cs new file mode 100644 index 0000000..eead7f5 --- /dev/null +++ b/PlanetDotnet/Services/Foundations/Authors/AuthorService.cs @@ -0,0 +1,31 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading.Tasks; +using PlanetDotnet.Authors.Models.Authors; +using PlanetDotnet.Brokers.Authors; +using PlanetDotnet.Brokers.Loggings; + +namespace PlanetDotnet.Services.Foundations.Authors +{ + public partial class AuthorService : IAuthorService + { + private readonly IAuthorBroker authorBroker; + private readonly ILoggingBroker loggingBroker; + + public AuthorService( + IAuthorBroker authorBroker, + ILoggingBroker loggingBroker) + { + this.authorBroker = authorBroker; + this.loggingBroker = loggingBroker; + } + + public ValueTask> RetrieveAllAuthorsAsync() => + TryCatch(async ()=> await this.authorBroker.GetAllAuthorsAsync()); + } +} diff --git a/PlanetDotnet/Services/Foundations/Authors/IAuthorService.cs b/PlanetDotnet/Services/Foundations/Authors/IAuthorService.cs new file mode 100644 index 0000000..2d3cc4c --- /dev/null +++ b/PlanetDotnet/Services/Foundations/Authors/IAuthorService.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading.Tasks; +using PlanetDotnet.Authors.Models.Authors; + +namespace PlanetDotnet.Services.Foundations.Authors +{ + public interface IAuthorService + { + ValueTask> RetrieveAllAuthorsAsync(); + } +} diff --git a/PlanetDotnet/Services/Foundations/Feeds/FeedService.cs b/PlanetDotnet/Services/Foundations/Feeds/FeedService.cs new file mode 100644 index 0000000..2929c59 --- /dev/null +++ b/PlanetDotnet/Services/Foundations/Feeds/FeedService.cs @@ -0,0 +1,215 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.ServiceModel.Syndication; +using System.Threading.Tasks; +using System.Xml; +using PlanetDotnet.Authors.Models.Authors; +using PlanetDotnet.Brokers.Authors; +using PlanetDotnet.Brokers.Feeds; +using PlanetDotnet.Brokers.Loggings; +using PlanetDotnet.Extensions; +using PlanetDotnet.Models.Feeds.Exceptions; +using Polly; +using Polly.Retry; + +namespace PlanetDotnet.Services.Foundations.Feeds +{ + public class FeedService : IFeedService + { + private readonly HttpClient httpClient; + private readonly AsyncRetryPolicy retryPolicy; + private readonly IAuthorBroker authorBroker; + private readonly ILoggingBroker loggingBroker; + private readonly IFeedBroker feedBroker; + + private const string RssFeedTitleKey = "RssFeedTitle"; + private const string RssFeedDescriptionKey = "RssFeedDescription"; + private const string RssFeedUrlKey = "RssFeedUrl"; + private const string RssFeedImageUrlKey = "RssFeedImageUrl"; + + public FeedService( + HttpClient httpClient, + IFeedBroker feedBroker, + IAuthorBroker authorBroker, + ILoggingBroker loggingBroker) + { + this.httpClient = httpClient; + this.feedBroker = feedBroker; + this.authorBroker = authorBroker; + this.loggingBroker = loggingBroker; + + EnsureHttpClient(); + + this.retryPolicy ??= Policy.Handle() + .WaitAndRetryAsync(2, retry => TimeSpan.FromSeconds(retry * Math.Pow(1.2, retry))); + } + + public async Task LoadFeedAsync(int? numberOfItems, string languageCode = "mixed") + { + var authors = await this.authorBroker.GetAllAuthorsAsync(); + + IEnumerable languageAuthors; + + if (languageCode == null || languageCode == "mixed") // use all tamarins + { + languageAuthors = authors; + } + else + { + languageAuthors = authors.Where(t => t.FeedLanguageCode == languageCode); + } + + var feedTasks = languageAuthors.SelectMany(t => TryReadFeeds(t)).ToArray(); + + this.loggingBroker.LogInformation($"Loading feed for language: {languageCode} for {feedTasks.Length} authors"); + + var syndicationItems = await Task.WhenAll(feedTasks).ConfigureAwait(false); + + var combinedFeed = GetCombinedFeed(syndicationItems.SelectMany(f => f), languageCode, languageAuthors, numberOfItems); + + return combinedFeed; + } + + private IEnumerable>> TryReadFeeds(Author tamarin) + { + return tamarin.FeedUris.Select(uri => TryReadFeed(tamarin, uri.AbsoluteUri)); + } + + private async Task> TryReadFeed(Author tamarin, string feedUri) + { + try + { + return await retryPolicy.ExecuteAsync(context => ReadFeed(feedUri), new Context(feedUri)).ConfigureAwait(false); + } + catch (FailedFeedException ex) + { + loggingBroker.LogError(ex, $"{tamarin.FirstName} {tamarin.LastName}'s feed of {ex.Data["FeedUri"]} failed to load."); + } + + return Array.Empty(); + } + + private async Task> ReadFeed(string feedUri) + { + try + { + loggingBroker.LogInformation($"Loading feed {feedUri}"); + + var feed = await feedBroker.ReadFeedAsync(feedUri); + + return feed.Items; + } + catch (HttpRequestException hex) + { + throw new FailedFeedException("Loading remote syndication feed failed", hex) + .WithData("FeedUri", feedUri); + } + catch (WebException ex) + { + throw new FailedFeedException("Loading remote syndication feed timed out", ex) + .WithData("FeedUri", feedUri); + } + catch (XmlException ex) + { + throw new FailedFeedException("Failed parsing remote syndication feed", ex) + .WithData("FeedUri", feedUri); + } + catch (TaskCanceledException ex) + { + throw new FailedFeedException("Reading feed timed out", ex) + .WithData("FeedUri", feedUri); + } + catch (OperationCanceledException opcex) + { + throw new FailedFeedException("Reading feed timed out", opcex) + .WithData("FeedUri", feedUri); + } + + throw new FailedFeedException("Loading remote syndication feed failed.") + .WithData("FeedUri", feedUri); + } + + private SyndicationFeed GetCombinedFeed(IEnumerable items, string languageCode, + IEnumerable authors, int? numberOfItems) + { + + var rssFeedTitle = Environment.GetEnvironmentVariable( + variable: RssFeedTitleKey, + target: EnvironmentVariableTarget.Process); + + var rssFeedDescription = Environment.GetEnvironmentVariable( + variable: RssFeedDescriptionKey, + target: EnvironmentVariableTarget.Process); + + var rssFeedUrl = Environment.GetEnvironmentVariable( + variable: RssFeedUrlKey, + target: EnvironmentVariableTarget.Process); + + var rssFeedImageUrl = Environment.GetEnvironmentVariable( + variable: RssFeedImageUrlKey, + target: EnvironmentVariableTarget.Process); + + var orderedItems = items + .Where(item => + GetMaxTime(item) <= DateTimeOffset.UtcNow) + .OrderByDescending(item => GetMaxTime(item)); + + var feed = new SyndicationFeed( + rssFeedTitle, + rssFeedDescription, + new Uri(rssFeedUrl), + numberOfItems.HasValue ? orderedItems.Take(numberOfItems.Value) : orderedItems) + { + ImageUrl = new Uri(rssFeedImageUrl), + Copyright = new TextSyndicationContent("The copyright for each post is retained by its author."), + Language = languageCode, + LastUpdatedTime = DateTimeOffset.UtcNow + }; + + foreach (var author in authors) + { + feed.Contributors.Add(new SyndicationPerson( + author.EmailAddress, $"{author.FirstName} {author.LastName}", author.WebSite.ToString())); + } + + return feed; + } + + private static DateTimeOffset GetMaxTime(SyndicationItem item) + { + try + { + return new[] { item.PublishDate.UtcDateTime, item.LastUpdatedTime.UtcDateTime }.Max(); + } + catch + { + return item.PublishDate.UtcDateTime; + } + } + + private void EnsureHttpClient() + { + this.httpClient.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue( + productName: "PlanetDotnet", + productVersion: $"{GetType().Assembly.GetName().Version}")); + + this.httpClient.Timeout = TimeSpan.FromSeconds(15); + + ServicePointManager.SecurityProtocol = + SecurityProtocolType.Tls13 + | SecurityProtocolType.Tls12 + | SecurityProtocolType.Tls11; + } + } +} \ No newline at end of file diff --git a/PlanetDotnet/Services/Foundations/Feeds/IFeedService.cs b/PlanetDotnet/Services/Foundations/Feeds/IFeedService.cs new file mode 100644 index 0000000..8218eb8 --- /dev/null +++ b/PlanetDotnet/Services/Foundations/Feeds/IFeedService.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.ServiceModel.Syndication; +using System.Threading.Tasks; + +namespace PlanetDotnet.Services.Foundations.Feeds +{ + public interface IFeedService + { + Task LoadFeedAsync(int? numberOfItems, string languageCode = "mixed"); + } +} diff --git a/PlanetDotnet/Services/Processings/Feeds/FeedProcessingService.cs b/PlanetDotnet/Services/Processings/Feeds/FeedProcessingService.cs new file mode 100644 index 0000000..6a7e06d --- /dev/null +++ b/PlanetDotnet/Services/Processings/Feeds/FeedProcessingService.cs @@ -0,0 +1,70 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using PlanetDotnet.Brokers.Authors; +using PlanetDotnet.Brokers.Loggings; +using PlanetDotnet.Brokers.Serializations; +using PlanetDotnet.Brokers.Storages; +using PlanetDotnet.Services.Foundations.Feeds; + +namespace PlanetDotnet.Services.Processings.Feeds +{ + public class FeedProcessingService : IFeedProcessingService + { + private readonly IFeedService feedService; + private readonly IStorageBroker storageBroker; + private readonly IAuthorBroker authorBroker; + private readonly ISerializationBroker serializationBroker; + private readonly ILoggingBroker loggingBroker; + + public FeedProcessingService( + IFeedService feedService, + IStorageBroker storageBroker, + IAuthorBroker authorBroker, + ISerializationBroker serializationBroker, + ILoggingBroker loggingBroker) + { + this.feedService = feedService; + this.storageBroker = storageBroker; + this.authorBroker = authorBroker; + this.serializationBroker = serializationBroker; + this.loggingBroker = loggingBroker; + } + + public async ValueTask ProcessFeedLoadingAsync() + { + await this.storageBroker.InitializeAsync(); + + var authors = await this.authorBroker.GetAllAuthorsAsync(); + + var languages = authors.Select(author => author.FeedLanguageCode).Distinct().ToList(); + + var mainCulture = CultureInfo.CurrentCulture; + + foreach (var language in languages) + { + try + { + CultureInfo.CurrentCulture = new CultureInfo(language); + this.loggingBroker.LogInformation($"Loading {language} combined author feed"); + var feed = await feedService.LoadFeedAsync(null, language); + using var stream = await this.serializationBroker.SerializeFeedAsync(feed); + await this.storageBroker.UploadBlobAsync(language, stream); + } + catch (Exception ex) + { + this.loggingBroker.LogError(ex, "error"); + } + } + + CultureInfo.CurrentCulture = mainCulture; + } + } +} diff --git a/PlanetDotnet/Services/Processings/Feeds/IFeedProcessingService.cs b/PlanetDotnet/Services/Processings/Feeds/IFeedProcessingService.cs new file mode 100644 index 0000000..2d08eda --- /dev/null +++ b/PlanetDotnet/Services/Processings/Feeds/IFeedProcessingService.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using System.Threading.Tasks; + +namespace PlanetDotnet.Services.Processings.Feeds +{ + public interface IFeedProcessingService + { + ValueTask ProcessFeedLoadingAsync(); + } +} diff --git a/PlanetDotnet/Startup.cs b/PlanetDotnet/Startup.cs new file mode 100644 index 0000000..89b78b2 --- /dev/null +++ b/PlanetDotnet/Startup.cs @@ -0,0 +1,40 @@ +// --------------------------------------------------------------- +// Copyright (c) 2023 Planet Dotnet. All rights reserved. +// Licensed under the MIT License. +// See License.txt in the project root for license information. +// --------------------------------------------------------------- + +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PlanetDotnet.Brokers.Authors; +using PlanetDotnet.Brokers.DateTimes; +using PlanetDotnet.Brokers.Feeds; +using PlanetDotnet.Brokers.Loggings; +using PlanetDotnet.Brokers.Serializations; +using PlanetDotnet.Brokers.Storages; +using PlanetDotnet.Services.Foundations.Authors; +using PlanetDotnet.Services.Foundations.Feeds; +using PlanetDotnet.Services.Processings.Feeds; + +[assembly: FunctionsStartup(typeof(PlanetDotnet.Startup))] +namespace PlanetDotnet +{ + public class Startup : FunctionsStartup + { + public override void Configure(IFunctionsHostBuilder builder) + { + builder.Services.AddLogging(); + builder.Services.AddSingleton, Logger>(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + } +} diff --git a/PlanetDotnet/host.json b/PlanetDotnet/host.json index bb3b8da..fbe7613 100644 --- a/PlanetDotnet/host.json +++ b/PlanetDotnet/host.json @@ -1,11 +1,15 @@ { - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingExcludedTypes": "Request", - "samplingSettings": { - "isEnabled": true - } - } + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + }, + "logLevel": { + "PlanetDotnet.Brokers.Loggings.LoggingBroker": "Information" } + } } \ No newline at end of file diff --git a/PlanetDotnet/local.settings.json b/PlanetDotnet/local.settings.json deleted file mode 100644 index 6f0b5a7..0000000 --- a/PlanetDotnet/local.settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "dotnet", - "RssFeedTitle": "Planet Xamarin", - "RssFeedDescription": "An aggregated feed from the Xamarin community", - "RssFeedUrl": "https://www.planetxamarin.com/feed", - "RssFeedImageUrl": "https://www.planetxamarin.com/Content/Logo.png", - "FeedBlobStorage": "" - } -} \ No newline at end of file diff --git a/PlanetDotnetAuthors/PlanetDotnetAuthors.csproj b/PlanetDotnetAuthors/PlanetDotnetAuthors.csproj deleted file mode 100644 index cb36460..0000000 --- a/PlanetDotnetAuthors/PlanetDotnetAuthors.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - netstandard2.0 - latest - - - - - - - - - - - diff --git a/author-schema.json b/author-schema.json deleted file mode 100644 index 4388d1e..0000000 --- a/author-schema.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - { - "definitions": { - "GeoPosition": { - "type": "object", - "properties": { - "lat": { - "type": "number" - }, - "lon": { - "type": "number" - } - }, - "required": [ - "lat", - "lon" - ] - } - }, - "type": "object", - "properties": { - "firstName": { - "description": "Author First Name", - "type": "string" - }, - "lastName": { - "description": "Author Last Name", - "type": "string" - }, - "stateOrRegion": { - "description": "Author State or Region", - "type": "string" - }, - "emailAddress": { - "description": "E-mail address", - "type": "string", - "format": "email" - }, - "tagOrBio": { - "description": "Tagline or bio", - "type": "string" - }, - "webSite": { - "description": "Author Web Site", - "type": "string", - "format": "uri" - }, - "feedUris": { - "description": "Feed URIs", - "type": "array", - "items": { - "type": [ - "string", - "null" - ], - "format": "uri" - } - }, - "twitterHandle": { - "description": "Author Twitter Handle", - "type": "string" - }, - "gravatarHash": { - "description": "Author Gravatar Hash", - "type": "string" - }, - "githubHandle": { - "description": "Author GitHub Handle", - "type": "string" - }, - "position": { - "description": "Author GeoPosition", - "$ref": "#/definitions/GeoPosition" - }, - "languageCode": { - "description": "Feed Language Code. ISO 639-1 format.", - "type": "string" - } - }, - "required": [ - "emailAddress", - "webSite", - "feedUris", - "githubHandle", - "languageCode" - ] - } -} \ No newline at end of file