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